authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 14:50:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 14:50:01-07:00
log8d1858d9d608d78b67268cdc52bceb4302ae8870
tree0a6797844004741fa890b2c39f6b1a77555e84d4
parenteb459b5afaf42621a6cb2e698715fa017e26e5d7
signature Signed by PGP key B5690EEEBB952194

refactor(source-control): Bitbucket lives in @t3tools/source-control-bitbucket (#17597)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

43 files changed, 5498 insertions(+), 5308 deletions(-)

apps/server/package.json+1
...@@ -46,6 +46,7 @@...@@ -46,6 +46,7 @@
46 "@t3tools/provider-opencode": "workspace:*",46 "@t3tools/provider-opencode": "workspace:*",
47 "@t3tools/provider-pi": "workspace:*",47 "@t3tools/provider-pi": "workspace:*",
48 "@t3tools/source-control-azure-devops": "workspace:*",48 "@t3tools/source-control-azure-devops": "workspace:*",
49 "@t3tools/source-control-bitbucket": "workspace:*",
49 "@t3tools/source-control-core": "workspace:*",50 "@t3tools/source-control-core": "workspace:*",
50 "@t3tools/source-control-forgejo": "workspace:*",51 "@t3tools/source-control-forgejo": "workspace:*",
51 "@t3tools/source-control-gitlab": "workspace:*",52 "@t3tools/source-control-gitlab": "workspace:*",
apps/server/scripts/evaluate-thread-titles.ts+1-7
...@@ -25,7 +25,6 @@ import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";...@@ -25,7 +25,6 @@ import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";
25import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";25import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";
26import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";26import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
27import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts";27import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts";
28import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts";
29import * as ServerSettings from "../src/serverSettings.ts";28import * as ServerSettings from "../src/serverSettings.ts";
30import * as VcsProcess from "../src/vcs/VcsProcess.ts";29import * as VcsProcess from "../src/vcs/VcsProcess.ts";
31import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";30import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";
...@@ -149,12 +148,7 @@ await Effect.runPromise(...@@ -149,12 +148,7 @@ await Effect.runPromise(
149 ProcessRunner.layer,148 ProcessRunner.layer,
150 SourceControlProviderRegistry.layer.pipe(149 SourceControlProviderRegistry.layer.pipe(
151 Layer.provide(150 Layer.provide(
152 Layer.mergeAll(151 Layer.mergeAll(GitHubApi.layerWithDependencies, SourceControlBuiltInDrivers.layer),
153 GitHubApi.layerWithDependencies,
154 SourceControlBuiltInDrivers.layer,
155 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.
156 BitbucketApi.layer,
157 ),
158 ),152 ),
159 // Default settings: no saved Bitbucket token, gh's own GitHub account choice.153 // Default settings: no saved Bitbucket token, gh's own GitHub account choice.
160 Layer.provide(ServerSettings.layerTest()),154 Layer.provide(ServerSettings.layerTest()),
apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts deleted-1127
...@@ -1,1127 +0,0 @@
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 "../sourceControl/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(callAt(0).url).toContain("/user/permissions/repositories");
1002 assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/web"');
1003 }),
1004 );
1005
1006 it.effect("escapes a repository name before it goes inside a filter literal", () =>
1007 Effect.gen(function* () {
1008 mockedRequest.mockReturnValue(Effect.succeed(response(JSON.stringify({ values: [] }))));
1009 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1010
1011 yield* api.getRepositoryPermission({ repository: 'acme/we"b' });
1012
1013 // A quote would otherwise end the literal and leave the rest standing as filter syntax.
1014 assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/we\\"b"');
1015 }),
1016 );
1017
1018 it.effect(
1019 "reads a removed permissions endpoint as granted rather than failing the merge on it",
1020 () =>
1021 Effect.gen(function* () {
1022 // Bitbucket retired /user/permissions/repositories under CHANGE-2770: every account now
1023 // gets HTTP 410 here, whatever it may do.
1024 mockedRequest.mockReturnValue(
1025 Effect.fail(
1026 new BitbucketApi.BitbucketResponseError({
1027 operation: "request",
1028 status: 410,
1029 responseBodyLength: 0,
1030 }),
1031 ),
1032 );
1033 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1034
1035 assert.isTrue(yield* api.getRepositoryPermission({ repository: "acme/web" }));
1036 }),
1037 );
1038
1039 it.effect("still fails the permission read on a failure that is not the removed endpoint", () =>
1040 Effect.gen(function* () {
1041 mockedRequest.mockReturnValue(
1042 Effect.fail(
1043 new BitbucketApi.BitbucketResponseError({
1044 operation: "request",
1045 status: 401,
1046 responseBodyLength: 0,
1047 }),
1048 ),
1049 );
1050 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1051
1052 const error = yield* Effect.flip(api.getRepositoryPermission({ repository: "acme/web" }));
1053
1054 assert.strictEqual(error._tag, "BitbucketResponseError");
1055 }),
1056 );
1057
1058 it.effect("reads the workspace's people and marks whoever is already a reviewer", () =>
1059 Effect.gen(function* () {
1060 mockedRequest
1061 .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] }))))
1062 .mockReturnValueOnce(
1063 Effect.succeed(
1064 response(
1065 JSON.stringify({ values: [{ user: bilal }, { user: octocat }, { user: hubot }] }),
1066 ),
1067 ),
1068 );
1069 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1070
1071 const list = yield* api.listReviewerCandidates({ repository: "acme/web", number: 7 });
1072
1073 // The people live on the workspace: nothing on a repository lists who may review it.
1074 expect(callAt(1).url).toBe("/workspaces/acme/members?pagelen=50");
1075 expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([
1076 ["{octocat}", true],
1077 ["{hubot}", false],
1078 ]);
1079 assert.isFalse(list.truncated);
1080 }),
1081 );
1082
1083 it.effect("writes the reviewer set back with the one being asked added to it", () =>
1084 Effect.gen(function* () {
1085 mockedRequest
1086 .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] }))))
1087 .mockReturnValueOnce(Effect.succeed(response("{}")));
1088 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1089
1090 yield* api.setReviewerRequest({
1091 repository: "acme/web",
1092 number: 7,
1093 reviewers: [{ id: "{hubot}" }],
1094 requested: true,
1095 });
1096
1097 // Bitbucket writes `reviewers` whole, so the one already on the pull request travels with
1098 // the new one or the request would take them off it.
1099 const call = callAt(1);
1100 expect(call.method).toBe("PUT");
1101 expect(call.url).toBe("/repositories/acme/web/pullrequests/7");
1102 expect(JSON.parse(call.body ?? "")).toEqual({
1103 reviewers: [{ uuid: "{octocat}" }, { uuid: "{hubot}" }],
1104 });
1105 }),
1106 );
1107
1108 it.effect("takes a reviewer out of the set rather than clearing it", () =>
1109 Effect.gen(function* () {
1110 mockedRequest
1111 .mockReturnValueOnce(
1112 Effect.succeed(response(pullRequestJson({ reviewers: [octocat, hubot] }))),
1113 )
1114 .mockReturnValueOnce(Effect.succeed(response("{}")));
1115 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1116
1117 yield* api.setReviewerRequest({
1118 repository: "acme/web",
1119 number: 7,
1120 reviewers: [{ id: "{hubot}" }],
1121 requested: false,
1122 });
1123
1124 expect(JSON.parse(callAt(1).body ?? "")).toEqual({ reviewers: [{ uuid: "{octocat}" }] });
1125 }),
1126 );
1127});
apps/server/src/pullRequest/BitbucketPullRequestApi.ts deleted-943
...@@ -1,943 +0,0 @@
1import * as Cache from "effect/Cache";
2import * as Context from "effect/Context";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Layer from "effect/Layer";
7import * as Result from "effect/Result";
8import * as Schema from "effect/Schema";
9import type {
10 PullRequestAction,
11 PullRequestCheck,
12 PullRequestComment,
13 PullRequestCommit,
14 PullRequestListState,
15 PullRequestMergeMethod,
16 PullRequestMergeability,
17 PullRequestReviewCommentDraft,
18 PullRequestReviewPosition,
19 PullRequestReviewThread,
20 PullRequestReviewVerdict,
21 PullRequestReviewerCandidateList,
22} from "@t3tools/contracts";
23
24import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
25import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions";
26import {
27 buildReviewThreads,
28 decodeCommentsJson,
29 decodeCommitsJson,
30 decodeConflictsJson,
31 decodeDiffstatJson,
32 decodePullRequestJson,
33 decodePullRequestPageJson,
34 decodeRepositoryPermissionJson,
35 decodeStatusesJson,
36 decodeViewerJson,
37 decodeWorkspaceMembersJson,
38 type BitbucketDiffStat,
39 type BitbucketPullRequest,
40 type BitbucketRawComment,
41} from "./bitbucketPullRequestJson.ts";
42import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider";
43
44/**
45 * Names the read that produced unusable output, so a failure reports the call it came from
46 * rather than borrowing another operation's message.
47 */
48export class BitbucketPullRequestReadError extends Schema.TaggedError<BitbucketPullRequestReadError>()(
49 "BitbucketPullRequestReadError",
50 {
51 operation: Schema.String,
52 cause: Schema.Defect(),
53 },
54) {
55 get detail(): string {
56 return `Bitbucket returned an unreadable ${this.operation} response.`;
57 }
58
59 override get message(): string {
60 return `Bitbucket failed in ${this.operation}: ${this.detail}`;
61 }
62}
63
64/** Not a decode failure: Bitbucket answered, the account it answered for just has no handle. */
65export class BitbucketViewerUnavailableError extends Schema.TaggedError<BitbucketViewerUnavailableError>()(
66 "BitbucketViewerUnavailableError",
67 {},
68) {
69 get detail(): string {
70 return "Bitbucket returned no account name for the configured credentials.";
71 }
72
73 override get message(): string {
74 return `Bitbucket failed in getViewer: ${this.detail}`;
75 }
76}
77
78/** A repository that is not `workspace/slug`, which is the only form Bitbucket addresses. */
79export class BitbucketRepositoryUnsupportedError extends Schema.TaggedError<BitbucketRepositoryUnsupportedError>()(
80 "BitbucketRepositoryUnsupportedError",
81 {
82 repository: Schema.String,
83 },
84) {
85 get detail(): string {
86 return "A Bitbucket repository is addressed as workspace/repository.";
87 }
88
89 override get message(): string {
90 return `Bitbucket failed in resolveRepository: ${this.detail}`;
91 }
92}
93
94/** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */
95export class BitbucketDiffCommitError extends Schema.TaggedError<BitbucketDiffCommitError>()(
96 "BitbucketDiffCommitError",
97 {},
98) {
99 get detail(): string {
100 return "The named commit was not a commit sha.";
101 }
102
103 override get message(): string {
104 return `Bitbucket failed in getPullRequestDiff: ${this.detail}`;
105 }
106}
107
108export type BitbucketPullRequestApiError =
109 | BitbucketApi.BitbucketApiError
110 | BitbucketPullRequestReadError
111 | BitbucketViewerUnavailableError
112 | BitbucketRepositoryUnsupportedError
113 | BitbucketDiffCommitError;
114
115/**
116 * `/user/permissions/repositories` answering CHANGE-2770's removal notice rather than a
117 * permission — Bitbucket sends this for every account now, not only ones it would have refused.
118 */
119function isRepositoryPermissionRemovedError(
120 error: BitbucketPullRequestApiError,
121): error is BitbucketApi.BitbucketResponseError {
122 return error._tag === "BitbucketResponseError" && error.status === 410;
123}
124
125/**
126 * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no
127 * error at all, so this is a number to respect rather than to push against.
128 */
129const MAX_PAGE_SIZE = 50;
130/** Pages to walk before a listing is reported as truncated. */
131const MAX_LIST_PAGES = 10;
132/** The page size for pull request conversations, commits, and checks. */
133const CONVERSATION_PAGE_SIZE = 50;
134/**
135 * Pages of the conversation to follow before it is reported as truncated. Bitbucket serves
136 * fifty comments a page, so this is five hundred — beyond any pull request a person is reading,
137 * and an end to a walk whose only other stop is Bitbucket running out.
138 */
139const CONVERSATION_PAGES = 10;
140/** The same ceiling the gh and glab diff reads use. */
141const DIFF_MAX_BYTES = 8 * 1024 * 1024;
142/**
143 * Deliberately far shorter than the window the caller holds versions for: a refresh drops what the
144 * caller holds precisely so the next read reaches Bitbucket, and this must not be what answers it
145 * instead.
146 */
147const REVISION_PATCH_TTL = Duration.seconds(5);
148const REVISION_PATCH_CAPACITY = 16;
149export interface BitbucketPullRequestBatch {
150 readonly items: ReadonlyArray<BitbucketPullRequest>;
151 readonly truncated: boolean;
152}
153
154export class BitbucketPullRequestApi extends Context.Service<
155 BitbucketPullRequestApi,
156 {
157 /** A function rather than a value, so the request is built per call and not at layer time. */
158 readonly getViewer: () => Effect.Effect<string, BitbucketPullRequestApiError>;
159
160 readonly listPullRequests: (input: {
161 readonly repository: string;
162 readonly state: PullRequestListState;
163 readonly limit: number;
164 /** Free text, matched against a pull request's title and description. */
165 readonly query?: string | undefined;
166 /** Where to carry on from, as a predicate on `updated_on` beside any other. */
167 readonly cursor?: ProviderListCursor | undefined;
168 }) => Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError>;
169
170 readonly getPullRequest: (input: {
171 readonly repository: string;
172 readonly number: number;
173 }) => Effect.Effect<BitbucketPullRequest, BitbucketPullRequestApiError>;
174
175 /** True where the credentials can write to the repository, which is what merging needs. */
176 readonly getRepositoryPermission: (input: {
177 readonly repository: string;
178 }) => Effect.Effect<boolean, BitbucketPullRequestApiError>;
179
180 readonly getPullRequestDiff: (input: {
181 readonly repository: string;
182 readonly number: number;
183 /** One commit's own changes, rather than everything the pull request carries. */
184 readonly commit?: string | undefined;
185 }) => Effect.Effect<
186 { readonly patch: string; readonly truncated: boolean },
187 BitbucketPullRequestApiError
188 >;
189
190 readonly getDiffStat: (input: {
191 readonly repository: string;
192 readonly number: number;
193 }) => Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError>;
194
195 /**
196 * What the pull request's head has of each of these paths, as opaque ids, read off the pull
197 * request's own patch, the only place Bitbucket states a file's version. A path the patch does
198 * not carry is answered as the empty revision, and left out altogether when the patch was cut
199 * short at the byte ceiling and so cannot be spoken for.
200 *
201 * Answers with every file the patch carries rather than only the paths asked about, since
202 * reading one file's version here means parsing all of them. `complete` is false for a patch
203 * cut short, which cannot speak for what came after the cut.
204 */
205 readonly getFileRevisions: (input: {
206 readonly repository: string;
207 readonly number: number;
208 readonly paths: ReadonlyArray<string>;
209 }) => Effect.Effect<
210 { readonly revisions: ReadonlyMap<string, string>; readonly complete: boolean },
211 BitbucketPullRequestApiError
212 >;
213
214 readonly getMergeability: (input: {
215 readonly repository: string;
216 readonly number: number;
217 }) => Effect.Effect<PullRequestMergeability, BitbucketPullRequestApiError>;
218
219 readonly listComments: (input: {
220 readonly repository: string;
221 readonly number: number;
222 }) => Effect.Effect<
223 {
224 readonly comments: ReadonlyArray<PullRequestComment>;
225 readonly threads: ReadonlyArray<PullRequestReviewThread>;
226 readonly truncated: boolean;
227 },
228 BitbucketPullRequestApiError
229 >;
230
231 readonly listCommits: (input: {
232 readonly repository: string;
233 readonly number: number;
234 }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, BitbucketPullRequestApiError>;
235
236 readonly listChecks: (input: {
237 readonly repository: string;
238 readonly number: number;
239 }) => Effect.Effect<ReadonlyArray<PullRequestCheck>, BitbucketPullRequestApiError>;
240
241 /**
242 * Who this pull request may be sent to, and who it has already been sent to. Two reads at
243 * once, because Bitbucket keeps the people on the workspace and the reviewers on the pull
244 * request, and neither answers for the other.
245 */
246 readonly listReviewerCandidates: (input: {
247 readonly repository: string;
248 readonly number: number;
249 }) => Effect.Effect<PullRequestReviewerCandidateList, BitbucketPullRequestApiError>;
250
251 readonly setReviewerRequest: (input: {
252 readonly repository: string;
253 readonly number: number;
254 readonly reviewers: ReadonlyArray<{ readonly id: string }>;
255 readonly requested: boolean;
256 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
257
258 readonly runAction: (input: {
259 readonly repository: string;
260 readonly number: number;
261 readonly action: PullRequestAction;
262 readonly mergeMethod?: PullRequestMergeMethod;
263 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
264
265 readonly updateChangeRequest: (input: {
266 readonly repository: string;
267 readonly number: number;
268 readonly title?: string | undefined;
269 readonly body?: string | undefined;
270 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
271
272 readonly comment: (input: {
273 readonly repository: string;
274 readonly number: number;
275 readonly body: string;
276 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
277
278 readonly updateComment: (input: {
279 readonly repository: string;
280 readonly number: number;
281 readonly commentId: string;
282 readonly body: string;
283 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
284
285 readonly submitReview: (input: {
286 readonly repository: string;
287 readonly number: number;
288 readonly verdict: PullRequestReviewVerdict;
289 readonly body: string;
290 readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;
291 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
292
293 readonly replyToComment: (input: {
294 readonly repository: string;
295 readonly number: number;
296 readonly commentId: string;
297 readonly body: string;
298 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
299
300 readonly setCommentResolution: (input: {
301 readonly repository: string;
302 readonly number: number;
303 readonly commentId: string;
304 readonly resolved: boolean;
305 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
306 }
307>()("t3/pullRequest/BitbucketPullRequestApi") {}
308
309/** `workspace/slug`; Bitbucket has no deeper nesting to address. */
310function repositorySegments(
311 repository: string,
312): Result.Result<
313 { readonly workspace: string; readonly slug: string },
314 BitbucketRepositoryUnsupportedError
315> {
316 const segments = repository
317 .split("/")
318 .map((segment) => segment.trim())
319 .filter((segment) => segment.length > 0);
320 const [workspace, slug] = segments;
321 if (segments.length !== 2 || workspace === undefined || slug === undefined) {
322 return Result.fail(new BitbucketRepositoryUnsupportedError({ repository }));
323 }
324 return Result.succeed({ workspace, slug });
325}
326
327function repositoryPathOf(segments: { readonly workspace: string; readonly slug: string }): string {
328 return `/repositories/${encodeURIComponent(segments.workspace)}/${encodeURIComponent(
329 segments.slug,
330 )}`;
331}
332
333/**
334 * A commit sha arrives from the reader and goes straight into a request path, so it is checked
335 * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a
336 * whole sha.
337 */
338function isCommitSha(value: string): boolean {
339 return /^[0-9a-f]{7,64}$/i.test(value);
340}
341
342/**
343 * Bitbucket unions repeated `state` parameters, so a tab that spans several of its states asks
344 * for each. It separates a declined pull request from one superseded by another, and both read
345 * as closed here.
346 */
347function stateParams(state: PullRequestListState): ReadonlyArray<string> {
348 switch (state) {
349 case "open":
350 return ["OPEN"];
351 case "merged":
352 return ["MERGED"];
353 case "closed":
354 return ["DECLINED", "SUPERSEDED"];
355 case "all":
356 return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"];
357 }
358}
359
360/**
361 * Bitbucket has no search term, only a filter expression, so free text becomes one: a
362 * case-insensitive contains against the two fields a pull request carries words in. The
363 * parentheses matter, because the expression is ANDed with the state filter beside it and an
364 * unbracketed `OR` would swallow it.
365 *
366 * A string literal in that grammar is delimited by double quotes, so the reader's text is
367 * escaped before it goes inside one — a quote would otherwise end the literal and leave the
368 * rest of the text standing as filter syntax. The whole expression is then URL-encoded, so
369 * nothing in it reaches the query string as a parameter of its own.
370 */
371function searchFilter(query: string): string {
372 const literal = filterLiteral(query);
373 return `(title ~ "${literal}" OR description ~ "${literal}")`;
374}
375
376/**
377 * Text as a string literal of Bitbucket's filter grammar. The backslash is escaped first, or
378 * escaping the quote would only produce a literal backslash followed by a live quote.
379 */
380function filterLiteral(value: string): string {
381 return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
382}
383
384/** Bitbucket's merge strategies, named differently from the three the contract carries. */
385function mergeStrategy(method: PullRequestMergeMethod | undefined): string {
386 switch (method) {
387 case "squash":
388 return "squash";
389 case "rebase":
390 // The linear history GitHub calls "rebase and merge".
391 return "rebase_fast_forward";
392 default:
393 return "merge_commit";
394 }
395}
396
397function bitbucketReviewPosition(
398 position: PullRequestReviewPosition,
399): { readonly from: number } | { readonly to: number } {
400 switch (position.kind) {
401 case "added":
402 return { to: position.newLine };
403 case "deleted":
404 return { from: position.oldLine };
405 case "context":
406 return position.side === "left" ? { from: position.oldLine } : { to: position.newLine };
407 }
408}
409
410/** @public Service construction is part of the canonical Effect module API. */
411export const make = Effect.gen(function* () {
412 const bitbucket = yield* BitbucketApi.BitbucketApi;
413
414 /**
415 * The repository's own path, and the workspace above it — which the people who may review are
416 * kept on rather than on the repository, so both are handed over at once.
417 */
418 const withRepository = <A>(
419 repository: string,
420 use: (path: string, workspace: string) => Effect.Effect<A, BitbucketPullRequestApiError>,
421 ): Effect.Effect<A, BitbucketPullRequestApiError> => {
422 const segments = repositorySegments(repository);
423 return Result.isSuccess(segments)
424 ? use(repositoryPathOf(segments.success), segments.success.workspace)
425 : Effect.fail(segments.failure);
426 };
427
428 /**
429 * Bitbucket pages with a cursor rather than an offset, so the walk follows the `next` URL it
430 * sends. It stops once the caller's page is filled, when Bitbucket reports no next page, or at
431 * the page cap — and anything but running out of pages means there is more to be had.
432 */
433 const listPage = (input: {
434 readonly url: string;
435 readonly limit: number;
436 readonly page: number;
437 readonly collected: ReadonlyArray<BitbucketPullRequest>;
438 }): Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError> =>
439 bitbucket.request({ method: "GET", url: input.url }).pipe(
440 Effect.flatMap((response) => {
441 const decoded = decodePullRequestPageJson(response.body);
442 if (!Result.isSuccess(decoded)) {
443 return Effect.fail(
444 new BitbucketPullRequestReadError({
445 operation: "listPullRequests",
446 cause: decoded.failure,
447 }),
448 );
449 }
450 const collected = [...input.collected, ...decoded.success.items];
451 const next = decoded.success.next;
452 if (next === null || collected.length >= input.limit || input.page >= MAX_LIST_PAGES) {
453 return Effect.succeed({
454 items: collected.slice(0, input.limit),
455 // Bitbucket pages in fifties whatever was asked for, so a walk that stopped on the
456 // count rather than on the last page is holding rows it is about to drop. Those are
457 // more results just as surely as another page would be.
458 truncated: next !== null || collected.length > input.limit,
459 });
460 }
461 return listPage({ ...input, url: next, page: input.page + 1, collected });
462 }),
463 );
464
465 const readPage = <A>(input: {
466 readonly operation: string;
467 readonly url: string;
468 readonly decode: (body: string) => Result.Result<A, unknown>;
469 }): Effect.Effect<A, BitbucketPullRequestApiError> =>
470 bitbucket.request({ method: "GET", url: input.url }).pipe(
471 Effect.flatMap((response) => {
472 const decoded = input.decode(response.body);
473 return Result.isSuccess(decoded)
474 ? Effect.succeed(decoded.success)
475 : Effect.fail(
476 new BitbucketPullRequestReadError({
477 operation: input.operation,
478 cause: decoded.failure,
479 }),
480 );
481 }),
482 );
483
484 /**
485 * The conversation, following the `next` Bitbucket sends until it sends none. Threads are
486 * assembled once at the end rather than per page, because a reply and the remark it answers
487 * can land either side of a page boundary.
488 */
489 const commentsPage = (input: {
490 readonly url: string;
491 readonly page: number;
492 readonly comments: ReadonlyArray<PullRequestComment>;
493 readonly entries: ReadonlyArray<BitbucketRawComment>;
494 }): Effect.Effect<
495 {
496 readonly comments: ReadonlyArray<PullRequestComment>;
497 readonly threads: ReadonlyArray<PullRequestReviewThread>;
498 readonly truncated: boolean;
499 },
500 BitbucketPullRequestApiError
501 > =>
502 readPage({ operation: "listComments", url: input.url, decode: decodeCommentsJson }).pipe(
503 Effect.flatMap((page) => {
504 const comments = [...input.comments, ...page.comments];
505 const entries = [...input.entries, ...page.entries];
506 if (page.next !== null && input.page < CONVERSATION_PAGES) {
507 return commentsPage({ url: page.next, page: input.page + 1, comments, entries });
508 }
509 return Effect.succeed({
510 comments,
511 threads: buildReviewThreads(entries),
512 truncated: page.next !== null,
513 });
514 }),
515 );
516
517 /** Walks a Bitbucket cursor to its end and combines every decoded item. */
518 const itemPages = <A>(input: {
519 readonly operation: string;
520 readonly url: string;
521 readonly decode: (
522 body: string,
523 ) => Result.Result<{ readonly items: ReadonlyArray<A>; readonly next: string | null }, unknown>;
524 readonly items: ReadonlyArray<A>;
525 /** Commit pages are individually oldest-first, so older pages are prepended. */
526 readonly prepend: boolean;
527 }): Effect.Effect<ReadonlyArray<A>, BitbucketPullRequestApiError> =>
528 readPage({ operation: input.operation, url: input.url, decode: input.decode }).pipe(
529 Effect.flatMap((page) => {
530 const items = input.prepend
531 ? [...page.items, ...input.items]
532 : [...input.items, ...page.items];
533 return page.next === null
534 ? Effect.succeed(items)
535 : itemPages({ ...input, url: page.next, items });
536 }),
537 );
538
539 /** Diffstat has one aggregate per page, so its totals are folded while following `next`. */
540 const diffStatPages = (input: {
541 readonly url: string;
542 readonly totals: BitbucketDiffStat;
543 }): Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError> =>
544 readPage({ operation: "getDiffStat", url: input.url, decode: decodeDiffstatJson }).pipe(
545 Effect.flatMap((page) => {
546 const totals = {
547 additions: input.totals.additions + page.additions,
548 deletions: input.totals.deletions + page.deletions,
549 changedFiles: input.totals.changedFiles + page.changedFiles,
550 };
551 return page.next === null
552 ? Effect.succeed(totals)
553 : diffStatPages({ url: page.next, totals });
554 }),
555 );
556
557 const pullRequestDiff = (input: {
558 readonly repository: string;
559 readonly number: number;
560 readonly commit?: string | undefined;
561 }): Effect.Effect<
562 { readonly patch: string; readonly truncated: boolean },
563 BitbucketPullRequestApiError
564 > =>
565 input.commit !== undefined && !isCommitSha(input.commit)
566 ? Effect.fail(new BitbucketDiffCommitError())
567 : withRepository(input.repository, (path) =>
568 // Already a unified patch, so it needs no decoding at all, only a bound, which a
569 // diff of any size would otherwise ignore. A commit's own patch sits beside the pull
570 // request's at `/diff/{sha}` and reads the same way.
571 bitbucket
572 .request({
573 method: "GET",
574 url:
575 input.commit === undefined
576 ? `${path}/pullrequests/${input.number}/diff`
577 : `${path}/diff/${input.commit}`,
578 maxBytes: DIFF_MAX_BYTES,
579 })
580 .pipe(
581 Effect.map((response) => ({ patch: response.body, truncated: response.truncated })),
582 ),
583 );
584
585 /**
586 * What the version reads that come one tick at a time want out of the pull request's whole
587 * patch, shared between them. The parsed answer rather than the patch, which at this capacity
588 * would hold sixteen bodies of up to the byte ceiling each resident, and saves walking a patch
589 * of a hundred thousand lines again on every tick.
590 */
591 const revisionPatches = yield* Cache.makeWith(
592 (key: string) => {
593 const [repository, number] = JSON.parse(key) as [string, number];
594 return pullRequestDiff({ repository, number }).pipe(
595 Effect.map((diff) => ({
596 revisions: parseDiffFileRevisions(diff.patch),
597 truncated: diff.truncated,
598 })),
599 );
600 },
601 {
602 capacity: REVISION_PATCH_CAPACITY,
603 // A failure is not held: the tick after it should reach Bitbucket rather than be handed the
604 // same error for as long as a good patch would have lasted.
605 timeToLive: (exit) => (Exit.isSuccess(exit) ? REVISION_PATCH_TTL : Duration.zero),
606 },
607 );
608
609 return BitbucketPullRequestApi.of({
610 getViewer: () =>
611 bitbucket.request({ method: "GET", url: "/user" }).pipe(
612 Effect.flatMap((response): Effect.Effect<string, BitbucketPullRequestApiError> => {
613 const decoded = decodeViewerJson(response.body);
614 if (!Result.isSuccess(decoded)) {
615 return Effect.fail(
616 new BitbucketPullRequestReadError({ operation: "getViewer", cause: decoded.failure }),
617 );
618 }
619 return decoded.success === null
620 ? Effect.fail(new BitbucketViewerUnavailableError())
621 : Effect.succeed(decoded.success);
622 }),
623 ),
624
625 listPullRequests: (input) =>
626 withRepository(input.repository, (path) => {
627 const search = input.query?.trim() ?? "";
628 // Both narrowings share the one `q` Bitbucket takes, so they are ANDed rather than one
629 // replacing the other. The boundary instant is read inclusively — the rows already sent
630 // at it come back and the caller drops them, which is what keeps their neighbours at the
631 // same instant from being skipped. A date is a bare literal in this grammar, and this one
632 // was checked against a timestamp's shape before it got here.
633 const predicates = [
634 ...(search.length === 0 ? [] : [searchFilter(search)]),
635 ...(input.cursor === undefined ? [] : [`updated_on <= ${input.cursor.updatedBefore}`]),
636 ];
637 return listPage({
638 // Reviewers are not on a listing by default, and `viewerReviewRequested` needs them.
639 url: `${path}/pullrequests?${stateParams(input.state)
640 .map((state) => `state=${state}`)
641 .join("&")}&pagelen=${MAX_PAGE_SIZE}&sort=-updated_on&fields=%2Bvalues.reviewers${
642 predicates.length === 0 ? "" : `&q=${encodeURIComponent(predicates.join(" AND "))}`
643 }`,
644 limit: input.limit,
645 page: 1,
646 collected: [],
647 });
648 }),
649
650 getPullRequest: (input) =>
651 withRepository(input.repository, (path) =>
652 readPage({
653 operation: "getPullRequest",
654 url: `${path}/pullrequests/${input.number}`,
655 decode: decodePullRequestJson,
656 }),
657 ),
658
659 // Nothing on the repository, the pull request or the workspace states what the credentials
660 // may do, so this endpoint is the one request Bitbucket makes unavoidable. It is asked
661 // alongside the reads the detail was already making, so it costs no round trip of its own.
662 //
663 // Bitbucket permanently removed this endpoint (CHANGE-2770): every account now gets HTTP 410
664 // in place of an answer, whatever it may do. That is the deprecated-endpoint signal, not a
665 // permission being refused, so it is read the same way an unreachable read already is
666 // elsewhere — as a permission that could not be learned, which grants rather than blocks, and
667 // leaves the actual merge or write to say why if the account may not do it. Any other failure
668 // (a bad token, a network fault, an unreadable body) still fails as it did before.
669 getRepositoryPermission: (input) =>
670 withRepository(input.repository, () =>
671 readPage({
672 operation: "getRepositoryPermission",
673 url: `/user/permissions/repositories?q=${encodeURIComponent(
674 `repository.full_name="${filterLiteral(input.repository.trim())}"`,
675 )}`,
676 decode: decodeRepositoryPermissionJson,
677 }),
678 ).pipe(Effect.catchIf(isRepositoryPermissionRemovedError, () => Effect.succeed(true))),
679
680 getPullRequestDiff: pullRequestDiff,
681
682 getFileRevisions: (input) =>
683 input.paths.length === 0
684 ? Effect.succeed({ revisions: new Map(), complete: false })
685 : Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe(
686 Effect.map((diff) => {
687 const revisions = new Map(diff.revisions);
688 // A patch cut short at the byte ceiling says nothing about the files past the cut,
689 // so those paths are left out rather than reported as removed.
690 if (!diff.truncated) {
691 for (const path of input.paths) {
692 if (!revisions.has(path)) revisions.set(path, "");
693 }
694 }
695 return { revisions, complete: !diff.truncated };
696 }),
697 ),
698
699 getDiffStat: (input) =>
700 withRepository(input.repository, (path) =>
701 diffStatPages({
702 url: `${path}/pullrequests/${input.number}/diffstat?pagelen=${MAX_PAGE_SIZE}`,
703 totals: { additions: 0, deletions: 0, changedFiles: 0 },
704 }),
705 ),
706
707 getMergeability: (input) =>
708 withRepository(input.repository, (path) =>
709 readPage({
710 operation: "getMergeability",
711 url: `${path}/pullrequests/${input.number}/conflicts`,
712 decode: decodeConflictsJson,
713 }),
714 ),
715
716 listComments: (input) =>
717 withRepository(input.repository, (path) =>
718 commentsPage({
719 url: `${path}/pullrequests/${input.number}/comments?pagelen=${CONVERSATION_PAGE_SIZE}`,
720 page: 1,
721 comments: [],
722 entries: [],
723 }),
724 ),
725
726 listCommits: (input) =>
727 withRepository(input.repository, (path) =>
728 itemPages({
729 operation: "listCommits",
730 url: `${path}/pullrequests/${input.number}/commits?pagelen=${CONVERSATION_PAGE_SIZE}`,
731 decode: decodeCommitsJson,
732 items: [],
733 prepend: true,
734 }),
735 ),
736
737 listChecks: (input) =>
738 withRepository(input.repository, (path) =>
739 itemPages({
740 operation: "listChecks",
741 url: `${path}/pullrequests/${input.number}/statuses?pagelen=${CONVERSATION_PAGE_SIZE}`,
742 decode: decodeStatusesJson,
743 items: [],
744 prepend: false,
745 }),
746 ),
747
748 listReviewerCandidates: (input) =>
749 withRepository(input.repository, (path, workspace) =>
750 Effect.all(
751 [
752 readPage({
753 operation: "getPullRequest",
754 url: `${path}/pullrequests/${input.number}`,
755 decode: decodePullRequestJson,
756 }),
757 readPage({
758 operation: "listReviewerCandidates",
759 url: `/workspaces/${encodeURIComponent(workspace)}/members?pagelen=${MAX_PAGE_SIZE}`,
760 decode: decodeWorkspaceMembersJson,
761 }),
762 ],
763 { concurrency: 2 },
764 ).pipe(
765 Effect.map(([pullRequest, members]) => {
766 const requested = new Set(pullRequest.reviewerIds);
767 const author = pullRequest.author?.login;
768 return {
769 // The author is dropped rather than shown unusable: Bitbucket refuses to make the
770 // person who opened a pull request its reviewer.
771 candidates: members.items.flatMap((candidate) =>
772 candidate.login === author
773 ? []
774 : [{ ...candidate, isRequested: requested.has(candidate.id) }],
775 ),
776 truncated: members.next !== null,
777 };
778 }),
779 ),
780 ),
781
782 setReviewerRequest: (input) =>
783 withRepository(input.repository, (path) => {
784 const pullRequest = `${path}/pullrequests/${input.number}`;
785 return readPage({
786 operation: "getPullRequest",
787 url: pullRequest,
788 decode: decodePullRequestJson,
789 }).pipe(
790 Effect.flatMap((current) => {
791 // Bitbucket has no endpoint that adds or removes one reviewer: the pull request's
792 // `reviewers` is written whole, so the set that is already there is read first and
793 // the change applied to it. Everything else about the pull request is left out of
794 // the body, which leaves it as it was.
795 const uuids = new Set(current.reviewerIds);
796 for (const reviewer of input.reviewers) {
797 if (input.requested) uuids.add(reviewer.id);
798 else uuids.delete(reviewer.id);
799 }
800 return bitbucket.request({
801 method: "PUT",
802 url: pullRequest,
803 body: JSON.stringify({ reviewers: [...uuids].map((uuid) => ({ uuid })) }),
804 });
805 }),
806 Effect.asVoid,
807 );
808 }),
809
810 runAction: (input) =>
811 withRepository(input.repository, (path) => {
812 const pullRequest = `${path}/pullrequests/${input.number}`;
813 // Only merge and close reach here: the provider declares the others unsupported, so the
814 // surface never offers them.
815 if (input.action === "merge") {
816 return bitbucket
817 .request({
818 method: "POST",
819 url: `${pullRequest}/merge`,
820 body: JSON.stringify({ merge_strategy: mergeStrategy(input.mergeMethod) }),
821 })
822 .pipe(Effect.asVoid);
823 }
824 return bitbucket
825 .request({ method: "POST", url: `${pullRequest}/decline` })
826 .pipe(Effect.asVoid);
827 }),
828
829 updateChangeRequest: (input) =>
830 withRepository(input.repository, (path) =>
831 // Only the words this call rewrites travel in the body: as `setReviewerRequest` above
832 // relies on, Bitbucket's PUT is a partial update, so any field left out is left as it
833 // was — sending `reviewers` back here would overwrite a change another user made to it
834 // between this call being issued and the request landing.
835 bitbucket
836 .request({
837 method: "PUT",
838 url: `${path}/pullrequests/${input.number}`,
839 body: JSON.stringify({
840 ...(input.title === undefined ? {} : { title: input.title }),
841 ...(input.body === undefined ? {} : { description: input.body }),
842 }),
843 })
844 .pipe(Effect.asVoid),
845 ),
846
847 comment: (input) =>
848 withRepository(input.repository, (path) =>
849 bitbucket
850 .request({
851 method: "POST",
852 url: `${path}/pullrequests/${input.number}/comments`,
853 // A JSON document rather than a form field, so the body stays text whatever it says.
854 body: JSON.stringify({ content: { raw: input.body } }),
855 })
856 .pipe(Effect.asVoid),
857 ),
858
859 updateComment: (input) =>
860 withRepository(input.repository, (path) =>
861 bitbucket
862 .request({
863 // Bitbucket keeps a pull request's remarks and its line comments in the one
864 // collection, so this endpoint rewrites either kind.
865 method: "PUT",
866 url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent(
867 input.commentId,
868 )}`,
869 body: JSON.stringify({ content: { raw: input.body } }),
870 })
871 .pipe(Effect.asVoid),
872 ),
873
874 submitReview: (input) =>
875 withRepository(input.repository, (path) =>
876 Effect.gen(function* () {
877 const pullRequest = `${path}/pullrequests/${input.number}`;
878 // Bitbucket has no pending review, so a review is replayed as the requests it is
879 // made of: the line comments, then the summary, then the verdict. The verdict goes
880 // last so a review that fails part-way is never left standing as an approval.
881 yield* Effect.forEach(
882 input.comments,
883 (comment) =>
884 bitbucket.request({
885 method: "POST",
886 url: `${pullRequest}/comments`,
887 body: JSON.stringify({
888 content: { raw: comment.body },
889 inline: {
890 path: comment.path,
891 ...bitbucketReviewPosition(comment.position),
892 },
893 }),
894 }),
895 { discard: true },
896 );
897 if (input.body.trim().length > 0) {
898 yield* bitbucket.request({
899 method: "POST",
900 url: `${pullRequest}/comments`,
901 // @effect-diagnostics-next-line preferSchemaOverJson:off
902 body: JSON.stringify({ content: { raw: input.body } }),
903 });
904 }
905 if (input.verdict === "approve") {
906 yield* bitbucket.request({ method: "POST", url: `${pullRequest}/approve` });
907 }
908 if (input.verdict === "request-changes") {
909 yield* bitbucket.request({ method: "POST", url: `${pullRequest}/request-changes` });
910 }
911 }),
912 ),
913
914 replyToComment: (input) =>
915 withRepository(input.repository, (path) =>
916 bitbucket
917 .request({
918 method: "POST",
919 url: `${path}/pullrequests/${input.number}/comments`,
920 body: JSON.stringify({
921 content: { raw: input.body },
922 parent: { id: Number(input.commentId) },
923 }),
924 })
925 .pipe(Effect.asVoid),
926 ),
927
928 setCommentResolution: (input) =>
929 withRepository(input.repository, (path) =>
930 bitbucket
931 .request({
932 // Resolving is a sub-resource that is created and deleted, rather than a field.
933 method: input.resolved ? "POST" : "DELETE",
934 url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent(
935 input.commentId,
936 )}/resolve`,
937 })
938 .pipe(Effect.asVoid),
939 ),
940 });
941});
942
943export const layer = Layer.effect(BitbucketPullRequestApi, make);
apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts deleted-175
...@@ -1,175 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Result from "effect/Result";
5
6import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
7import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
8import { decodePullRequestJson } from "./bitbucketPullRequestJson.ts";
9import {
10 bitbucketProviderFailure,
11 bitbucketViewerPermissions,
12 make,
13} from "./BitbucketPullRequestProvider.ts";
14
15for (const operation of [
16 "getMergeability",
17 "listChecks",
18 "getRepositoryPermission",
19 "listComments",
20 "listCommits",
21] as const) {
22 it.effect.each(["response", "body read"])(
23 `preserves rate limits from ${operation} on %s errors while recovering other optional-read failures`,
24 (variant) =>
25 Effect.gen(function* () {
26 const pullRequest = Result.getOrThrow(
27 decodePullRequestJson(`{
28 "id": 1, "title": "Check polling", "state": "OPEN",
29 "source": { "branch": { "name": "feature" } },
30 "destination": { "branch": { "name": "main" } },
31 "created_on": "2026-09-16T00:00:00Z",
32 "updated_on": "2026-09-16T00:00:00Z",
33 "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } }
34 }`),
35 );
36 for (const status of [429, 403]) {
37 const provider = yield* make.pipe(
38 Effect.provide(
39 Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({
40 getPullRequest: () => Effect.succeed(pullRequest),
41 getDiffStat: () => Effect.succeed({ additions: 0, deletions: 0, changedFiles: 0 }),
42 getMergeability: () => Effect.succeed("unknown" as const),
43 listChecks: () => Effect.succeed([]),
44 getRepositoryPermission: () => Effect.succeed(true),
45 listComments: () => Effect.succeed({ comments: [], threads: [], truncated: false }),
46 listCommits: () => Effect.succeed([]),
47 [operation]: () =>
48 Effect.fail(
49 variant === "response"
50 ? new BitbucketApi.BitbucketResponseError({
51 operation: "request",
52 status,
53 responseBodyLength: 0,
54 retryAt: 120_000,
55 })
56 : new BitbucketApi.BitbucketResponseBodyReadError({
57 operation: "request",
58 status,
59 cause: new Error("response stream failed"),
60 retryAt: 120_000,
61 }),
62 ),
63 }),
64 ),
65 );
66 const reference = {
67 cwd: "/repo",
68 repository: "acme/web",
69 number: 1,
70 host: "bitbucket.org",
71 };
72 const result = yield* operation === "listComments" || operation === "listCommits"
73 ? Effect.result(provider.getChangeRequestActivity(reference))
74 : Effect.result(provider.getChangeRequest(reference));
75 if (status === 429) {
76 expect(result).toMatchObject({
77 _tag: "Failure",
78 failure: { reason: "rate-limited", retryAt: 120_000 },
79 });
80 } else {
81 expect(result._tag).toBe("Success");
82 }
83 }
84 }),
85 );
86}
87
88it.effect("reads checks and PR state without diff, mergeability, or permission requests", () =>
89 Effect.gen(function* () {
90 const pullRequest = Result.getOrThrow(
91 decodePullRequestJson(`{
92 "id": 1, "title": "Checks", "state": "OPEN",
93 "source": { "branch": { "name": "feature" } },
94 "destination": { "branch": { "name": "main" } },
95 "created_on": "2026-09-16T00:00:00Z", "updated_on": "2026-09-16T00:00:00Z",
96 "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } }
97 }`),
98 );
99 let limited = false;
100 const provider = yield* make.pipe(
101 Effect.provide(
102 Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({
103 getPullRequest: () => Effect.succeed(pullRequest),
104 listChecks: () =>
105 limited
106 ? Effect.fail(
107 new BitbucketApi.BitbucketResponseError({
108 operation: "request",
109 status: 429,
110 responseBodyLength: 0,
111 retryAt: 120_000,
112 }),
113 )
114 : Effect.succeed([
115 { name: "build", status: "failure" as const, description: null, url: null },
116 ]),
117 }),
118 ),
119 );
120 const read = provider.getChangeRequestChecks;
121 if (read === undefined) return yield* Effect.die("checks read missing");
122 const input = { cwd: "/repo", repository: "acme/web", host: "bitbucket.org", number: 1 };
123 expect((yield* read(input)).checks[0]?.status).toBe("failure");
124 limited = true;
125 expect(yield* Effect.flip(read(input))).toMatchObject({
126 reason: "rate-limited",
127 retryAt: 120_000,
128 });
129 }),
130);
131
132describe("bitbucketProviderFailure", () => {
133 it("treats only an HTTP 401 as unusable credentials", () => {
134 const responseError = (status: number) =>
135 new BitbucketApi.BitbucketResponseError({
136 operation: "request",
137 status,
138 responseBodyLength: 0,
139 });
140
141 expect(bitbucketProviderFailure(responseError(401)).reason).toBe("unauthenticated");
142 expect(bitbucketProviderFailure(responseError(403)).reason).toBe("failed");
143 });
144});
145
146describe("bitbucketViewerPermissions", () => {
147 it("offers both actions to credentials with write access", () => {
148 expect(bitbucketViewerPermissions({ canWrite: true })).toEqual({
149 actions: ["merge", "close"],
150 comment: true,
151 resolve: true,
152 verdicts: ["comment", "approve", "request-changes"],
153 // Bitbucket says nothing about who may set a reviewer, and an unreported permission is
154 // granted.
155 requestReviewers: true,
156 });
157 });
158
159 it("keeps merge from credentials that can only read the repository", () => {
160 expect(bitbucketViewerPermissions({ canWrite: false })).toEqual({
161 actions: ["close"],
162 comment: true,
163 resolve: true,
164 verdicts: ["comment", "approve", "request-changes"],
165 requestReviewers: true,
166 });
167 });
168
169 it("treats an author with read access as any other reader, which is all Bitbucket says", () => {
170 // The repository permission is the whole of what Bitbucket reports per account; it says
171 // nothing about who opened this pull request, and its author may decline it with read access
172 // alone — so declining stays offered rather than being taken from them.
173 expect(bitbucketViewerPermissions({ canWrite: false }).actions).toEqual(["close"]);
174 });
175});
apps/server/src/pullRequest/BitbucketPullRequestProvider.ts deleted-385
...@@ -1,385 +0,0 @@
1import * as Effect from "effect/Effect";
2import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
3
4import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
5import {
6 PullRequestProviderError,
7 type PullRequestProviderFailure,
8 type ProviderChangeRequest,
9 type ProviderChangeRequestActivity,
10 type ProviderChangeRequestDetail,
11 type PullRequestProviderApi,
12} from "@t3tools/source-control-core/server/PullRequestProvider";
13import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts";
14
15const CAPABILITIES: PullRequestCapabilities = {
16 diff: true,
17 comment: true,
18 // Bitbucket has no endpoint that reopens a declined pull request, and nothing documented that
19 // moves one in or out of draft, so neither is offered rather than failing when pressed.
20 actions: ["merge", "close"],
21 mergeMethods: ["merge", "squash", "rebase"],
22 search: true,
23 // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is
24 // read and none is offered.
25 reactions: false,
26 review: {
27 inlineComment: true,
28 reply: true,
29 resolve: true,
30 verdicts: ["comment", "approve", "request-changes"],
31 },
32 reviewers: { request: true, listCandidates: true },
33 edit: { changeRequest: true, comment: true },
34 // Bitbucket Cloud states nothing about what a reviewer has already read: no endpoint carries a
35 // viewed file, and the per-pull-request properties it does offer are one value shared by
36 // everyone rather than one per reader. So the marks are kept here, and the client says whose
37 // they are rather than implying bitbucket.org will show them.
38 viewedFiles: "environment",
39};
40
41/**
42 * What the configured account may do here, from the one thing Bitbucket states per viewer: the
43 * repository permission. Merging needs `write` or `admin`, so that is what narrows.
44 *
45 * Declining stays offered whatever the permission. Bitbucket lets the author of a pull request
46 * decline their own with no more than read access, and the permission response says nothing about
47 * who opened this one — so withholding the control from the one person entitled to it is the
48 * worse of the two mistakes. Commenting and reviewing are not narrowed either: read access is
49 * enough to say something, to approve and to ask for changes.
50 *
51 * Asking for a review is left open for the same reason: Bitbucket takes a reviewer set from the
52 * author of a pull request as well as from whoever can write, and says nothing here about which
53 * of the two this account is.
54 */
55export function bitbucketViewerPermissions(input: {
56 readonly canWrite: boolean;
57}): PullRequestViewerPermissions {
58 return {
59 actions: CAPABILITIES.actions.filter((action) => action !== "merge" || input.canWrite),
60 comment: true,
61 resolve: true,
62 verdicts: CAPABILITIES.review.verdicts,
63 requestReviewers: true,
64 };
65}
66
67/** The failures that mean the credentials are the problem, rather than one request. */
68export function bitbucketProviderFailure(
69 error: BitbucketPullRequestApi.BitbucketPullRequestApiError,
70): PullRequestProviderFailure {
71 // Bitbucket is read over HTTP with credentials from the environment, so there is no tool to be
72 // missing: unusable always means the credentials are absent or refused.
73 if (error._tag === "BitbucketResponseError" && error.status === 401) {
74 return { reason: "unauthenticated" };
75 }
76 if (
77 (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") &&
78 error.status === 429
79 ) {
80 return {
81 reason: "rate-limited",
82 ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),
83 };
84 }
85 if (
86 (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") &&
87 error.status === 404
88 ) {
89 return { reason: "not-found" };
90 }
91 return { reason: "failed" };
92}
93
94function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeRequest {
95 return {
96 number: pullRequest.number,
97 title: pullRequest.title,
98 url: pullRequest.url,
99 author: pullRequest.author,
100 headBranch: pullRequest.headBranch,
101 ...(pullRequest.headRepositoryNameWithOwner
102 ? { headRepositoryNameWithOwner: pullRequest.headRepositoryNameWithOwner }
103 : {}),
104 baseBranch: pullRequest.baseBranch,
105 state: pullRequest.state,
106 isDraft: pullRequest.isDraft,
107 mergeability: pullRequest.mergeability,
108 // Line counts are a separate read, which only the detail is worth spending on.
109 additions: 0,
110 deletions: 0,
111 createdAt: pullRequest.createdAt,
112 updatedAt: pullRequest.updatedAt,
113 reviewRequestLogins: pullRequest.reviewRequestLogins,
114 // Bitbucket has no labels on a pull request.
115 labels: [],
116 };
117}
118
119export const make = Effect.gen(function* () {
120 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
121
122 const fail =
123 (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) =>
124 new PullRequestProviderError({
125 provider: "bitbucket",
126 operation,
127 ...bitbucketProviderFailure(error),
128 // Every Bitbucket failure states its own fact; this names the operation around it, so
129 // the two do not stack into "failed in x: failed in y: ...".
130 detail: error.detail,
131 cause: error,
132 });
133
134 const recoverRead = <A>(
135 read: Effect.Effect<A, BitbucketPullRequestApi.BitbucketPullRequestApiError>,
136 fallback: A,
137 ) => {
138 const recover = () => Effect.succeed(fallback);
139 return Effect.catchTags(read, {
140 BitbucketResponseError: (error) => (error.status === 429 ? Effect.fail(error) : recover()),
141 BitbucketUntrustedUrlError: recover,
142 BitbucketRepositoryLocatorError: recover,
143 BitbucketRequestError: recover,
144 BitbucketResponseBodyReadError: (error) =>
145 error.status === 429 ? Effect.fail(error) : recover(),
146 BitbucketResponseDecodeError: recover,
147 BitbucketRepositoryVcsResolveError: recover,
148 BitbucketRepositoryRemotesListError: recover,
149 BitbucketRepositoryRemoteNotFoundError: recover,
150 BitbucketPullRequestBodyReadError: recover,
151 BitbucketCheckoutError: recover,
152 BitbucketPullRequestReadError: recover,
153 BitbucketViewerUnavailableError: recover,
154 BitbucketRepositoryUnsupportedError: recover,
155 BitbucketDiffCommitError: recover,
156 });
157 };
158
159 const provider: PullRequestProviderApi = {
160 kind: "bitbucket",
161 capabilities: CAPABILITIES,
162
163 // Bitbucket credentials come from the server's environment rather than a checkout, so the
164 // account is the same whichever workspace asks.
165 getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))),
166
167 listChangeRequests: (input) =>
168 api
169 .listPullRequests({
170 repository: input.repository,
171 state: input.state,
172 limit: input.limit,
173 query: input.query,
174 cursor: input.cursor,
175 })
176 .pipe(
177 Effect.mapError(fail("listChangeRequests")),
178 Effect.map((batch) => ({
179 items: batch.items.map(toChangeRequest),
180 truncated: batch.truncated,
181 // Bitbucket is asked for `-updated_on` whether or not it is being carried on from,
182 // so every page it answers is one a cursor can continue.
183 continues: true,
184 })),
185 ),
186
187 getChangeRequestChecks: (input) =>
188 Effect.all([api.getPullRequest(input), api.listChecks(input)], { concurrency: 2 }).pipe(
189 Effect.map(([pullRequest, checks]) => ({ state: pullRequest.state, checks })),
190 Effect.mapError(fail("getChangeRequestChecks")),
191 ),
192
193 getChangeRequest: (input) => {
194 const target = { repository: input.repository, number: input.number };
195 return Effect.all(
196 [
197 api.getPullRequest(target),
198 api.getDiffStat(target),
199 recoverRead(api.getMergeability(target), "unknown" as const),
200 recoverRead(api.listChecks(target), []),
201 // A permission that could not be read is an unknown one, which is granted: a hidden
202 // Merge leaves someone entitled to it with no way through, and one Bitbucket refuses
203 // at least says why.
204 recoverRead(api.getRepositoryPermission(target), true),
205 ],
206 { concurrency: 5 },
207 ).pipe(
208 Effect.mapError(fail("getChangeRequest")),
209 Effect.map(
210 ([
211 pullRequest,
212 diffStat,
213 mergeability,
214 checks,
215 canWrite,
216 ]): ProviderChangeRequestDetail => ({
217 ...toChangeRequest(pullRequest),
218 mergeability,
219 additions: diffStat.additions,
220 deletions: diffStat.deletions,
221 changedFiles: diffStat.changedFiles,
222 body: pullRequest.body,
223 mergedAt: null,
224 closedAt: null,
225 reviewers: pullRequest.reviewers,
226 checks,
227 // Bitbucket publishes no per-repository list of allowed strategies, so the ones it
228 // supports are all offered and a strategy the repository forbids fails on merge.
229 mergeCapabilities: { merge: true, squash: true, rebase: true },
230 viewerPermissions: bitbucketViewerPermissions({ canWrite }),
231 }),
232 ),
233 );
234 },
235
236 getChangeRequestActivity: (input) => {
237 const target = { repository: input.repository, number: input.number };
238 return Effect.all(
239 [
240 // Reviews ride on the pull request itself, so this inexpensive core read is repeated
241 // here rather than making the core response wait for the conversation endpoints.
242 api.getPullRequest(target),
243 recoverRead(api.listComments(target), { comments: [], threads: [], truncated: true }),
244 recoverRead(api.listCommits(target), []),
245 ],
246 { concurrency: 3 },
247 ).pipe(
248 Effect.mapError(fail("getChangeRequestActivity")),
249 Effect.map(([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({
250 comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) =>
251 left.createdAt.localeCompare(right.createdAt),
252 ),
253 commentCount: comments.comments.length + pullRequest.reviews.length,
254 commentsTruncated: comments.truncated,
255 reviewThreads: comments.threads,
256 commits,
257 })),
258 );
259 },
260
261 getViewerPermissions: (input) =>
262 api.getRepositoryPermission({ repository: input.repository }).pipe(
263 Effect.mapError(fail("getViewerPermissions")),
264 Effect.map((canWrite) => bitbucketViewerPermissions({ canWrite })),
265 ),
266
267 // `/diff` answers with the whole patch and pages nothing, so the first slice is the last.
268 getDiff: (input) =>
269 api
270 .getPullRequestDiff({
271 repository: input.repository,
272 number: input.number,
273 ...(input.commit === undefined ? {} : { commit: input.commit }),
274 })
275 .pipe(
276 Effect.mapError(fail("getDiff")),
277 Effect.map((diff) => ({ ...diff, nextCursor: null })),
278 ),
279
280 getFileRevisions: (input) =>
281 api
282 .getFileRevisions({
283 repository: input.repository,
284 number: input.number,
285 paths: input.paths,
286 })
287 .pipe(Effect.mapError(fail("getFileRevisions"))),
288
289 // Users only: Bitbucket requests a review of an account, and has no group that stands in for
290 // one on a pull request.
291 listReviewerCandidates: (input) =>
292 api
293 .listReviewerCandidates({ repository: input.repository, number: input.number })
294 .pipe(Effect.mapError(fail("listReviewerCandidates"))),
295
296 setReviewerRequest: (input) =>
297 api
298 .setReviewerRequest({
299 repository: input.repository,
300 number: input.number,
301 reviewers: input.reviewers,
302 requested: input.requested,
303 })
304 .pipe(Effect.mapError(fail("setReviewerRequest"))),
305
306 runAction: (input) =>
307 api
308 .runAction({
309 repository: input.repository,
310 number: input.number,
311 action: input.action,
312 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
313 })
314 .pipe(Effect.mapError(fail("runAction"))),
315
316 updateChangeRequest: (input) =>
317 api
318 .updateChangeRequest({
319 repository: input.repository,
320 number: input.number,
321 title: input.title,
322 body: input.body,
323 })
324 .pipe(Effect.mapError(fail("updateChangeRequest"))),
325
326 comment: (input) =>
327 api
328 .comment({ repository: input.repository, number: input.number, body: input.body })
329 .pipe(Effect.mapError(fail("comment"))),
330
331 updateComment: (input) =>
332 api
333 .updateComment({
334 repository: input.repository,
335 number: input.number,
336 commentId: input.commentId,
337 body: input.body,
338 })
339 .pipe(Effect.mapError(fail("updateComment"))),
340
341 submitReview: (input) =>
342 api
343 .submitReview({
344 repository: input.repository,
345 number: input.number,
346 verdict: input.verdict,
347 body: input.body,
348 comments: input.comments,
349 })
350 .pipe(Effect.mapError(fail("submitReview"))),
351
352 replyToThread: (input) =>
353 api
354 .replyToComment({
355 repository: input.repository,
356 number: input.number,
357 commentId: input.threadId,
358 body: input.body,
359 })
360 .pipe(Effect.mapError(fail("replyToThread"))),
361
362 // Never called: `capabilities.reactions` is false, and the service refuses without it.
363 setReaction: () =>
364 Effect.fail(
365 new PullRequestProviderError({
366 provider: "bitbucket",
367 operation: "setReaction",
368 reason: "failed",
369 detail: "Bitbucket does not support reactions.",
370 }),
371 ),
372
373 setThreadResolution: (input) =>
374 api
375 .setCommentResolution({
376 repository: input.repository,
377 number: input.number,
378 commentId: input.threadId,
379 resolved: input.resolved,
380 })
381 .pipe(Effect.mapError(fail("setThreadResolution"))),
382 };
383
384 return provider;
385});
apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts+2-2
...@@ -1,12 +1,12 @@...@@ -1,12 +1,12 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
22
3import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";3import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";
4import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";4import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
5import * as GitHubApi from "../sourceControl/GitHubApi.ts";5import * as GitHubApi from "../sourceControl/GitHubApi.ts";
6import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";6import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
7import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";7import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
8import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider";8import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider";
9import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts";9import { bitbucketProviderFailure } from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestProvider";
10import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts";10import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts";
11import { gitLabProviderFailure } from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider";11import { gitLabProviderFailure } from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider";
1212
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+1-8
...@@ -3,10 +3,7 @@ import * as Effect from "effect/Effect";...@@ -3,10 +3,7 @@ import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import type { SourceControlProviderKind } from "@t3tools/contracts";4import type { SourceControlProviderKind } from "@t3tools/contracts";
55
6import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
7import * as GitHubApi from "../sourceControl/GitHubApi.ts";6import * as GitHubApi from "../sourceControl/GitHubApi.ts";
8import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
9import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts";
10import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";7import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
11import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";8import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";
12import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts";9import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts";
...@@ -39,10 +36,7 @@ export function fromProviders(...@@ -39,10 +36,7 @@ export function fromProviders(
39 * @public Service construction is part of the canonical Effect module API.36 * @public Service construction is part of the canonical Effect module API.
40 */37 */
41export const make = Effect.gen(function* () {38export const make = Effect.gen(function* () {
42 const providers = yield* Effect.all([39 const providers = yield* Effect.all([GitHubPullRequestProvider.make]);
43 GitHubPullRequestProvider.make,
44 BitbucketPullRequestProvider.make,
45 ]);
46 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>40 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>
47 driver.make.pipe(Effect.map((instance) => instance.pullRequests)),41 driver.make.pipe(Effect.map((instance) => instance.pullRequests)),
48 );42 );
...@@ -62,5 +56,4 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(...@@ -62,5 +56,4 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
62 ),56 ),
63 ),57 ),
64 Layer.provide(BuiltInDrivers.layer),58 Layer.provide(BuiltInDrivers.layer),
65 Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))),
66);59);
apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts deleted-377
...@@ -1,377 +0,0 @@
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: { branch: { name: "feat/page" }, repository: { full_name: "fork/web" } },
28 destination: { branch: { name: "master" } },
29 links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } },
30 ...overrides,
31 };
32}
33
34function page(values: ReadonlyArray<unknown>, extra: Record<string, unknown> = {}): string {
35 return JSON.stringify({ pagelen: 50, page: 1, size: values.length, values, ...extra });
36}
37
38function expectSuccess<A>(result: Result.Result<A, unknown>): A {
39 expect(Result.isSuccess(result)).toBe(true);
40 if (!Result.isSuccess(result)) throw new Error("expected a successful decode");
41 return result.success;
42}
43
44describe("decodePullRequestPageJson", () => {
45 it("reads a pull request as a change request", () => {
46 const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()])));
47
48 expect(decoded.items).toHaveLength(1);
49 expect(decoded.items[0]).toMatchObject({
50 number: 897,
51 title: "Add trustabl-pipe",
52 url: "https://bitbucket.org/acme/web/pull-requests/897",
53 author: { login: "bilal", name: "Bilal Hassan" },
54 headBranch: "feat/page",
55 headRepositoryNameWithOwner: "fork/web",
56 baseBranch: "master",
57 state: "open",
58 isDraft: false,
59 // Bitbucket says nothing about conflicts on the pull request itself.
60 mergeability: "unknown",
61 });
62 expect(decoded.next).toBeNull();
63 });
64
65 it("normalizes Bitbucket's offset timestamps, which the page sorts against other hosts", () => {
66 const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()])));
67
68 expect(decoded.items[0]).toMatchObject({
69 createdAt: "2026-06-16T05:04:32.258Z",
70 updatedAt: "2026-06-16T05:04:33.750Z",
71 });
72 });
73
74 it("reports the next page as the whole URL Bitbucket sends", () => {
75 const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2";
76 const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()], { next })));
77
78 expect(decoded.next).toBe(next);
79 });
80
81 it.each([
82 ["MERGED", "merged"],
83 ["DECLINED", "closed"],
84 ["SUPERSEDED", "closed"],
85 ["OPEN", "open"],
86 ["something new", "open"],
87 ])("reads the %s state as %s", (state, expected) => {
88 const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest({ state })])));
89
90 expect(decoded.items[0]?.state).toBe(expected);
91 });
92
93 it("skips a malformed row rather than failing the page", () => {
94 const decoded = expectSuccess(
95 decodePullRequestPageJson(page([{ id: "not a number" }, pullRequest()])),
96 );
97
98 expect(decoded.items).toHaveLength(1);
99 });
100
101 it("fails when Bitbucket did not answer with a page", () => {
102 expect(Result.isFailure(decodePullRequestPageJson(JSON.stringify({ error: "nope" })))).toBe(
103 true,
104 );
105 });
106});
107
108describe("decodePullRequestJson", () => {
109 it("reads reviewers as review requests", () => {
110 const decoded = expectSuccess(
111 decodePullRequestJson(
112 JSON.stringify(
113 pullRequest({
114 reviewers: [{ nickname: "julius", display_name: "Julius" }],
115 }),
116 ),
117 ),
118 );
119
120 expect(decoded.reviewRequestLogins).toEqual(["julius"]);
121 expect(decoded.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]);
122 });
123
124 it("reads a participant's vote as a review", () => {
125 const decoded = expectSuccess(
126 decodePullRequestJson(
127 JSON.stringify(
128 pullRequest({
129 participants: [
130 {
131 user: { nickname: "julius", display_name: "Julius" },
132 role: "REVIEWER",
133 approved: true,
134 state: "approved",
135 participated_on: "2026-06-17T09:00:00+00:00",
136 },
137 // Added as a reviewer but has not voted, so there is no verdict to show.
138 {
139 user: { nickname: "sam", display_name: "Sam" },
140 role: "REVIEWER",
141 approved: false,
142 state: null,
143 participated_on: null,
144 },
145 ],
146 }),
147 ),
148 ),
149 );
150
151 expect(decoded.reviews).toHaveLength(1);
152 expect(decoded.reviews[0]).toMatchObject({
153 kind: "review",
154 author: { login: "julius" },
155 reviewState: "approved",
156 createdAt: "2026-06-17T09:00:00.000Z",
157 });
158 });
159});
160
161describe("decodeViewerJson", () => {
162 it("reads the signed-in nickname", () => {
163 const decoded = decodeViewerJson(JSON.stringify({ nickname: "bilal", display_name: "Bilal" }));
164
165 expect(expectSuccess(decoded)).toBe("bilal");
166 });
167
168 it("falls back to the display name, which app accounts have instead", () => {
169 const decoded = decodeViewerJson(JSON.stringify({ display_name: "Release Bot" }));
170
171 expect(expectSuccess(decoded)).toBe("Release Bot");
172 });
173
174 it("returns nothing when the account has neither", () => {
175 expect(expectSuccess(decodeViewerJson(JSON.stringify({})))).toBeNull();
176 });
177});
178
179describe("decodeCommentsJson", () => {
180 it("keeps a posted comment and drops deleted and unposted ones", () => {
181 const decoded = expectSuccess(
182 decodeCommentsJson(
183 page([
184 {
185 id: 797230941,
186 content: { raw: "The issue is ready for review." },
187 user: { display_name: "Release Bot", type: "app_user" },
188 created_on: "2026-05-15T01:58:38.220690+00:00",
189 deleted: false,
190 pending: false,
191 links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/892#c1" } },
192 },
193 {
194 id: 2,
195 content: { raw: "gone" },
196 created_on: "2026-05-15T02:00:00+00:00",
197 deleted: true,
198 },
199 {
200 id: 3,
201 content: { raw: "wip" },
202 created_on: "2026-05-15T02:00:00+00:00",
203 pending: true,
204 },
205 { id: 4, content: { raw: " " }, created_on: "2026-05-15T02:00:00+00:00" },
206 ]),
207 ),
208 );
209
210 expect(decoded.comments).toHaveLength(1);
211 expect(decoded.comments[0]).toMatchObject({
212 id: "797230941",
213 kind: "issue-comment",
214 // An app account has no nickname, so its display name is the only handle it has.
215 author: { login: "Release Bot" },
216 createdAt: "2026-05-15T01:58:38.220Z",
217 });
218 });
219
220 it("reads a comment pinned to a file as a review comment", () => {
221 const decoded = expectSuccess(
222 decodeCommentsJson(
223 page([
224 {
225 id: 5,
226 content: { raw: "Rename this." },
227 created_on: "2026-05-15T02:00:00+00:00",
228 inline: { path: "src/app.ts" },
229 },
230 ]),
231 ),
232 );
233
234 expect(decoded.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" });
235 });
236});
237
238describe("decodeCommitsJson", () => {
239 it("returns commits oldest first with only the subject line", () => {
240 const decoded = expectSuccess(
241 decodeCommitsJson(
242 page([
243 { hash: "bbb", message: "second\n\nbody text\n", date: "2026-06-16T04:51:00+00:00" },
244 {
245 hash: "aaa",
246 message: "first\n",
247 date: "2026-06-16T04:50:49+00:00",
248 author: {
249 raw: "Ada Lovelace <ada@example.com>",
250 user: { nickname: "ada", display_name: "Ada Lovelace" },
251 },
252 },
253 ]),
254 ),
255 );
256
257 expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]);
258 expect(decoded.items[0]?.authors).toEqual([
259 { login: "ada", name: "Ada Lovelace", avatarUrl: null },
260 ]);
261 expect(decoded.items[1]?.messageHeadline).toBe("second");
262 expect(decoded.next).toBeNull();
263 });
264
265 it("skips commits whose hash is empty", () => {
266 const decoded = expectSuccess(
267 decodeCommitsJson(
268 page([
269 { hash: " ", message: "invalid", date: "2026-06-16T04:51:00+00:00" },
270 { hash: "aaa", date: "2026-06-16T04:50:49+00:00" },
271 ]),
272 ),
273 );
274
275 expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa"]);
276 });
277});
278
279describe("decodeStatusesJson", () => {
280 it("reads a build status as a check", () => {
281 const decoded = expectSuccess(
282 decodeStatusesJson(
283 page([
284 {
285 key: "custom:check-version-and-pr",
286 name: "Pipeline - custom: check-version-and-pr",
287 state: "SUCCESSFUL",
288 description: "",
289 url: "https://bitbucket.org/acme/web/pipelines/results/8126",
290 },
291 ]),
292 ),
293 );
294
295 expect(decoded).toEqual({
296 items: [
297 {
298 name: "Pipeline - custom: check-version-and-pr",
299 status: "success",
300 description: null,
301 url: "https://bitbucket.org/acme/web/pipelines/results/8126",
302 },
303 ],
304 next: null,
305 });
306 });
307
308 it.each([
309 ["SUCCESSFUL", "success"],
310 ["FAILED", "failure"],
311 ["INPROGRESS", "pending"],
312 ["STOPPED", "cancelled"],
313 ["something new", "neutral"],
314 ])("reads the %s build state as %s", (state, expected) => {
315 const decoded = expectSuccess(decodeStatusesJson(page([{ name: "Pipeline", state }])));
316
317 expect(decoded.items[0]?.status).toBe(expected);
318 });
319
320 it("keeps two statuses that share a display name but have different keys", () => {
321 const decoded = expectSuccess(
322 decodeStatusesJson(
323 page([
324 { key: "build", name: "Pipeline", state: "SUCCESSFUL" },
325 { key: "deploy", name: "Pipeline", state: "FAILED" },
326 ]),
327 ),
328 );
329
330 expect(decoded.items.map((check) => [check.name, check.status])).toEqual([
331 ["build / Pipeline", "success"],
332 ["deploy / Pipeline", "failure"],
333 ]);
334 });
335});
336
337describe("decodeDiffstatJson", () => {
338 it("adds up the per-file counts", () => {
339 const decoded = expectSuccess(
340 decodeDiffstatJson(
341 page([
342 { lines_added: 9, lines_removed: 2 },
343 { lines_added: 32, lines_removed: 14 },
344 ]),
345 ),
346 );
347
348 expect(decoded).toEqual({ additions: 41, deletions: 16, changedFiles: 2, next: null });
349 });
350});
351
352describe("decodeConflictsJson", () => {
353 it("calls an empty conflict list mergeable", () => {
354 expect(expectSuccess(decodeConflictsJson(page([])))).toBe("mergeable");
355 });
356
357 it("calls any reported conflict conflicting", () => {
358 expect(expectSuccess(decodeConflictsJson(page([{ path: "src/app.ts" }])))).toBe("conflicting");
359 });
360});
361
362describe("repository permission decoding", () => {
363 const permissionPage = (permission: string) =>
364 page([{ type: "repository_permission", permission }]);
365
366 it("counts admin and write as write, and read as not", () => {
367 expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("admin")))).toBe(true);
368 expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("write")))).toBe(true);
369 expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("read")))).toBe(false);
370 });
371
372 it("grants write where Bitbucket named no permission at all", () => {
373 // An empty page is Bitbucket declining to say, which is an unknown standing rather than a
374 // refusal — and an unknown one is granted.
375 expect(expectSuccess(decodeRepositoryPermissionJson(page([])))).toBe(true);
376 });
377});
apps/server/src/pullRequest/bitbucketPullRequestJson.ts deleted-631
...@@ -1,631 +0,0 @@
1import * as Cause from "effect/Cause";
2import * as DateTime from "effect/DateTime";
3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import type {
8 PullRequestActor,
9 PullRequestCheck,
10 PullRequestCheckStatus,
11 PullRequestComment,
12 PullRequestCommit,
13 PullRequestMergeability,
14 PullRequestReviewThread,
15 PullRequestReviewerCandidate,
16 PullRequestState,
17} from "@t3tools/contracts";
18import { TrimmedNonEmptyString } from "@t3tools/contracts";
19import { decodeJsonResult } from "@t3tools/shared/schemaJson";
20
21import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
22
23/**
24 * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant
25 * style as the GitHub and GitLab decoders: a new pull request state or build status must not
26 * fail a whole payload.
27 */
28const RawUserSchema = Schema.Struct({
29 /**
30 * How Bitbucket addresses an account when a reviewer set is written; the handles it shows are
31 * not accepted there. Braced, and sent back exactly as it arrived.
32 */
33 uuid: Schema.optional(Schema.NullOr(Schema.String)),
34 /** Absent on an app account, which is why `display_name` has to stand in for it. */
35 nickname: Schema.optional(Schema.NullOr(Schema.String)),
36 display_name: Schema.optional(Schema.NullOr(Schema.String)),
37 links: Schema.optional(
38 Schema.NullOr(
39 Schema.Struct({
40 avatar: Schema.optional(
41 Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })),
42 ),
43 }),
44 ),
45 ),
46});
47
48/**
49 * Required, and required to be non-empty: the wire contract will not carry a change request
50 * without a branch or a link, so a row missing one is skipped rather than breaking the response
51 * it travels in.
52 */
53const RawBranchSchema = Schema.Struct({
54 branch: Schema.Struct({ name: TrimmedNonEmptyString }),
55 repository: Schema.optional(Schema.NullOr(Schema.Struct({ full_name: TrimmedNonEmptyString }))),
56});
57
58const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) });
59
60const RawPullRequestSchema = Schema.Struct({
61 id: Schema.Int,
62 title: Schema.String,
63 description: Schema.optional(Schema.NullOr(Schema.String)),
64 state: Schema.optional(Schema.NullOr(Schema.String)),
65 draft: Schema.optional(Schema.Boolean),
66 author: Schema.optional(Schema.NullOr(RawUserSchema)),
67 source: RawBranchSchema,
68 destination: RawBranchSchema,
69 created_on: Schema.String,
70 updated_on: Schema.String,
71 reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))),
72 participants: Schema.optional(
73 Schema.NullOr(
74 Schema.Array(
75 Schema.Struct({
76 user: Schema.optional(Schema.NullOr(RawUserSchema)),
77 role: Schema.optional(Schema.NullOr(Schema.String)),
78 approved: Schema.optional(Schema.Boolean),
79 state: Schema.optional(Schema.NullOr(Schema.String)),
80 participated_on: Schema.optional(Schema.NullOr(Schema.String)),
81 }),
82 ),
83 ),
84 ),
85 links: Schema.Struct({ html: Schema.Struct({ href: TrimmedNonEmptyString }) }),
86});
87
88const RawPageSchema = Schema.Struct({
89 values: Schema.Array(Schema.Unknown),
90 /** A total count, which Bitbucket omits on some endpoints. */
91 size: Schema.optional(Schema.NullOr(Schema.Int)),
92 /** Present only while a further page exists. */
93 next: Schema.optional(Schema.NullOr(Schema.String)),
94});
95
96const RawCommentSchema = Schema.Struct({
97 id: Schema.Int,
98 content: Schema.optional(Schema.NullOr(Schema.Struct({ raw: Schema.optional(Schema.String) }))),
99 user: Schema.optional(Schema.NullOr(RawUserSchema)),
100 created_on: Schema.String,
101 deleted: Schema.optional(Schema.Boolean),
102 /** A comment still being drafted by its author. */
103 pending: Schema.optional(Schema.Boolean),
104 /** Set on a reply, to the comment it answers — which may itself be a reply. */
105 parent: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.Int }))),
106 inline: Schema.optional(
107 Schema.NullOr(
108 Schema.Struct({
109 path: Schema.optional(Schema.NullOr(Schema.String)),
110 /** The line in the file as it was; set instead of `to` on a removed line. */
111 from: Schema.optional(Schema.NullOr(Schema.Int)),
112 /** The line in the file as it is now. */
113 to: Schema.optional(Schema.NullOr(Schema.Int)),
114 outdated: Schema.optional(Schema.NullOr(Schema.Boolean)),
115 }),
116 ),
117 ),
118 /** Non-null once someone has marked the thread resolved. */
119 resolution: Schema.optional(Schema.NullOr(Schema.Unknown)),
120 links: Schema.optional(
121 Schema.NullOr(Schema.Struct({ html: Schema.optional(Schema.NullOr(RawLinkSchema)) })),
122 ),
123});
124
125const RawCommitSchema = Schema.Struct({
126 hash: TrimmedNonEmptyString,
127 message: Schema.optional(Schema.NullOr(Schema.String)),
128 date: Schema.optional(Schema.NullOr(Schema.String)),
129 author: Schema.optional(
130 Schema.NullOr(
131 Schema.Struct({
132 raw: Schema.optional(Schema.NullOr(Schema.String)),
133 user: Schema.optional(Schema.NullOr(RawUserSchema)),
134 }),
135 ),
136 ),
137});
138
139const RawStatusSchema = Schema.Struct({
140 key: Schema.optional(Schema.NullOr(Schema.String)),
141 name: Schema.optional(Schema.NullOr(Schema.String)),
142 state: Schema.optional(Schema.NullOr(Schema.String)),
143 description: Schema.optional(Schema.NullOr(Schema.String)),
144 url: Schema.optional(Schema.NullOr(Schema.String)),
145});
146
147const RawDiffstatSchema = Schema.Struct({
148 lines_added: Schema.optional(Schema.NullOr(Schema.Int)),
149 lines_removed: Schema.optional(Schema.NullOr(Schema.Int)),
150});
151
152/** One row of `/workspaces/{workspace}/members`, which wraps the account it is about. */
153const RawMemberSchema = Schema.Struct({
154 user: Schema.optional(Schema.NullOr(RawUserSchema)),
155});
156
157const RawViewerSchema = Schema.Struct({
158 nickname: Schema.optional(Schema.NullOr(Schema.String)),
159 display_name: Schema.optional(Schema.NullOr(Schema.String)),
160});
161
162/**
163 * `/user/permissions/repositories` filtered to one repository, which is the only place Bitbucket
164 * states what the credentials may do with it: nothing on the repository, the pull request or the
165 * workspace carries it. One row, or none where Bitbucket names no permission for this account.
166 */
167const RawRepositoryPermissionsSchema = Schema.Struct({
168 values: Schema.optional(
169 Schema.NullOr(
170 Schema.Array(Schema.Struct({ permission: Schema.optional(Schema.NullOr(Schema.String)) })),
171 ),
172 ),
173});
174
175export interface BitbucketPullRequest {
176 readonly number: number;
177 readonly title: string;
178 readonly url: string;
179 readonly author: PullRequestActor | null;
180 readonly headBranch: string;
181 readonly headRepositoryNameWithOwner: string | null;
182 readonly baseBranch: string;
183 readonly state: PullRequestState;
184 readonly isDraft: boolean;
185 /**
186 * Bitbucket reports no conflict state on a pull request, so the list leaves it unknown. The
187 * detail read asks the conflicts endpoint, which does answer.
188 */
189 readonly mergeability: PullRequestMergeability;
190 readonly createdAt: string;
191 readonly updatedAt: string;
192 readonly body: string;
193 readonly reviewRequestLogins: ReadonlyArray<string>;
194 readonly reviewers: ReadonlyArray<PullRequestActor>;
195 /** The reviewers as Bitbucket addresses them, which is what writing the set back takes. */
196 readonly reviewerIds: ReadonlyArray<string>;
197 /** Approvals and change requests, which Bitbucket keeps on its participants. */
198 readonly reviews: ReadonlyArray<PullRequestComment>;
199}
200
201function trimmed(value: string | null | undefined): string | null {
202 const text = value?.trim() ?? "";
203 return text.length > 0 ? text : null;
204}
205
206/**
207 * Bitbucket stamps times as `+00:00` with microseconds. The page sorts change requests from
208 * every host against each other as plain strings, so they are normalized to the same `Z` form
209 * the other hosts already use.
210 */
211function toIsoUtc(value: string): string {
212 return Option.match(DateTime.make(value), {
213 onNone: () => value,
214 onSome: DateTime.formatIso,
215 });
216}
217
218/** An app account has no nickname, so the display name is the only handle it has. */
219function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) {
220 const login = trimmed(raw?.nickname) ?? trimmed(raw?.display_name);
221 return login === null
222 ? null
223 : {
224 login,
225 name: trimmed(raw?.display_name),
226 avatarUrl: trimmed(raw?.links?.avatar?.href),
227 };
228}
229
230function toState(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): PullRequestState {
231 switch (raw.state?.trim().toUpperCase()) {
232 case "MERGED":
233 return "merged";
234 case "DECLINED":
235 case "SUPERSEDED":
236 return "closed";
237 default:
238 return "open";
239 }
240}
241
242function toBuildStatus(value: string | null | undefined): PullRequestCheckStatus {
243 switch (value?.trim().toUpperCase()) {
244 case "SUCCESSFUL":
245 return "success";
246 case "FAILED":
247 return "failure";
248 case "STOPPED":
249 return "cancelled";
250 case "INPROGRESS":
251 return "pending";
252 default:
253 return "neutral";
254 }
255}
256
257/**
258 * A participant who has voted is the closest Bitbucket has to a review, so it reads as one in
259 * the conversation. Participants who have only been added carry no verdict and are skipped.
260 */
261function toReviews(
262 raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
263): ReadonlyArray<PullRequestComment> {
264 return (raw.participants ?? []).flatMap((participant): ReadonlyArray<PullRequestComment> => {
265 const author = toActor(participant.user);
266 const votedAt = trimmed(participant.participated_on);
267 const reviewState =
268 trimmed(participant.state) ?? (participant.approved === true ? "approved" : null);
269 if (author === null || votedAt === null || reviewState === null) return [];
270 return [
271 {
272 id: `${raw.id}:${author.login}`,
273 kind: "review",
274 author,
275 body: "",
276 createdAt: toIsoUtc(votedAt),
277 url: null,
278 path: null,
279 reviewState,
280 },
281 ];
282 });
283}
284
285function toPullRequest(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): BitbucketPullRequest {
286 const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => {
287 const actor = toActor(reviewer);
288 return actor === null ? [] : [actor];
289 });
290 return {
291 number: raw.id,
292 title: raw.title,
293 url: raw.links.html.href,
294 author: toActor(raw.author),
295 headBranch: raw.source.branch.name,
296 headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null,
297 baseBranch: raw.destination.branch.name,
298 state: toState(raw),
299 isDraft: raw.draft ?? false,
300 mergeability: "unknown",
301 createdAt: toIsoUtc(raw.created_on),
302 updatedAt: toIsoUtc(raw.updated_on),
303 body: raw.description ?? "",
304 reviewRequestLogins: reviewers.map((reviewer) => reviewer.login),
305 reviewers,
306 reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => trimmed(reviewer.uuid) ?? []),
307 reviews: toReviews(raw),
308 };
309}
310
311const decodePage = decodeJsonResult(RawPageSchema);
312const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema);
313const decodePullRequest = decodeJsonResult(RawPullRequestSchema);
314const decodeCommentEntry = Schema.decodeUnknownExit(RawCommentSchema);
315const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema);
316const decodeStatusEntry = Schema.decodeUnknownExit(RawStatusSchema);
317const decodeDiffstatEntry = Schema.decodeUnknownExit(RawDiffstatSchema);
318const decodeMemberEntry = Schema.decodeUnknownExit(RawMemberSchema);
319const decodeViewer = decodeJsonResult(RawViewerSchema);
320const decodeConflicts = decodeJsonResult(RawPageSchema);
321const decodeRepositoryPermissions = decodeJsonResult(RawRepositoryPermissionsSchema);
322
323type DecodeFailure = Cause.Cause<Schema.SchemaError>;
324
325export interface BitbucketPage<A> {
326 readonly items: ReadonlyArray<A>;
327 /** The whole URL of the next page, which Bitbucket sends rather than an offset. */
328 readonly next: string | null;
329}
330
331/** Malformed entries are skipped rather than failing the page, as on the other hosts. */
332export function decodePullRequestPageJson(
333 raw: string,
334): Result.Result<BitbucketPage<BitbucketPullRequest>, DecodeFailure> {
335 const decoded = decodePage(raw);
336 if (!Result.isSuccess(decoded)) {
337 return Result.fail(decoded.failure);
338 }
339 const items: BitbucketPullRequest[] = [];
340 for (const entry of decoded.success.values) {
341 const item = decodePullRequestEntry(entry);
342 if (Exit.isSuccess(item)) {
343 items.push(toPullRequest(item.value));
344 }
345 }
346 return Result.succeed({ items, next: trimmed(decoded.success.next) });
347}
348
349export function decodePullRequestJson(
350 raw: string,
351): Result.Result<BitbucketPullRequest, DecodeFailure> {
352 const decoded = decodePullRequest(raw);
353 return Result.isSuccess(decoded)
354 ? Result.succeed(toPullRequest(decoded.success))
355 : Result.fail(decoded.failure);
356}
357
358export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> {
359 const decoded = decodeViewer(raw);
360 return Result.isSuccess(decoded)
361 ? Result.succeed(trimmed(decoded.success.nickname) ?? trimmed(decoded.success.display_name))
362 : Result.fail(decoded.failure);
363}
364
365/**
366 * Whether the configured credentials can write to the repository, which is what merging needs.
367 * Bitbucket answers `admin`, `write` or `read`, and an empty page means it named no permission at
368 * all for this account — an unknown standing, which is granted rather than guessed away.
369 */
370export function decodeRepositoryPermissionJson(raw: string): Result.Result<boolean, DecodeFailure> {
371 const decoded = decodeRepositoryPermissions(raw);
372 if (!Result.isSuccess(decoded)) {
373 return Result.fail(decoded.failure);
374 }
375 const permission = trimmed(decoded.success.values?.[0]?.permission)?.toLowerCase() ?? null;
376 return Result.succeed(permission === null || permission === "admin" || permission === "write");
377}
378
379/**
380 * The workspace's members, which is the nearest thing Bitbucket has to "who may review this".
381 * Nothing on a repository lists the people with access to it — `permissions-config/users` is for
382 * administrators only — and a pull request can be sent to anyone in the workspace, so this is the
383 * list Bitbucket's own reviewer field is filled from too.
384 *
385 * Nobody is marked requested here: who has been asked lives on the pull request, and only the
386 * caller holds both.
387 */
388export function decodeWorkspaceMembersJson(
389 raw: string,
390): Result.Result<BitbucketPage<PullRequestReviewerCandidate>, DecodeFailure> {
391 const decoded = decodePage(raw);
392 if (!Result.isSuccess(decoded)) {
393 return Result.fail(decoded.failure);
394 }
395 const items: PullRequestReviewerCandidate[] = [];
396 for (const entry of decoded.success.values) {
397 const member = decodeMemberEntry(entry);
398 if (Exit.isFailure(member)) continue;
399 const uuid = trimmed(member.value.user?.uuid);
400 const actor = toActor(member.value.user);
401 if (uuid === null || actor === null) continue;
402 items.push({ ...actor, id: uuid, kind: "user", isRequested: false });
403 }
404 return Result.succeed({ items, next: trimmed(decoded.success.next) });
405}
406
407/** One comment as Bitbucket sent it, kept so threads can be assembled across pages. */
408export type BitbucketRawComment = Schema.Schema.Type<typeof RawCommentSchema>;
409
410export interface BitbucketComments {
411 readonly comments: ReadonlyArray<PullRequestComment>;
412 /**
413 * The same comments unread, for `buildReviewThreads`. A reply and the remark it answers can
414 * land on different pages, and only the caller holding every page can put them together.
415 */
416 readonly entries: ReadonlyArray<BitbucketRawComment>;
417 readonly next: string | null;
418}
419
420/**
421 * Bitbucket returns one flat list, so a thread is reassembled from it: a comment pinned to a
422 * line opens a thread, and every reply that leads back to it belongs in it. A reply whose
423 * parent is on a page that was not read has nowhere to go, and is left out rather than shown
424 * as a thread of its own — it still stands in the flat conversation, which needs no parent.
425 */
426export function buildReviewThreads(
427 comments: ReadonlyArray<BitbucketRawComment>,
428): ReadonlyArray<PullRequestReviewThread> {
429 const byId = new Map(comments.map((comment) => [comment.id, comment]));
430 const rootOf = (comment: Schema.Schema.Type<typeof RawCommentSchema>) => {
431 // Bounded by the number of comments read, so a parent cycle cannot spin here.
432 let current = comment;
433 for (let step = 0; step < byId.size; step += 1) {
434 const parent = current.parent === null ? undefined : byId.get(current.parent?.id ?? -1);
435 if (parent === undefined) return current;
436 current = parent;
437 }
438 return current;
439 };
440
441 const threads = new Map<number, PullRequestReviewThread>();
442 const replies = new Map<number, Array<Schema.Schema.Type<typeof RawCommentSchema>>>();
443 for (const comment of comments) {
444 const root = rootOf(comment);
445 const inline = root.inline;
446 const path = trimmed(inline?.path);
447 if (path === null) continue;
448 if (root.id === comment.id) {
449 // `to` is the line as the file stands now, `from` the line it replaced; a comment that
450 // carries only `from` was written against the removed side.
451 const side = inline?.to === null || inline?.to === undefined ? "left" : "right";
452 const line = side === "left" ? inline?.from : inline?.to;
453 threads.set(root.id, {
454 id: String(root.id),
455 path,
456 line: typeof line === "number" && line > 0 ? line : null,
457 side,
458 isResolved: root.resolution !== null && root.resolution !== undefined,
459 isOutdated: inline?.outdated === true,
460 comments: [],
461 });
462 }
463 const bucket = replies.get(root.id);
464 if (bucket === undefined) replies.set(root.id, [comment]);
465 else bucket.push(comment);
466 }
467
468 return [...threads.values()].flatMap((thread) => {
469 const entries = (replies.get(Number(thread.id)) ?? [])
470 .toSorted((left, right) => left.created_on.localeCompare(right.created_on))
471 .map((comment) => ({
472 id: String(comment.id),
473 author: toActor(comment.user),
474 body: comment.content?.raw ?? "",
475 createdAt: toIsoUtc(comment.created_on),
476 url: trimmed(comment.links?.html?.href),
477 }));
478 return entries.length === 0 ? [] : [{ ...thread, comments: entries }];
479 });
480}
481
482/**
483 * Deleted comments and ones their author has not posted yet carry nothing to show. A comment
484 * pinned to a file is a line-level review comment, which is what that kind means.
485 */
486export function decodeCommentsJson(raw: string): Result.Result<BitbucketComments, DecodeFailure> {
487 const decoded = decodePage(raw);
488 if (!Result.isSuccess(decoded)) {
489 return Result.fail(decoded.failure);
490 }
491 const comments: PullRequestComment[] = [];
492 const kept: Array<BitbucketRawComment> = [];
493 for (const entry of decoded.success.values) {
494 const decodedComment = decodeCommentEntry(entry);
495 if (Exit.isFailure(decodedComment)) continue;
496 const comment = decodedComment.value;
497 if (comment.deleted === true || comment.pending === true) continue;
498 const body = comment.content?.raw ?? "";
499 if (body.trim().length === 0) continue;
500 kept.push(comment);
501 const path = trimmed(comment.inline?.path);
502 comments.push({
503 id: String(comment.id),
504 kind: path === null ? "issue-comment" : "review-comment",
505 author: toActor(comment.user),
506 body,
507 createdAt: toIsoUtc(comment.created_on),
508 url: trimmed(comment.links?.html?.href),
509 path,
510 reviewState: null,
511 });
512 }
513 return Result.succeed({ comments, entries: kept, next: trimmed(decoded.success.next) });
514}
515
516export function decodeCommitsJson(
517 raw: string,
518): Result.Result<BitbucketPage<PullRequestCommit>, DecodeFailure> {
519 const decoded = decodePage(raw);
520 if (!Result.isSuccess(decoded)) {
521 return Result.fail(decoded.failure);
522 }
523 const commits: PullRequestCommit[] = [];
524 for (const entry of decoded.success.values) {
525 const decodedCommit = decodeCommitEntry(entry);
526 if (Exit.isFailure(decodedCommit)) continue;
527 const commit = decodedCommit.value;
528 const committedDate = trimmed(commit.date);
529 if (committedDate === null) continue;
530 const linkedAuthor = toActor(commit.author?.user);
531 const rawAuthor = trimmed(commit.author?.raw);
532 commits.push({
533 oid: commit.hash,
534 messageHeadline: (commit.message ?? "").split("\n")[0] ?? "",
535 committedDate: toIsoUtc(committedDate),
536 authors:
537 linkedAuthor !== null
538 ? [linkedAuthor]
539 : rawAuthor === null
540 ? []
541 : [{ login: rawAuthor, name: rawAuthor, avatarUrl: null }],
542 });
543 }
544 // Bitbucket lists a pull request's commits newest first; the timeline reads oldest first.
545 return Result.succeed({ items: commits.toReversed(), next: trimmed(decoded.success.next) });
546}
547
548export function decodeStatusesJson(
549 raw: string,
550): Result.Result<BitbucketPage<PullRequestCheck>, DecodeFailure> {
551 const decoded = decodePage(raw);
552 if (!Result.isSuccess(decoded)) {
553 return Result.fail(decoded.failure);
554 }
555 const checks: Array<{
556 readonly check: PullRequestCheck;
557 readonly workflowName: string | null;
558 readonly at: string | null;
559 }> = [];
560 for (const entry of decoded.success.values) {
561 const decodedStatus = decodeStatusEntry(entry);
562 if (Exit.isFailure(decodedStatus)) continue;
563 const status = decodedStatus.value;
564 const name = trimmed(status.name) ?? trimmed(status.key);
565 if (name === null) continue;
566 // Bitbucket re-uses a status key when a pipeline is run again, so the same check can appear
567 // twice on one page. Nothing decoded here says which copy is newer, so the later one wins,
568 // which is the order Bitbucket writes an update in. The key is kept as the workflow name so
569 // two different pipelines that display the same name are not folded into one.
570 checks.push({
571 check: {
572 name,
573 status: toBuildStatus(status.state),
574 description: trimmed(status.description),
575 url: trimmed(status.url),
576 },
577 workflowName: trimmed(status.key),
578 at: null,
579 });
580 }
581 return Result.succeed({ items: dedupeChecks(checks), next: trimmed(decoded.success.next) });
582}
583
584export interface BitbucketDiffStat {
585 readonly additions: number;
586 readonly deletions: number;
587 readonly changedFiles: number;
588}
589
590export interface BitbucketDiffStatPage extends BitbucketDiffStat {
591 readonly next: string | null;
592}
593
594/** One entry per changed file, each carrying that file's line counts. */
595export function decodeDiffstatJson(
596 raw: string,
597): Result.Result<BitbucketDiffStatPage, DecodeFailure> {
598 const decoded = decodePage(raw);
599 if (!Result.isSuccess(decoded)) {
600 return Result.fail(decoded.failure);
601 }
602 let additions = 0;
603 let deletions = 0;
604 let changedFiles = 0;
605 for (const entry of decoded.success.values) {
606 const decodedStat = decodeDiffstatEntry(entry);
607 if (Exit.isFailure(decodedStat)) continue;
608 additions += decodedStat.value.lines_added ?? 0;
609 deletions += decodedStat.value.lines_removed ?? 0;
610 changedFiles += 1;
611 }
612 return Result.succeed({
613 additions,
614 deletions,
615 changedFiles,
616 next: trimmed(decoded.success.next),
617 });
618}
619
620/**
621 * The conflicts endpoint answers with one entry per conflicting path, so an empty page is the
622 * only statement Bitbucket makes that a pull request merges cleanly.
623 */
624export function decodeConflictsJson(
625 raw: string,
626): Result.Result<PullRequestMergeability, DecodeFailure> {
627 const decoded = decodeConflicts(raw);
628 return Result.isSuccess(decoded)
629 ? Result.succeed(decoded.success.values.length === 0 ? "mergeable" : "conflicting")
630 : Result.fail(decoded.failure);
631}
apps/server/src/server.ts+1-8
...@@ -52,7 +52,6 @@ import * as ProviderHostLive from "./provider/ProviderHostLive.ts";...@@ -52,7 +52,6 @@ import * as ProviderHostLive from "./provider/ProviderHostLive.ts";
52import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport";52import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport";
53import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";53import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
54import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";54import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
55import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
56import * as GitHubApi from "./sourceControl/GitHubApi.ts";55import * as GitHubApi from "./sourceControl/GitHubApi.ts";
57import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";56import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
58import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";57import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
...@@ -297,13 +296,7 @@ const layerPersistence = Layer.empty.pipe(Layer.provideMerge(SqlitePersistence.l...@@ -297,13 +296,7 @@ const layerPersistence = Layer.empty.pipe(Layer.provideMerge(SqlitePersistence.l
297const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer));296const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer));
298297
299const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.pipe(298const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.pipe(
300 Layer.provide(299 Layer.provide(Layer.mergeAll(GitHubApi.layerWithDependencies, SourceControlBuiltInDrivers.layer)),
301 Layer.mergeAll(
302 BitbucketApi.layer,
303 GitHubApi.layerWithDependencies,
304 SourceControlBuiltInDrivers.layer,
305 ),
306 ),
307 Layer.provideMerge(GitVcsDriver.layer),300 Layer.provideMerge(GitVcsDriver.layer),
308 Layer.provideMerge(layerVcsDriverRegistry),301 Layer.provideMerge(layerVcsDriverRegistry),
309);302);
apps/server/src/sourceControl/BitbucketApi.test.ts+5-1
...@@ -11,11 +11,13 @@ import * as TestClock from "effect/testing/TestClock";...@@ -11,11 +11,13 @@ import * as TestClock from "effect/testing/TestClock";
11import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";11import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
1212
13import { GitCommandError } from "@t3tools/contracts";13import { GitCommandError } from "@t3tools/contracts";
14import * as BitbucketApi from "./BitbucketApi.ts";14import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
15import * as ServerSettings from "../serverSettings.ts";15import * as ServerSettings from "../serverSettings.ts";
16import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";16import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
17import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";17import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
18import type * as VcsDriver from "../vcs/VcsDriver.ts";18import type * as VcsDriver from "../vcs/VcsDriver.ts";
19import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
20import * as VcsProcess from "../vcs/VcsProcess.ts";
1921
20const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError);22const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError);
2123
...@@ -139,6 +141,8 @@ function makeLayer(input: {...@@ -139,6 +141,8 @@ function makeLayer(input: {
139 );141 );
140142
141 const layer = BitbucketApi.layer.pipe(143 const layer = BitbucketApi.layer.pipe(
144 Layer.provide(ServerSourceControlHost.layer),
145 Layer.provide(Layer.mock(VcsProcess.VcsProcess)({})),
142 Layer.provide(146 Layer.provide(
143 Layer.succeed(147 Layer.succeed(
144 HttpClient.HttpClient,148 HttpClient.HttpClient,
apps/server/src/sourceControl/BitbucketApi.ts deleted-1163
...@@ -1,1163 +0,0 @@
1import * as Clock from "effect/Clock";
2import * as Config from "effect/Config";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import {
10 DEFAULT_SERVER_SETTINGS,
11 NonNegativeInt,
12 TrimmedNonEmptyString,
13 type BitbucketSettings,
14 type SourceControlProviderAuth,
15 type SourceControlRepositoryCloneUrls,
16 type SourceControlRepositoryVisibility,
17} from "@t3tools/contracts";
18import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
19import { sanitizeBranchFragment } from "@t3tools/shared/git";
20import {
21 detectSourceControlProviderFromRemoteUrl,
22 isSshRemoteUrl,
23} from "@t3tools/shared/sourceControl";
24
25import {
26 BitbucketPullRequestListSchema,
27 BitbucketPullRequestSchema,
28 normalizeBitbucketPullRequestRecord,
29 type NormalizedBitbucketPullRequestRecord,
30} from "./bitbucketPullRequests.ts";
31import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
32import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
33import * as ServerSettings from "../serverSettings.ts";
34import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
35import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
36import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit";
37
38const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0";
39/** A response body past this is cut short, so one huge diff cannot exhaust the server. */
40const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
41/** Bitbucket redirects a diff once; this leaves room without following a chain forever. */
42const MAX_REDIRECTS = 3;
43
44const BitbucketApiEnvConfig = Config.all({
45 baseUrl: Config.String("T3CODE_BITBUCKET_API_BASE_URL").pipe(
46 Config.withDefault(DEFAULT_API_BASE_URL),
47 ),
48 accessToken: Config.String("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option),
49 email: Config.String("T3CODE_BITBUCKET_EMAIL").pipe(Config.option),
50 apiToken: Config.String("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option),
51});
52
53const BitbucketApiOperation = Schema.Literals([
54 "resolveRepository",
55 "getRepository",
56 "getBranchingModel",
57 "getPullRequest",
58 "listPullRequests",
59 "createRepository",
60 "createPullRequest",
61 "probeAuth",
62 "checkoutPullRequest",
63 // The raw escape hatch. Callers name their own operation in their own error, the way the
64 // pull request wrappers do on top of `gh` and `glab`.
65 "request",
66]);
67type BitbucketApiOperation = typeof BitbucketApiOperation.Type;
68
69export class BitbucketRepositoryLocatorError extends Schema.TaggedError<BitbucketRepositoryLocatorError>()(
70 "BitbucketRepositoryLocatorError",
71 {
72 repository: Schema.String,
73 },
74) {
75 static readonly detail = "Bitbucket repositories must be specified as workspace/repository.";
76
77 get detail(): string {
78 return BitbucketRepositoryLocatorError.detail;
79 }
80
81 override get message(): string {
82 return `Bitbucket API failed: ${this.detail}`;
83 }
84}
85
86export class BitbucketRequestError extends Schema.TaggedError<BitbucketRequestError>()(
87 "BitbucketRequestError",
88 {
89 operation: BitbucketApiOperation,
90 cause: Schema.Defect(),
91 },
92) {
93 get detail(): string {
94 return "Failed to send the Bitbucket request.";
95 }
96
97 override get message(): string {
98 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
99 }
100}
101
102export class BitbucketResponseError extends Schema.TaggedError<BitbucketResponseError>()(
103 "BitbucketResponseError",
104 {
105 operation: BitbucketApiOperation,
106 status: Schema.Int,
107 responseBodyLength: NonNegativeInt,
108 retryAt: Schema.optional(Schema.Number),
109 },
110) {
111 get detail(): string {
112 return `Bitbucket returned HTTP ${this.status}.`;
113 }
114
115 override get message(): string {
116 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
117 }
118}
119
120export class BitbucketResponseBodyReadError extends Schema.TaggedError<BitbucketResponseBodyReadError>()(
121 "BitbucketResponseBodyReadError",
122 {
123 operation: BitbucketApiOperation,
124 status: Schema.Int,
125 retryAt: Schema.optional(Schema.Number),
126 cause: Schema.Defect(),
127 },
128) {
129 get detail(): string {
130 return `Bitbucket returned HTTP ${this.status}.`;
131 }
132
133 override get message(): string {
134 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
135 }
136}
137
138export class BitbucketResponseDecodeError extends Schema.TaggedError<BitbucketResponseDecodeError>()(
139 "BitbucketResponseDecodeError",
140 {
141 operation: BitbucketApiOperation,
142 status: Schema.Int,
143 cause: Schema.Defect(),
144 },
145) {
146 get detail(): string {
147 return "Bitbucket returned invalid JSON for the requested resource.";
148 }
149
150 override get message(): string {
151 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
152 }
153}
154
155export class BitbucketRepositoryVcsResolveError extends Schema.TaggedError<BitbucketRepositoryVcsResolveError>()(
156 "BitbucketRepositoryVcsResolveError",
157 {
158 cwd: Schema.String,
159 cause: Schema.Defect(),
160 },
161) {
162 get detail(): string {
163 return `Failed to resolve VCS repository for ${this.cwd}.`;
164 }
165
166 override get message(): string {
167 return `Bitbucket API failed in resolveRepository: ${this.detail}`;
168 }
169}
170
171export class BitbucketRepositoryRemotesListError extends Schema.TaggedError<BitbucketRepositoryRemotesListError>()(
172 "BitbucketRepositoryRemotesListError",
173 {
174 cwd: Schema.String,
175 cause: Schema.Defect(),
176 },
177) {
178 get detail(): string {
179 return `Failed to list remotes for ${this.cwd}.`;
180 }
181
182 override get message(): string {
183 return `Bitbucket API failed in resolveRepository: ${this.detail}`;
184 }
185}
186
187export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedError<BitbucketRepositoryRemoteNotFoundError>()(
188 "BitbucketRepositoryRemoteNotFoundError",
189 {
190 cwd: Schema.String,
191 },
192) {
193 get detail(): string {
194 return `No Bitbucket repository remote was detected for ${this.cwd}.`;
195 }
196
197 override get message(): string {
198 return `Bitbucket API failed in resolveRepository: ${this.detail}`;
199 }
200}
201
202export class BitbucketPullRequestBodyReadError extends Schema.TaggedError<BitbucketPullRequestBodyReadError>()(
203 "BitbucketPullRequestBodyReadError",
204 {
205 cwd: Schema.String,
206 bodyFile: Schema.String,
207 cause: Schema.Defect(),
208 },
209) {
210 get detail(): string {
211 return `Failed to read pull request body file ${this.bodyFile}.`;
212 }
213
214 override get message(): string {
215 return `Bitbucket API failed in createPullRequest: ${this.detail}`;
216 }
217}
218
219export class BitbucketCheckoutError extends Schema.TaggedError<BitbucketCheckoutError>()(
220 "BitbucketCheckoutError",
221 {
222 cwd: Schema.String,
223 reference: Schema.String,
224 cause: Schema.Defect(),
225 },
226) {
227 get detail(): string {
228 return "Failed to check out the Bitbucket pull request.";
229 }
230
231 override get message(): string {
232 return `Bitbucket API failed in checkoutPullRequest: ${this.detail}`;
233 }
234}
235
236/**
237 * A url that does not belong to the configured Bitbucket. Refused rather than followed, because
238 * the request carries the account's credentials and a url that came back in a response — a
239 * pagination cursor, or the target of a redirect — is not this server's to trust.
240 */
241export class BitbucketUntrustedUrlError extends Schema.TaggedError<BitbucketUntrustedUrlError>()(
242 "BitbucketUntrustedUrlError",
243 {
244 /** The host only. A rejected hop is often a signed url, whose query carries a credential. */
245 host: Schema.String,
246 },
247) {
248 get detail(): string {
249 return `The response pointed at ${this.host}, outside the configured Bitbucket.`;
250 }
251
252 override get message(): string {
253 return `Bitbucket API failed in request: ${this.detail}`;
254 }
255}
256
257export const BitbucketApiError = Schema.Union([
258 BitbucketUntrustedUrlError,
259 BitbucketRepositoryLocatorError,
260 BitbucketRequestError,
261 BitbucketResponseError,
262 BitbucketResponseBodyReadError,
263 BitbucketResponseDecodeError,
264 BitbucketRepositoryVcsResolveError,
265 BitbucketRepositoryRemotesListError,
266 BitbucketRepositoryRemoteNotFoundError,
267 BitbucketPullRequestBodyReadError,
268 BitbucketCheckoutError,
269]);
270export type BitbucketApiError = typeof BitbucketApiError.Type;
271const isBitbucketApiError = Schema.is(BitbucketApiError);
272
273const RawBitbucketRepositorySchema = Schema.Struct({
274 full_name: TrimmedNonEmptyString,
275 links: Schema.Struct({
276 html: Schema.optional(
277 Schema.Struct({
278 href: TrimmedNonEmptyString,
279 }),
280 ),
281 clone: Schema.optional(
282 Schema.Array(
283 Schema.Struct({
284 name: TrimmedNonEmptyString,
285 href: TrimmedNonEmptyString,
286 }),
287 ),
288 ),
289 }),
290 mainbranch: Schema.optional(
291 Schema.NullOr(
292 Schema.Struct({
293 name: TrimmedNonEmptyString,
294 }),
295 ),
296 ),
297});
298
299const RawBitbucketBranchingModelSchema = Schema.Struct({
300 development: Schema.optional(
301 Schema.Struct({
302 branch: Schema.optional(
303 Schema.NullOr(
304 Schema.Struct({
305 name: Schema.optional(TrimmedNonEmptyString),
306 }),
307 ),
308 ),
309 is_valid: Schema.optional(Schema.Boolean),
310 name: Schema.optional(Schema.NullOr(Schema.String)),
311 use_mainbranch: Schema.optional(Schema.Boolean),
312 }),
313 ),
314});
315
316const BitbucketUserSchema = Schema.Struct({
317 username: Schema.optional(TrimmedNonEmptyString),
318 display_name: Schema.optional(TrimmedNonEmptyString),
319 account_id: Schema.optional(TrimmedNonEmptyString),
320});
321
322export interface BitbucketRepositoryLocator {
323 readonly workspace: string;
324 readonly repoSlug: string;
325}
326
327export class BitbucketApi extends Context.Service<
328 BitbucketApi,
329 {
330 readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
331
332 /**
333 * One authenticated request, returning the body verbatim. Bitbucket answers most endpoints
334 * with JSON and a few — a pull request diff, for one — with plain text, so the body is
335 * handed back undecoded for the caller to read as it sees fit.
336 */
337 readonly request: (input: {
338 readonly method: "GET" | "POST" | "PUT" | "DELETE";
339 /**
340 * A path below the API base, or a whole URL as a paged response reports its next page.
341 * A whole URL is refused unless it belongs to the configured Bitbucket.
342 */
343 readonly url: string;
344 /** A JSON document, for the endpoints that take one. */
345 readonly body?: string;
346 /** Response bytes to keep; past this the body comes back cut short and marked. */
347 readonly maxBytes?: number;
348 }) => Effect.Effect<{ readonly body: string; readonly truncated: boolean }, BitbucketApiError>;
349 readonly listPullRequests: (input: {
350 readonly cwd: string;
351 readonly context?: SourceControlProvider.SourceControlProviderContext;
352 readonly headSelector: string;
353 readonly source?: SourceControlProvider.SourceControlRefSelector;
354 readonly state: "open" | "closed" | "merged" | "all";
355 readonly limit?: number;
356 }) => Effect.Effect<ReadonlyArray<NormalizedBitbucketPullRequestRecord>, BitbucketApiError>;
357 readonly getPullRequest: (input: {
358 readonly cwd: string;
359 readonly context?: SourceControlProvider.SourceControlProviderContext;
360 readonly reference: string;
361 }) => Effect.Effect<NormalizedBitbucketPullRequestRecord, BitbucketApiError>;
362 readonly getRepositoryCloneUrls: (input: {
363 readonly cwd: string;
364 readonly context?: SourceControlProvider.SourceControlProviderContext;
365 readonly repository: string;
366 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
367 readonly createRepository: (input: {
368 readonly cwd: string;
369 readonly repository: string;
370 readonly visibility: SourceControlRepositoryVisibility;
371 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
372 readonly createPullRequest: (input: {
373 readonly cwd: string;
374 readonly context?: SourceControlProvider.SourceControlProviderContext;
375 readonly baseBranch: string;
376 readonly headSelector: string;
377 readonly source?: SourceControlProvider.SourceControlRefSelector;
378 readonly target?: SourceControlProvider.SourceControlRefSelector;
379 readonly title: string;
380 readonly bodyFile: string;
381 }) => Effect.Effect<void, BitbucketApiError>;
382 readonly getDefaultBranch: (input: {
383 readonly cwd: string;
384 readonly context?: SourceControlProvider.SourceControlProviderContext;
385 }) => Effect.Effect<string | null, BitbucketApiError>;
386 readonly checkoutPullRequest: (input: {
387 readonly cwd: string;
388 readonly context?: SourceControlProvider.SourceControlProviderContext;
389 readonly reference: string;
390 readonly force?: boolean;
391 }) => Effect.Effect<void, BitbucketApiError>;
392 }
393>()("t3/sourceControl/BitbucketApi") {}
394
395function nonEmpty(value: string | undefined): Option.Option<string> {
396 const trimmed = value?.trim();
397 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
398}
399
400function normalizeChangeRequestId(reference: string): string {
401 const trimmed = reference.trim().replace(/^#/, "");
402 const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec(
403 trimmed,
404 );
405 return urlMatch?.[1] ?? trimmed;
406}
407
408function sourceWorkspace(input: {
409 readonly headSelector: string;
410 readonly source?: SourceControlProvider.SourceControlRefSelector;
411}): string | undefined {
412 if (input.source?.owner) return input.source.owner;
413 return SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner;
414}
415
416function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> {
417 switch (state) {
418 case "open":
419 return ["OPEN"];
420 case "closed":
421 return ["DECLINED", "SUPERSEDED"];
422 case "merged":
423 return ["MERGED"];
424 case "all":
425 return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"];
426 }
427}
428
429function bitbucketQueryString(filters: ReadonlyArray<string>): string {
430 return filters.join(" AND ");
431}
432
433function bitbucketStateFilter(states: ReadonlyArray<string>): string {
434 return states.length === 1
435 ? `state = "${states[0]}"`
436 : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`;
437}
438
439function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null {
440 const normalized = value.trim().replace(/\.git$/u, "");
441 const parts = normalized.split("/").filter((part) => part.length > 0);
442 if (parts.length < 2) return null;
443 const workspace = parts.at(-2);
444 const repoSlug = parts.at(-1);
445 return workspace && repoSlug ? { workspace, repoSlug } : null;
446}
447
448function requireRepositoryLocator(
449 repository: string,
450): Effect.Effect<BitbucketRepositoryLocator, BitbucketApiError> {
451 const locator = parseBitbucketRepositorySlug(repository);
452 return locator
453 ? Effect.succeed(locator)
454 : Effect.fail(
455 new BitbucketRepositoryLocatorError({
456 repository,
457 }),
458 );
459}
460
461function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null {
462 const trimmed = remoteUrl.trim();
463 const scpMatch = /^[a-zA-Z0-9._-]+@[^:/]+:(.+)$/.exec(trimmed);
464 if (scpMatch?.[1]) {
465 return parseBitbucketRepositorySlug(scpMatch[1]);
466 }
467
468 try {
469 return parseBitbucketRepositorySlug(new URL(trimmed).pathname);
470 } catch {
471 return null;
472 }
473}
474
475function normalizeRepositoryCloneUrls(
476 raw: typeof RawBitbucketRepositorySchema.Type,
477): SourceControlRepositoryCloneUrls {
478 const httpClone =
479 raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ??
480 raw.links.html?.href;
481 const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href;
482
483 return {
484 nameWithOwner: raw.full_name,
485 url: httpClone ?? raw.links.html?.href ?? raw.full_name,
486 sshUrl: sshClone ?? httpClone ?? raw.full_name,
487 };
488}
489
490function defaultChangeRequestTargetBranch(input: {
491 readonly repository: typeof RawBitbucketRepositorySchema.Type;
492 readonly branchingModel: typeof RawBitbucketBranchingModelSchema.Type | null;
493}): string | null {
494 const repositoryMainBranch = input.repository.mainbranch?.name ?? null;
495 const development = input.branchingModel?.development;
496 if (!development || development.use_mainbranch === true || development.is_valid === false) {
497 return repositoryMainBranch;
498 }
499
500 const developmentBranch = development.branch?.name?.trim() ?? development.name?.trim() ?? "";
501 if (developmentBranch.length === 0 || developmentBranch === "null") {
502 return repositoryMainBranch;
503 }
504
505 return developmentBranch;
506}
507
508function shouldPreferSshRemote(originRemoteUrl: string | null): boolean {
509 if (!originRemoteUrl) return false;
510 return isSshRemoteUrl(originRemoteUrl);
511}
512
513function selectCloneUrl(input: {
514 readonly cloneUrls: SourceControlRepositoryCloneUrls;
515 readonly originRemoteUrl: string | null;
516}): string {
517 return shouldPreferSshRemote(input.originRemoteUrl)
518 ? input.cloneUrls.sshUrl
519 : input.cloneUrls.url;
520}
521
522function checkoutBranchName(input: {
523 readonly pullRequestId: number;
524 readonly headBranch: string;
525 readonly isCrossRepository: boolean;
526}): string {
527 if (!input.isCrossRepository) {
528 return input.headBranch;
529 }
530
531 return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`;
532}
533
534function repositoryNameWithOwner(
535 repository: Schema.Schema.Type<typeof BitbucketPullRequestSchema>["source"]["repository"],
536): string | null {
537 const fullName = repository?.full_name?.trim() ?? "";
538 return fullName.length > 0 ? fullName : null;
539}
540
541function repositoryOwnerName(repositoryName: string): string {
542 return repositoryName.split("/")[0]?.trim() || "bitbucket";
543}
544
545type BitbucketCredential =
546 | { readonly kind: "access-token"; readonly accessToken: string }
547 | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string };
548
549/**
550 * Visible ASCII only. A value the HTTP stack rejects makes it throw an error quoting the whole
551 * header, and that error travels to clients as a cause, so an unusable token is treated as unset.
552 */
553const HEADER_SAFE = /^[\x21-\x7e]+$/u;
554
555function credentialFrom(input: {
556 readonly accessToken: string;
557 readonly email: string;
558 readonly apiToken: string;
559}): BitbucketCredential | null {
560 if (HEADER_SAFE.test(input.accessToken)) {
561 return { kind: "access-token", accessToken: input.accessToken };
562 }
563 if (HEADER_SAFE.test(input.email) && HEADER_SAFE.test(input.apiToken)) {
564 return { kind: "api-token", email: input.email, apiToken: input.apiToken };
565 }
566 return null;
567}
568
569/**
570 * Credentials saved in settings win over the `T3CODE_BITBUCKET_*` environment variables, which
571 * stay as a fallback. Within each source the access token wins.
572 */
573function resolveCredential(
574 settings: BitbucketSettings,
575 env: Config.Success<typeof BitbucketApiEnvConfig>,
576): BitbucketCredential | null {
577 return (
578 credentialFrom(settings) ??
579 credentialFrom({
580 accessToken: Option.getOrElse(env.accessToken, () => ""),
581 email: Option.getOrElse(env.email, () => ""),
582 apiToken: Option.getOrElse(env.apiToken, () => ""),
583 })
584 );
585}
586
587function authFromCredential(credential: BitbucketCredential | null): SourceControlProviderAuth {
588 if (credential?.kind === "access-token") {
589 return {
590 status: "unknown",
591 account: Option.none(),
592 host: Option.some("bitbucket.org"),
593 detail: Option.some("An access token is configured."),
594 };
595 }
596
597 if (credential?.kind === "api-token") {
598 return {
599 status: "unknown",
600 account: Option.some(credential.email),
601 host: Option.some("bitbucket.org"),
602 detail: Option.some("An API token is configured."),
603 };
604 }
605
606 return {
607 status: "unauthenticated",
608 account: Option.none(),
609 host: Option.some("bitbucket.org"),
610 detail: Option.some(
611 "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.",
612 ),
613 };
614}
615
616/** Null for anything that is not a url at all, which is never the configured Bitbucket. */
617function originOf(value: string): string | null {
618 try {
619 return new URL(value).origin;
620 } catch {
621 return null;
622 }
623}
624
625function responseError(
626 operation: BitbucketApiOperation,
627 response: HttpClientResponse.HttpClientResponse,
628): Effect.Effect<never, BitbucketApiError> {
629 // Bounded like any other body: an error response is no smaller than a successful one, and
630 // only its length is reported anyway.
631 return Effect.gen(function* () {
632 const now = yield* Clock.currentTimeMillis;
633 const retryAt = retryAtFromHeader(response.headers["retry-after"], now);
634 const collected = yield* collectUint8StreamText({
635 stream: response.stream,
636 maxBytes: DEFAULT_MAX_RESPONSE_BYTES,
637 }).pipe(
638 Effect.mapError(
639 (cause) =>
640 new BitbucketResponseBodyReadError({
641 operation,
642 status: response.status,
643 retryAt,
644 cause,
645 }),
646 ),
647 );
648 return yield* new BitbucketResponseError({
649 operation,
650 status: response.status,
651 responseBodyLength: collected.text.length,
652 retryAt,
653 });
654 });
655}
656
657/** @public Service construction is part of the canonical Effect module API. */
658export const make = Effect.gen(function* () {
659 const config = yield* BitbucketApiEnvConfig;
660 const serverSettings = yield* ServerSettings.ServerSettingsService;
661 const httpClient = yield* HttpClient.HttpClient;
662 const fileSystem = yield* FileSystem.FileSystem;
663 const git = yield* GitVcsDriver.GitVcsDriver;
664 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
665
666 const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`;
667
668 // Read on every request so credentials saved in settings apply without a restart.
669 const currentCredential = serverSettings.getSettings.pipe(
670 Effect.map((settings) => resolveCredential(settings.bitbucket, config)),
671 Effect.catch((error) =>
672 // No cause: a settings decode error can quote a hand-edited token.
673 Effect.logWarning("failed to read Bitbucket credentials from settings", {
674 operation: error.operation,
675 }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))),
676 ),
677 );
678
679 const withAuth = (request: HttpClientRequest.HttpClientRequest) =>
680 currentCredential.pipe(
681 Effect.map((credential) =>
682 credential === null
683 ? request
684 : credential.kind === "access-token"
685 ? request.pipe(HttpClientRequest.bearerToken(credential.accessToken))
686 : request.pipe(HttpClientRequest.basicAuth(credential.email, credential.apiToken)),
687 ),
688 );
689
690 const decodeResponse = <S extends Schema.Top>(
691 operation: BitbucketApiOperation,
692 schema: S,
693 response: HttpClientResponse.HttpClientResponse,
694 ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> =>
695 HttpClientResponse.matchStatus({
696 "2xx": (success) =>
697 HttpClientResponse.schemaBodyJson(schema)(success).pipe(
698 Effect.mapError(
699 (cause) =>
700 new BitbucketResponseDecodeError({
701 operation,
702 status: success.status,
703 cause,
704 }),
705 ),
706 ),
707 orElse: (failed) => responseError(operation, failed),
708 })(response);
709
710 const executeJson = <S extends Schema.Top>(
711 operation: BitbucketApiOperation,
712 request: HttpClientRequest.HttpClientRequest,
713 schema: S,
714 ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> =>
715 withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe(
716 Effect.flatMap(httpClient.execute),
717 Effect.mapError(
718 (cause) =>
719 new BitbucketRequestError({
720 operation,
721 cause,
722 }),
723 ),
724 Effect.flatMap((response) => decodeResponse(operation, schema, response)),
725 );
726
727 const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: {
728 readonly cwd: string;
729 readonly context?: SourceControlProvider.SourceControlProviderContext;
730 readonly repository?: string;
731 }) {
732 if (input.repository !== undefined) {
733 return yield* requireRepositoryLocator(input.repository);
734 }
735
736 const fromContext =
737 input.context?.provider.kind === "bitbucket"
738 ? parseBitbucketRemoteUrl(input.context.remoteUrl)
739 : null;
740 if (fromContext) return fromContext;
741
742 const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe(
743 Effect.mapError(
744 (cause) =>
745 new BitbucketRepositoryVcsResolveError({
746 cwd: input.cwd,
747 cause,
748 }),
749 ),
750 );
751 const remotes = yield* handle.driver.listRemotes(input.cwd).pipe(
752 Effect.mapError(
753 (cause) =>
754 new BitbucketRepositoryRemotesListError({
755 cwd: input.cwd,
756 cause,
757 }),
758 ),
759 );
760
761 for (const remote of remotes.remotes) {
762 if (detectSourceControlProviderFromRemoteUrl(remote.url)?.kind !== "bitbucket") continue;
763 const parsed = parseBitbucketRemoteUrl(remote.url);
764 if (parsed) return parsed;
765 }
766
767 return yield* new BitbucketRepositoryRemoteNotFoundError({
768 cwd: input.cwd,
769 });
770 });
771
772 const getRepositoryFromLocator = (repository: BitbucketRepositoryLocator) =>
773 executeJson(
774 "getRepository",
775 HttpClientRequest.get(
776 apiUrl(
777 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`,
778 ),
779 ),
780 RawBitbucketRepositorySchema,
781 );
782
783 const getRepository = (input: {
784 readonly cwd: string;
785 readonly context?: SourceControlProvider.SourceControlProviderContext;
786 readonly repository?: string;
787 }) => resolveRepository(input).pipe(Effect.flatMap(getRepositoryFromLocator));
788
789 const getBranchingModelFromLocator = (repository: BitbucketRepositoryLocator) =>
790 executeJson(
791 "getBranchingModel",
792 HttpClientRequest.get(
793 apiUrl(
794 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/branching-model`,
795 ),
796 ),
797 RawBitbucketBranchingModelSchema,
798 );
799
800 const getRawPullRequestFromRepository = (
801 repository: BitbucketRepositoryLocator,
802 reference: string,
803 ) =>
804 executeJson(
805 "getPullRequest",
806 HttpClientRequest.get(
807 apiUrl(
808 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`,
809 ),
810 ),
811 BitbucketPullRequestSchema,
812 );
813
814 const getRawPullRequest = (input: {
815 readonly cwd: string;
816 readonly context?: SourceControlProvider.SourceControlProviderContext;
817 readonly reference: string;
818 }) =>
819 resolveRepository(input).pipe(
820 Effect.flatMap((repository) => getRawPullRequestFromRepository(repository, input.reference)),
821 );
822
823 const readConfigValueNullable = (cwd: string, key: string) =>
824 git.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null));
825
826 const resolveCheckoutRemote = Effect.fn("BitbucketApi.resolveCheckoutRemote")(function* (input: {
827 readonly cwd: string;
828 readonly context?: SourceControlProvider.SourceControlProviderContext;
829 readonly destinationRepository: BitbucketRepositoryLocator;
830 readonly sourceRepositoryName: string;
831 readonly isCrossRepository: boolean;
832 }) {
833 if (
834 input.context?.provider.kind === "bitbucket" &&
835 !input.isCrossRepository &&
836 parseBitbucketRemoteUrl(input.context.remoteUrl) !== null
837 ) {
838 return input.context.remoteName;
839 }
840
841 if (!input.isCrossRepository) {
842 const remoteName = yield* git
843 .resolvePrimaryRemoteName(input.cwd)
844 .pipe(Effect.orElseSucceed(() => null));
845 if (remoteName) return remoteName;
846 }
847
848 const cloneUrls = yield* getRepository({
849 cwd: input.cwd,
850 repository: input.sourceRepositoryName,
851 ...(input.context ? { context: input.context } : {}),
852 }).pipe(Effect.map(normalizeRepositoryCloneUrls));
853 const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url");
854 return yield* git.ensureRemote({
855 cwd: input.cwd,
856 preferredName: input.isCrossRepository
857 ? repositoryOwnerName(input.sourceRepositoryName)
858 : input.destinationRepository.workspace,
859 url: selectCloneUrl({ cloneUrls, originRemoteUrl }),
860 });
861 });
862
863 // A pull request's diff, diffstat and conflicts are served as redirects to a commit-range
864 // URL, and the client does not follow redirects unless asked. The hop stays on the same host,
865 // so the credentials travel with it.
866 /**
867 * The one host these credentials may be sent to. A url that came back inside a response — a
868 * pagination cursor, or the target of a redirect — is data, not instruction, so it is checked
869 * against this before the account's token travels with it.
870 */
871 const apiOrigin = originOf(config.baseUrl);
872
873 const trustedUrl = (value: string): string | null => {
874 if (!/^https?:\/\//u.test(value)) return apiUrl(value);
875 const origin = originOf(value);
876 return origin !== null && origin === apiOrigin ? value : null;
877 };
878
879 /**
880 * Redirects are followed here rather than by the client, which forwards every header to
881 * whatever host it is sent to. A pull request diff, diffstat and conflicts are all served as
882 * redirects, so they have to be followed — but only back to the same Bitbucket.
883 */
884 const send = (input: {
885 readonly method: "GET" | "POST" | "PUT" | "DELETE";
886 readonly url: string;
887 readonly body?: string;
888 readonly redirects: number;
889 }): Effect.Effect<HttpClientResponse.HttpClientResponse, BitbucketApiError> => {
890 const url = trustedUrl(input.url);
891 if (url === null) {
892 return Effect.fail(
893 new BitbucketUntrustedUrlError({ host: originOf(input.url) ?? "an unreadable url" }),
894 );
895 }
896 const base =
897 input.method === "GET"
898 ? HttpClientRequest.get(url)
899 : input.method === "POST"
900 ? HttpClientRequest.post(url)
901 : input.method === "DELETE"
902 ? HttpClientRequest.make("DELETE")(url)
903 : HttpClientRequest.put(url);
904 // No `Accept: application/json`: the diff endpoints answer with a patch, not JSON.
905 const withBody =
906 input.body === undefined
907 ? base
908 : base.pipe(HttpClientRequest.bodyText(input.body, "application/json"));
909 return withAuth(withBody).pipe(
910 Effect.flatMap(httpClient.execute),
911 Effect.mapError(
912 (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }),
913 ),
914 Effect.flatMap((response) => {
915 const location = response.headers.location;
916 if (
917 response.status >= 300 &&
918 response.status < 400 &&
919 location !== undefined &&
920 input.redirects < MAX_REDIRECTS
921 ) {
922 return send({
923 ...input,
924 url: new URL(location, url).toString(),
925 redirects: input.redirects + 1,
926 });
927 }
928 return Effect.succeed(response);
929 }),
930 );
931 };
932
933 const request: BitbucketApi["Service"]["request"] = (input) =>
934 send({ ...input, redirects: 0 }).pipe(
935 Effect.flatMap((response) =>
936 HttpClientResponse.matchStatus({
937 // Read through the body stream rather than `text`, so an oversized diff is stopped
938 // as it arrives instead of being materialized whole and then cut. The same collector
939 // the process runner bounds command output with.
940 "2xx": (success) =>
941 collectUint8StreamText({
942 stream: success.stream,
943 maxBytes: input.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
944 }).pipe(
945 Effect.mapError(
946 (cause) =>
947 new BitbucketResponseBodyReadError({
948 operation: "request",
949 status: success.status,
950 cause,
951 }),
952 ),
953 Effect.map((collected) => ({
954 body: collected.text,
955 truncated: collected.truncated,
956 })),
957 ),
958 orElse: (failed) => responseError("request", failed),
959 })(response),
960 ),
961 );
962
963 return BitbucketApi.of({
964 request,
965 probeAuth: executeJson(
966 "probeAuth",
967 HttpClientRequest.get(apiUrl("/user")),
968 BitbucketUserSchema,
969 ).pipe(
970 Effect.map((user) => ({
971 status: "authenticated" as const,
972 account: nonEmpty(user.username ?? user.display_name ?? user.account_id),
973 host: Option.some("bitbucket.org"),
974 detail: Option.none<string>(),
975 })),
976 Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))),
977 ),
978 listPullRequests: (input) =>
979 resolveRepository(input).pipe(
980 Effect.flatMap((repository) => {
981 const states = toBitbucketStates(input.state);
982 const query: Record<string, string | ReadonlyArray<string>> = {
983 pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))),
984 sort: "-updated_on",
985 q: bitbucketQueryString([
986 `source.branch.name = "${SourceControlProvider.sourceBranch(input).replaceAll('"', '\\"')}"`,
987 bitbucketStateFilter(states),
988 ]),
989 state: states,
990 };
991
992 return executeJson(
993 "listPullRequests",
994 HttpClientRequest.get(
995 apiUrl(
996 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
997 ),
998 { urlParams: query },
999 ),
1000 BitbucketPullRequestListSchema,
1001 );
1002 }),
1003 Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)),
1004 ),
1005 getPullRequest: (input) =>
1006 getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)),
1007 getRepositoryCloneUrls: (input) =>
1008 getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)),
1009 createRepository: (input) =>
1010 requireRepositoryLocator(input.repository).pipe(
1011 Effect.flatMap((repository) =>
1012 executeJson(
1013 "createRepository",
1014 HttpClientRequest.post(
1015 apiUrl(
1016 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`,
1017 ),
1018 ).pipe(
1019 HttpClientRequest.bodyJsonUnsafe({
1020 scm: "git",
1021 is_private: input.visibility === "private",
1022 }),
1023 ),
1024 RawBitbucketRepositorySchema,
1025 ),
1026 ),
1027 Effect.map(normalizeRepositoryCloneUrls),
1028 ),
1029 createPullRequest: (input) =>
1030 Effect.gen(function* () {
1031 const repository = yield* resolveRepository(input);
1032 const description = yield* fileSystem.readFileString(input.bodyFile).pipe(
1033 Effect.mapError(
1034 (cause) =>
1035 new BitbucketPullRequestBodyReadError({
1036 cwd: input.cwd,
1037 bodyFile: input.bodyFile,
1038 cause,
1039 }),
1040 ),
1041 );
1042 const sourceOwner = sourceWorkspace(input);
1043 const body = {
1044 title: input.title,
1045 description,
1046 source: {
1047 branch: {
1048 name: SourceControlProvider.sourceBranch(input),
1049 },
1050 ...(sourceOwner
1051 ? {
1052 repository: {
1053 full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`,
1054 },
1055 }
1056 : {}),
1057 },
1058 destination: {
1059 branch: {
1060 name: input.target?.refName ?? input.baseBranch,
1061 },
1062 },
1063 };
1064
1065 yield* executeJson(
1066 "createPullRequest",
1067 HttpClientRequest.post(
1068 apiUrl(
1069 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
1070 ),
1071 ).pipe(HttpClientRequest.bodyJsonUnsafe(body)),
1072 BitbucketPullRequestSchema,
1073 );
1074 }),
1075 getDefaultBranch: (input) =>
1076 resolveRepository(input).pipe(
1077 Effect.flatMap((locator) =>
1078 Effect.all(
1079 {
1080 repository: getRepositoryFromLocator(locator),
1081 branchingModel: getBranchingModelFromLocator(locator).pipe(
1082 Effect.orElseSucceed(
1083 (): typeof RawBitbucketBranchingModelSchema.Type | null => null,
1084 ),
1085 ),
1086 },
1087 { concurrency: "unbounded" },
1088 ),
1089 ),
1090 Effect.map(defaultChangeRequestTargetBranch),
1091 ),
1092 // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide
1093 // an official checkout CLI. This provider-local path uses GitVcsDriver as a
1094 // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this
1095 // as the source-control provider model: if we support non-Git-compatible
1096 // hosting providers or native JJ/Sapling checkout flows, move this into a
1097 // VCS-specific change-request checkout capability.
1098 checkoutPullRequest: (input) =>
1099 Effect.gen(function* () {
1100 const destinationRepository = yield* resolveRepository(input);
1101 const pullRequest = yield* getRawPullRequestFromRepository(
1102 destinationRepository,
1103 input.reference,
1104 );
1105 const destinationRepositoryName =
1106 repositoryNameWithOwner(pullRequest.destination.repository) ??
1107 `${destinationRepository.workspace}/${destinationRepository.repoSlug}`;
1108 const sourceRepositoryName =
1109 repositoryNameWithOwner(pullRequest.source.repository) ?? destinationRepositoryName;
1110 const isCrossRepository = sourceRepositoryName !== destinationRepositoryName;
1111 const remoteName = yield* resolveCheckoutRemote({
1112 cwd: input.cwd,
1113 destinationRepository,
1114 sourceRepositoryName,
1115 isCrossRepository,
1116 ...(input.context ? { context: input.context } : {}),
1117 });
1118 const remoteBranch = pullRequest.source.branch.name;
1119 const localBranch = checkoutBranchName({
1120 pullRequestId: pullRequest.id,
1121 headBranch: remoteBranch,
1122 isCrossRepository,
1123 });
1124 const localBranchNames = yield* git.listLocalBranchNames(input.cwd);
1125 const localBranchExists = localBranchNames.includes(localBranch);
1126
1127 if (input.force === true || !localBranchExists) {
1128 yield* git.fetchRemoteBranch({
1129 cwd: input.cwd,
1130 remoteName,
1131 remoteBranch,
1132 localBranch,
1133 });
1134 } else {
1135 yield* git.fetchRemoteTrackingBranch({
1136 cwd: input.cwd,
1137 remoteName,
1138 remoteBranch,
1139 });
1140 }
1141
1142 yield* git.setBranchUpstream({
1143 cwd: input.cwd,
1144 branch: localBranch,
1145 remoteName,
1146 remoteBranch,
1147 });
1148 yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch }));
1149 }).pipe(
1150 Effect.mapError((cause) =>
1151 isBitbucketApiError(cause)
1152 ? cause
1153 : new BitbucketCheckoutError({
1154 cwd: input.cwd,
1155 reference: input.reference,
1156 cause,
1157 }),
1158 ),
1159 ),
1160 });
1161});
1162
1163export const layer = Layer.effect(BitbucketApi, make);
apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts deleted-151
...@@ -1,151 +0,0 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5
6import * as BitbucketApi from "./BitbucketApi.ts";
7import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
8
9function makeProvider(bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>) {
10 return BitbucketSourceControlProvider.make.pipe(
11 Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)),
12 );
13}
14
15it.effect("maps Bitbucket PR summaries into provider-neutral change requests", () =>
16 Effect.gen(function* () {
17 const provider = yield* makeProvider({
18 getPullRequest: () =>
19 Effect.succeed({
20 number: 42,
21 title: "Add Bitbucket provider",
22 url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
23 baseRefName: "main",
24 headRefName: "feature/source-control",
25 state: "open",
26 updatedAt: Option.none(),
27 isCrossRepository: true,
28 headRepositoryNameWithOwner: "fork/t3code",
29 headRepositoryOwnerLogin: "fork",
30 }),
31 });
32
33 const changeRequest = yield* provider.getChangeRequest({
34 cwd: "/repo",
35 reference: "42",
36 });
37
38 assert.deepStrictEqual(changeRequest, {
39 provider: "bitbucket",
40 number: 42,
41 title: "Add Bitbucket provider",
42 url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
43 baseRefName: "main",
44 headRefName: "feature/source-control",
45 state: "open",
46 updatedAt: Option.none(),
47 isCrossRepository: true,
48 headRepositoryNameWithOwner: "fork/t3code",
49 headRepositoryOwnerLogin: "fork",
50 });
51 }),
52);
53
54it.effect("adds repository context while retaining Bitbucket API causes", () =>
55 Effect.gen(function* () {
56 const upstreamCause = new Error("raw upstream failure");
57 const cause = new BitbucketApi.BitbucketRequestError({
58 operation: "getRepository",
59 cause: upstreamCause,
60 });
61 const provider = yield* makeProvider({
62 getRepositoryCloneUrls: () => Effect.fail(cause),
63 });
64
65 const error = yield* provider
66 .getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" })
67 .pipe(Effect.flip);
68
69 assert.deepStrictEqual(
70 {
71 provider: error.provider,
72 operation: error.operation,
73 command: error.command,
74 cwd: error.cwd,
75 repository: error.repository,
76 detail: error.detail,
77 },
78 {
79 provider: "bitbucket",
80 operation: "getRepositoryCloneUrls",
81 command: undefined,
82 cwd: "/repo",
83 repository: "owner/repo",
84 detail: "Failed to get repository clone URLs.",
85 },
86 );
87 assert.strictEqual(error.cause, cause);
88 assert.equal(error.message.includes(upstreamCause.message), false);
89 }),
90);
91
92it.effect("lists Bitbucket PRs through provider-neutral input names", () =>
93 Effect.gen(function* () {
94 let listInput: Parameters<BitbucketApi.BitbucketApi["Service"]["listPullRequests"]>[0] | null =
95 null;
96 const provider = yield* makeProvider({
97 listPullRequests: (input) => {
98 listInput = input;
99 return Effect.succeed([]);
100 },
101 });
102
103 yield* provider.listChangeRequests({
104 cwd: "/repo",
105 headSelector: "feature/provider",
106 state: "all",
107 limit: 10,
108 });
109
110 assert.deepStrictEqual(listInput, {
111 cwd: "/repo",
112 headSelector: "feature/provider",
113 state: "all",
114 limit: 10,
115 });
116 }),
117);
118
119it.effect("creates Bitbucket PRs through provider-neutral input names", () =>
120 Effect.gen(function* () {
121 let createInput:
122 | Parameters<BitbucketApi.BitbucketApi["Service"]["createPullRequest"]>[0]
123 | null = null;
124 const provider = yield* makeProvider({
125 createPullRequest: (input) => {
126 createInput = input;
127 return Effect.void;
128 },
129 });
130
131 yield* provider.createChangeRequest({
132 cwd: "/repo",
133 baseRefName: "main",
134 headSelector: "owner:feature/provider",
135 title: "Provider PR",
136 bodyFile: "/tmp/body.md",
137 });
138
139 assert.deepStrictEqual(createInput, {
140 cwd: "/repo",
141 baseBranch: "main",
142 headSelector: "owner:feature/provider",
143 source: {
144 owner: "owner",
145 refName: "feature/provider",
146 },
147 title: "Provider PR",
148 bodyFile: "/tmp/body.md",
149 });
150 }),
151);
apps/server/src/sourceControl/BitbucketSourceControlProvider.ts deleted-198
...@@ -1,198 +0,0 @@
1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
4
5import * as BitbucketApi from "./BitbucketApi.ts";
6import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts";
7import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
8import type { SourceControlApiDiscoverySpec } from "@t3tools/source-control-core/server/discovery";
9
10function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest {
11 return {
12 provider: "bitbucket",
13 number: summary.number,
14 title: summary.title,
15 url: summary.url,
16 baseRefName: summary.baseRefName,
17 headRefName: summary.headRefName,
18 state: summary.state,
19 ...(summary.isDraft === true ? { isDraft: true } : {}),
20 updatedAt: summary.updatedAt ?? Option.none(),
21 ...(summary.isCrossRepository !== undefined
22 ? { isCrossRepository: summary.isCrossRepository }
23 : {}),
24 ...(summary.headRepositoryNameWithOwner !== undefined
25 ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
26 : {}),
27 ...(summary.headRepositoryOwnerLogin !== undefined
28 ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
29 : {}),
30 };
31}
32
33export const make = Effect.gen(function* () {
34 const bitbucket = yield* BitbucketApi.BitbucketApi;
35
36 return SourceControlProvider.SourceControlProvider.of({
37 kind: "bitbucket",
38 listChangeRequests: (input) => {
39 const source = SourceControlProvider.sourceControlRefFromInput(input);
40 return bitbucket
41 .listPullRequests({
42 cwd: input.cwd,
43 ...(input.context ? { context: input.context } : {}),
44 headSelector: input.headSelector,
45 ...(source ? { source } : {}),
46 state: input.state,
47 ...(input.limit !== undefined ? { limit: input.limit } : {}),
48 })
49 .pipe(
50 Effect.map((items) => items.map(toChangeRequest)),
51 Effect.mapError(
52 (error) =>
53 new SourceControlProviderError({
54 provider: "bitbucket",
55 operation: "listChangeRequests",
56 cwd: input.cwd,
57 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
58 input.headSelector,
59 ),
60 detail: "Failed to list change requests.",
61 cause: error,
62 }),
63 ),
64 );
65 },
66 getChangeRequest: (input) =>
67 bitbucket.getPullRequest(input).pipe(
68 Effect.map(toChangeRequest),
69 Effect.mapError(
70 (error) =>
71 new SourceControlProviderError({
72 provider: "bitbucket",
73 operation: "getChangeRequest",
74 cwd: input.cwd,
75 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
76 input.reference,
77 ),
78 detail: "Failed to get change request.",
79 cause: error,
80 }),
81 ),
82 ),
83 createChangeRequest: (input) => {
84 const source = SourceControlProvider.sourceControlRefFromInput(input);
85 return bitbucket
86 .createPullRequest({
87 cwd: input.cwd,
88 ...(input.context ? { context: input.context } : {}),
89 baseBranch: input.baseRefName,
90 headSelector: input.headSelector,
91 ...(source ? { source } : {}),
92 ...(input.target ? { target: input.target } : {}),
93 title: input.title,
94 bodyFile: input.bodyFile,
95 })
96 .pipe(
97 Effect.mapError(
98 (error) =>
99 new SourceControlProviderError({
100 provider: "bitbucket",
101 operation: "createChangeRequest",
102 cwd: input.cwd,
103 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
104 input.headSelector,
105 ),
106 detail: "Failed to create change request.",
107 cause: error,
108 }),
109 ),
110 );
111 },
112 getRepositoryCloneUrls: (input) =>
113 bitbucket.getRepositoryCloneUrls(input).pipe(
114 Effect.mapError(
115 (error) =>
116 new SourceControlProviderError({
117 provider: "bitbucket",
118 operation: "getRepositoryCloneUrls",
119 cwd: input.cwd,
120 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
121 input.repository,
122 ),
123 detail: "Failed to get repository clone URLs.",
124 cause: error,
125 }),
126 ),
127 ),
128 createRepository: (input) =>
129 bitbucket.createRepository(input).pipe(
130 Effect.mapError(
131 (error) =>
132 new SourceControlProviderError({
133 provider: "bitbucket",
134 operation: "createRepository",
135 cwd: input.cwd,
136 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
137 input.repository,
138 ),
139 detail: "Failed to create repository.",
140 cause: error,
141 }),
142 ),
143 ),
144 getDefaultBranch: (input) =>
145 bitbucket
146 .getDefaultBranch({
147 cwd: input.cwd,
148 ...(input.context ? { context: input.context } : {}),
149 })
150 .pipe(
151 Effect.mapError(
152 (error) =>
153 new SourceControlProviderError({
154 provider: "bitbucket",
155 operation: "getDefaultBranch",
156 cwd: input.cwd,
157 detail: "Failed to get default branch.",
158 cause: error,
159 }),
160 ),
161 ),
162 checkoutChangeRequest: (input) =>
163 bitbucket
164 .checkoutPullRequest({
165 cwd: input.cwd,
166 ...(input.context ? { context: input.context } : {}),
167 reference: input.reference,
168 ...(input.force !== undefined ? { force: input.force } : {}),
169 })
170 .pipe(
171 Effect.mapError(
172 (error) =>
173 new SourceControlProviderError({
174 provider: "bitbucket",
175 operation: "checkoutChangeRequest",
176 cwd: input.cwd,
177 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
178 input.reference,
179 ),
180 detail: "Failed to check out change request.",
181 cause: error,
182 }),
183 ),
184 ),
185 });
186});
187
188export const makeDiscovery = Effect.gen(function* () {
189 const bitbucket = yield* BitbucketApi.BitbucketApi;
190
191 return {
192 type: "api",
193 kind: "bitbucket",
194 label: "Bitbucket",
195 installHint: "Add a Bitbucket token in Settings → Source Control.",
196 probeAuth: bitbucket.probeAuth,
197 } satisfies SourceControlApiDiscoverySpec;
198});
apps/server/src/sourceControl/ServerSourceControlHost.ts+16
...@@ -9,6 +9,8 @@ import * as Effect from "effect/Effect";...@@ -9,6 +9,8 @@ import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
1010
11import * as ServerSettings from "../serverSettings.ts";11import * as ServerSettings from "../serverSettings.ts";
12import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
13import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
12import * as VcsProcess from "../vcs/VcsProcess.ts";14import * as VcsProcess from "../vcs/VcsProcess.ts";
1315
14export const layer = Layer.effect(16export const layer = Layer.effect(
...@@ -16,9 +18,23 @@ export const layer = Layer.effect(...@@ -16,9 +18,23 @@ export const layer = Layer.effect(
16 Effect.gen(function* () {18 Effect.gen(function* () {
17 const serverSettings = yield* ServerSettings.ServerSettingsService;19 const serverSettings = yield* ServerSettings.ServerSettingsService;
18 const process = yield* VcsProcess.VcsProcess;20 const process = yield* VcsProcess.VcsProcess;
21 const git = yield* GitVcsDriver.GitVcsDriver;
22 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
19 return SourceControlHost.SourceControlHost.of({23 return SourceControlHost.SourceControlHost.of({
20 settings: { get: serverSettings.getSettings },24 settings: { get: serverSettings.getSettings },
21 process: { run: process.run },25 process: { run: process.run },
26 git: {
27 remotes: (cwd) =>
28 vcsRegistry.resolve({ cwd }).pipe(Effect.map((handle) => handle.driver.listRemotes(cwd))),
29 readConfigValue: git.readConfigValue,
30 resolvePrimaryRemoteName: git.resolvePrimaryRemoteName,
31 ensureRemote: git.ensureRemote,
32 listLocalBranchNames: git.listLocalBranchNames,
33 fetchRemoteBranch: git.fetchRemoteBranch,
34 fetchRemoteTrackingBranch: git.fetchRemoteTrackingBranch,
35 setBranchUpstream: git.setBranchUpstream,
36 switchRef: git.switchRef,
37 },
22 });38 });
23 }),39 }),
24);40);
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+9-2
...@@ -18,7 +18,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";...@@ -18,7 +18,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
18import * as VcsProcess from "../vcs/VcsProcess.ts";18import * as VcsProcess from "../vcs/VcsProcess.ts";
19import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";19import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";
20import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli";20import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli";
21import * as BitbucketApi from "./BitbucketApi.ts";21import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
22import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi";
22import * as GitHubApi from "./GitHubApi.ts";23import * as GitHubApi from "./GitHubApi.ts";
23import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";24import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
24import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";25import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
...@@ -43,6 +44,7 @@ const layerSourceControlProviderRegistryTest = (input: {...@@ -43,6 +44,7 @@ const layerSourceControlProviderRegistryTest = (input: {
43 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),44 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
44 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}),45 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}),
45 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),46 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
47 Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}),
46 ServerSettings.ServerSettingsService.layerTest(),48 ServerSettings.ServerSettingsService.layerTest(),
47 Layer.mock(GitHubApi.GitHubApi)({}),49 Layer.mock(GitHubApi.GitHubApi)({}),
48 Layer.mock(GitVcsDriver.GitVcsDriver)({}),50 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
...@@ -53,9 +55,11 @@ const layerSourceControlProviderRegistryTest = (input: {...@@ -53,9 +55,11 @@ const layerSourceControlProviderRegistryTest = (input: {
53 Layer.mock(VcsProcess.VcsProcess)(input.process),55 Layer.mock(VcsProcess.VcsProcess)(input.process),
54 ServerSourceControlHost.layer.pipe(56 ServerSourceControlHost.layer.pipe(
55 Layer.provide(57 Layer.provide(
56 Layer.merge(58 Layer.mergeAll(
57 Layer.mock(VcsProcess.VcsProcess)(input.process),59 Layer.mock(VcsProcess.VcsProcess)(input.process),
58 ServerSettings.ServerSettingsService.layerTest(),60 ServerSettings.ServerSettingsService.layerTest(),
61 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
62 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
59 ),63 ),
60 ),64 ),
61 ),65 ),
...@@ -395,6 +399,7 @@ it.effect(...@@ -395,6 +399,7 @@ it.effect(
395 SourceControlHost.SourceControlHost,399 SourceControlHost.SourceControlHost,
396 SourceControlHost.SourceControlHost.of({400 SourceControlHost.SourceControlHost.of({
397 settings: host.settings,401 settings: host.settings,
402 git: host.git,
398 process: {403 process: {
399 run: (input) => {404 run: (input) => {
400 if (input.args[0] !== "fetch") return git.run(input);405 if (input.args[0] !== "fetch") return git.run(input);
...@@ -529,6 +534,8 @@ it.effect(...@@ -529,6 +534,8 @@ it.effect(
529 ServerSourceControlHost.layer.pipe(534 ServerSourceControlHost.layer.pipe(
530 Layer.provideMerge(VcsProcess.layer),535 Layer.provideMerge(VcsProcess.layer),
531 Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()),536 Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()),
537 Layer.provideMerge(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
538 Layer.provideMerge(Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({})),
532 Layer.provideMerge(NodeServices.layer),539 Layer.provideMerge(NodeServices.layer),
533 ),540 ),
534 ),541 ),
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+9-2
...@@ -15,7 +15,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";...@@ -15,7 +15,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
15import * as VcsProcess from "../vcs/VcsProcess.ts";15import * as VcsProcess from "../vcs/VcsProcess.ts";
16import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";16import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";
17import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli";17import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli";
18import * as BitbucketApi from "./BitbucketApi.ts";18import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
19import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi";
19import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";20import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
20import * as GitHubApi from "./GitHubApi.ts";21import * as GitHubApi from "./GitHubApi.ts";
21import * as GitHubCredentials from "./GitHubCredentials.ts";22import * as GitHubCredentials from "./GitHubCredentials.ts";
...@@ -101,12 +102,18 @@ function makeRegistry(input: {...@@ -101,12 +102,18 @@ function makeRegistry(input: {
101 layerProcess,102 layerProcess,
102 ServerSourceControlHost.layer.pipe(103 ServerSourceControlHost.layer.pipe(
103 Layer.provide(104 Layer.provide(
104 Layer.merge(layerProcess, ServerSettings.ServerSettingsService.layerTest()),105 Layer.mergeAll(
106 layerProcess,
107 layerRegistry,
108 ServerSettings.ServerSettingsService.layerTest(),
109 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
110 ),
105 ),111 ),
106 ),112 ),
107 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),113 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
108 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}),114 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}),
109 Layer.mock(BitbucketApi.BitbucketApi)({}),115 Layer.mock(BitbucketApi.BitbucketApi)({}),
116 Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}),
110 ServerSettings.ServerSettingsService.layerTest(),117 ServerSettings.ServerSettingsService.layerTest(),
111 Layer.mock(GitHubApi.GitHubApi)({118 Layer.mock(GitHubApi.GitHubApi)({
112 // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed.119 // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed.
apps/server/src/sourceControl/SourceControlProviderRegistry.ts-8
...@@ -11,7 +11,6 @@ import {...@@ -11,7 +11,6 @@ import {
11import type { SourceControlProviderKind } from "@t3tools/contracts";11import type { SourceControlProviderKind } from "@t3tools/contracts";
12import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";12import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
1313
14import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
15import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";14import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
16import * as BuiltInDrivers from "./builtInDrivers.ts";15import * as BuiltInDrivers from "./builtInDrivers.ts";
17import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";16import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
...@@ -305,8 +304,6 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit...@@ -305,8 +304,6 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
305export const make = Effect.gen(function* () {304export const make = Effect.gen(function* () {
306 const github = yield* GitHubSourceControlProvider.make;305 const github = yield* GitHubSourceControlProvider.make;
307 const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery;306 const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery;
308 const bitbucket = yield* BitbucketSourceControlProvider.make;
309 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery;
310 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>307 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>
311 driver.make.pipe(308 driver.make.pipe(
312 Effect.map((instance): SourceControlProviderRegistration => ({309 Effect.map((instance): SourceControlProviderRegistration => ({
...@@ -324,11 +321,6 @@ export const make = Effect.gen(function* () {...@@ -324,11 +321,6 @@ export const make = Effect.gen(function* () {
324 provider: github,321 provider: github,
325 discovery: githubDiscovery,322 discovery: githubDiscovery,
326 },323 },
327 {
328 kind: "bitbucket",
329 provider: bitbucket,
330 discovery: bitbucketDiscovery,
331 },
332 ...drivers,324 ...drivers,
333 ];325 ];
334 return yield* makeWithProviders(326 return yield* makeWithProviders(
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+2-2
...@@ -13,8 +13,8 @@ import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts"...@@ -13,8 +13,8 @@ import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts"
1313
14import * as ServerConfig from "../config.ts";14import * as ServerConfig from "../config.ts";
15import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";15import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
16import * as BitbucketApi from "./BitbucketApi.ts";16import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
17import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";17import * as BitbucketSourceControlProvider from "@t3tools/source-control-bitbucket/server/BitbucketSourceControlProvider";
18import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";18import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
19import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";19import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
20import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts";20import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts";
apps/server/src/sourceControl/SourceControlRepositoryService.ts+1-1
...@@ -27,7 +27,7 @@ import {...@@ -27,7 +27,7 @@ import {
27 type GitCloneProgressLine,27 type GitCloneProgressLine,
28} from "../project/gitCloneProgress.ts";28} from "../project/gitCloneProgress.ts";
29import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";29import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
30import * as BitbucketApi from "./BitbucketApi.ts";30import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
31import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";31import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
32const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError);32const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError);
33const isSourceControlProviderError = Schema.is(SourceControlProviderError);33const isSourceControlProviderError = Schema.is(SourceControlProviderError);
apps/server/src/sourceControl/bitbucketPullRequests.ts deleted-109
...@@ -1,109 +0,0 @@
1import * as DateTime from "effect/DateTime";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
5
6export interface NormalizedBitbucketPullRequestRecord {
7 readonly number: number;
8 readonly title: string;
9 readonly url: string;
10 readonly baseRefName: string;
11 readonly headRefName: string;
12 readonly state: "open" | "closed" | "merged";
13 readonly isDraft?: boolean;
14 readonly updatedAt: Option.Option<DateTime.Utc>;
15 readonly isCrossRepository?: boolean;
16 readonly headRepositoryNameWithOwner?: string | null;
17 readonly headRepositoryOwnerLogin?: string | null;
18}
19
20export const BitbucketRepositoryRefSchema = Schema.Struct({
21 full_name: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
22 workspace: Schema.optional(
23 Schema.NullOr(
24 Schema.Struct({
25 slug: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
26 }),
27 ),
28 ),
29});
30
31export const BitbucketPullRequestBranchSchema = Schema.Struct({
32 repository: Schema.optional(Schema.NullOr(BitbucketRepositoryRefSchema)),
33 branch: Schema.Struct({
34 name: TrimmedNonEmptyString,
35 }),
36});
37
38export const BitbucketPullRequestSchema = Schema.Struct({
39 id: PositiveInt,
40 title: TrimmedNonEmptyString,
41 state: Schema.optional(Schema.NullOr(Schema.String)),
42 draft: Schema.optional(Schema.Boolean),
43 updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
44 links: Schema.Struct({
45 html: Schema.Struct({
46 href: TrimmedNonEmptyString,
47 }),
48 }),
49 source: BitbucketPullRequestBranchSchema,
50 destination: BitbucketPullRequestBranchSchema,
51});
52
53export const BitbucketPullRequestListSchema = Schema.Struct({
54 values: Schema.Array(BitbucketPullRequestSchema),
55 next: Schema.optional(TrimmedNonEmptyString),
56});
57
58function trimOptionalString(value: string | null | undefined): string | null {
59 const trimmed = value?.trim() ?? "";
60 return trimmed.length > 0 ? trimmed : null;
61}
62
63function repositoryOwner(repository: Schema.Schema.Type<typeof BitbucketRepositoryRefSchema>) {
64 return (
65 trimOptionalString(repository.workspace?.slug) ??
66 (repository.full_name?.includes("/") ? (repository.full_name.split("/")[0] ?? null) : null)
67 );
68}
69
70function normalizeBitbucketPullRequestState(state: string | null | undefined) {
71 switch (state?.trim().toUpperCase()) {
72 case "MERGED":
73 return "merged" as const;
74 case "DECLINED":
75 case "SUPERSEDED":
76 return "closed" as const;
77 case "OPEN":
78 default:
79 return "open" as const;
80 }
81}
82
83export function normalizeBitbucketPullRequestRecord(
84 raw: Schema.Schema.Type<typeof BitbucketPullRequestSchema>,
85): NormalizedBitbucketPullRequestRecord {
86 const headRepositoryNameWithOwner = trimOptionalString(raw.source.repository?.full_name);
87 const baseRepositoryNameWithOwner = trimOptionalString(raw.destination.repository?.full_name);
88 const headRepositoryOwnerLogin = raw.source.repository
89 ? repositoryOwner(raw.source.repository)
90 : null;
91 const isCrossRepository =
92 headRepositoryNameWithOwner !== null &&
93 baseRepositoryNameWithOwner !== null &&
94 headRepositoryNameWithOwner !== baseRepositoryNameWithOwner;
95
96 return {
97 number: raw.id,
98 title: raw.title,
99 url: raw.links.html.href,
100 baseRefName: raw.destination.branch.name,
101 headRefName: raw.source.branch.name,
102 state: normalizeBitbucketPullRequestState(raw.state),
103 ...(raw.draft === true ? { isDraft: true } : {}),
104 updatedAt: raw.updated_on ?? Option.none(),
105 ...(isCrossRepository ? { isCrossRepository: true } : {}),
106 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
107 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
108 };
109}
apps/server/src/sourceControl/builtInDrivers.ts+10-1
...@@ -10,6 +10,9 @@...@@ -10,6 +10,9 @@
10import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";10import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli";
11import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli";11import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli";
12import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver";12import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver";
13import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
14import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi";
15import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver";
13import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";16import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
14import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver";17import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver";
15import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";18import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
...@@ -20,7 +23,12 @@ import * as Layer from "effect/Layer";...@@ -20,7 +23,12 @@ import * as Layer from "effect/Layer";
2023
21import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";24import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
2225
23const drivers = [GitLabDriver.driver, AzureDevOpsDriver.driver, ForgejoDriver.driver];26const drivers = [
27 GitLabDriver.driver,
28 AzureDevOpsDriver.driver,
29 BitbucketDriver.driver,
30 ForgejoDriver.driver,
31];
2432
25/** Every service a built-in driver's `make` needs; the server's layers must provide them all. */33/** Every service a built-in driver's `make` needs; the server's layers must provide them all. */
26export type BuiltInSourceControlDriversEnv =34export type BuiltInSourceControlDriversEnv =
...@@ -33,6 +41,7 @@ export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray<...@@ -33,6 +41,7 @@ export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray<
33/** The services the built-in drivers' packages own, plus the host port they all run against. */41/** The services the built-in drivers' packages own, plus the host port they all run against. */
34export const layer = Layer.mergeAll(42export const layer = Layer.mergeAll(
35 AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)),43 AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)),
44 BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)),
36 ForgejoCli.layer,45 ForgejoCli.layer,
37 GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)),46 GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)),
38).pipe(Layer.provideMerge(ServerSourceControlHost.layer));47).pipe(Layer.provideMerge(ServerSourceControlHost.layer));
apps/server/src/ws.ts-2
...@@ -229,7 +229,6 @@ import * as SqlClient from "effect/sql/SqlClient";...@@ -229,7 +229,6 @@ import * as SqlClient from "effect/sql/SqlClient";
229import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts";229import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts";
230import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";230import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";
231import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";231import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
232import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
233import * as GitHubApi from "./sourceControl/GitHubApi.ts";232import * as GitHubApi from "./sourceControl/GitHubApi.ts";
234import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";233import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
235import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";234import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
...@@ -3202,7 +3201,6 @@ export const layer = Layer.unwrap(...@@ -3202,7 +3201,6 @@ export const layer = Layer.unwrap(
3202 SourceControlProviderRegistry.layer.pipe(3201 SourceControlProviderRegistry.layer.pipe(
3203 Layer.provide(3202 Layer.provide(
3204 Layer.mergeAll(3203 Layer.mergeAll(
3205 BitbucketApi.layer,
3206 GitHubApi.layerWithDependencies,3204 GitHubApi.layerWithDependencies,
3207 SourceControlBuiltInDrivers.layer,3205 SourceControlBuiltInDrivers.layer,
3208 ),3206 ),
docs/internals/pull-request-file-revisions.md+1-1
...@@ -27,7 +27,7 @@ What "never got to look at" is belongs to the host, and the contract cannot know...@@ -27,7 +27,7 @@ What "never got to look at" is belongs to the host, and the contract cannot know
27- [Azure DevOps](../../packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts) reads every27- [Azure DevOps](../../packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts) reads every
28 version off one iteration listing, so a change too long to follow to its end leaves the paths28 version off one iteration listing, so a change too long to follow to its end leaves the paths
29 past that point out.29 past that point out.
30- [Bitbucket](../../apps/server/src/pullRequest/BitbucketPullRequestApi.ts) reads them off the30- [Bitbucket](../../packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts) reads them off the
31 pull request's own patch, the only place it states a file's version, so a patch cut short at the31 pull request's own patch, the only place it states a file's version, so a patch cut short at the
32 byte ceiling leaves the paths past the cut out.32 byte ceiling leaves the paths past the cut out.
33- [GitLab](../../packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts) asks in batches, so a batch33- [GitLab](../../packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts) asks in batches, so a batch
package.json+1-1
...@@ -28,7 +28,7 @@...@@ -28,7 +28,7 @@
28 "tc": "vp run -r --concurrency-limit 2 typecheck",28 "tc": "vp run -r --concurrency-limit 2 typecheck",
29 "lint": "vp lint --report-unused-disable-directives",29 "lint": "vp lint --report-unused-disable-directives",
30 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",30 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",
31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-forgejo --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
33 "lint:mobile": "node scripts/mobile-native-static-check.ts",33 "lint:mobile": "node scripts/mobile-native-static-check.ts",
34 "test": "vp run -r test",34 "test": "vp run -r test",
packages/source-control-bitbucket/package.json created+51
...@@ -0,0 +1,51 @@
1{
2 "name": "@t3tools/source-control-bitbucket",
3 "private": true,
4 "type": "module",
5 "exports": {
6 "./server/BitbucketApi": {
7 "types": "./src/server/BitbucketApi.ts",
8 "import": "./src/server/BitbucketApi.ts"
9 },
10 "./server/BitbucketPullRequestApi": {
11 "types": "./src/server/BitbucketPullRequestApi.ts",
12 "import": "./src/server/BitbucketPullRequestApi.ts"
13 },
14 "./server/bitbucketPullRequestJson": {
15 "types": "./src/server/bitbucketPullRequestJson.ts",
16 "import": "./src/server/bitbucketPullRequestJson.ts"
17 },
18 "./server/BitbucketPullRequestProvider": {
19 "types": "./src/server/BitbucketPullRequestProvider.ts",
20 "import": "./src/server/BitbucketPullRequestProvider.ts"
21 },
22 "./server/bitbucketPullRequests": {
23 "types": "./src/server/bitbucketPullRequests.ts",
24 "import": "./src/server/bitbucketPullRequests.ts"
25 },
26 "./server/BitbucketSourceControlProvider": {
27 "types": "./src/server/BitbucketSourceControlProvider.ts",
28 "import": "./src/server/BitbucketSourceControlProvider.ts"
29 },
30 "./server/driver": {
31 "types": "./src/server/driver.ts",
32 "import": "./src/server/driver.ts"
33 }
34 },
35 "scripts": {
36 "typecheck": "tsc --noEmit",
37 "test": "vp test run --config ../../vite.config.ts --dir ."
38 },
39 "dependencies": {
40 "@t3tools/contracts": "workspace:*",
41 "@t3tools/provider-core": "workspace:*",
42 "@t3tools/shared": "workspace:*",
43 "@t3tools/source-control-core": "workspace:*",
44 "effect": "catalog:"
45 },
46 "devDependencies": {
47 "@effect/vitest": "catalog:",
48 "@types/node": "catalog:",
49 "vite-plus": "catalog:"
50 }
51}
packages/source-control-bitbucket/src/server/BitbucketApi.ts created+1160
...@@ -0,0 +1,1160 @@
1import * as Clock from "effect/Clock";
2import * as Config from "effect/Config";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import {
10 DEFAULT_SERVER_SETTINGS,
11 NonNegativeInt,
12 TrimmedNonEmptyString,
13 type BitbucketSettings,
14 type SourceControlProviderAuth,
15 type SourceControlRepositoryCloneUrls,
16 type SourceControlRepositoryVisibility,
17} from "@t3tools/contracts";
18import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
19import { sanitizeBranchFragment } from "@t3tools/shared/git";
20import {
21 detectSourceControlProviderFromRemoteUrl,
22 isSshRemoteUrl,
23} from "@t3tools/shared/sourceControl";
24
25import {
26 BitbucketPullRequestListSchema,
27 BitbucketPullRequestSchema,
28 normalizeBitbucketPullRequestRecord,
29 type NormalizedBitbucketPullRequestRecord,
30} from "./bitbucketPullRequests.ts";
31import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
32import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
33import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
34import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit";
35
36const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0";
37/** A response body past this is cut short, so one huge diff cannot exhaust the server. */
38const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
39/** Bitbucket redirects a diff once; this leaves room without following a chain forever. */
40const MAX_REDIRECTS = 3;
41
42const BitbucketApiEnvConfig = Config.all({
43 baseUrl: Config.String("T3CODE_BITBUCKET_API_BASE_URL").pipe(
44 Config.withDefault(DEFAULT_API_BASE_URL),
45 ),
46 accessToken: Config.String("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option),
47 email: Config.String("T3CODE_BITBUCKET_EMAIL").pipe(Config.option),
48 apiToken: Config.String("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option),
49});
50
51const BitbucketApiOperation = Schema.Literals([
52 "resolveRepository",
53 "getRepository",
54 "getBranchingModel",
55 "getPullRequest",
56 "listPullRequests",
57 "createRepository",
58 "createPullRequest",
59 "probeAuth",
60 "checkoutPullRequest",
61 // The raw escape hatch. Callers name their own operation in their own error, the way the
62 // pull request wrappers do on top of `gh` and `glab`.
63 "request",
64]);
65type BitbucketApiOperation = typeof BitbucketApiOperation.Type;
66
67export class BitbucketRepositoryLocatorError extends Schema.TaggedError<BitbucketRepositoryLocatorError>()(
68 "BitbucketRepositoryLocatorError",
69 {
70 repository: Schema.String,
71 },
72) {
73 static readonly detail = "Bitbucket repositories must be specified as workspace/repository.";
74
75 get detail(): string {
76 return BitbucketRepositoryLocatorError.detail;
77 }
78
79 override get message(): string {
80 return `Bitbucket API failed: ${this.detail}`;
81 }
82}
83
84export class BitbucketRequestError extends Schema.TaggedError<BitbucketRequestError>()(
85 "BitbucketRequestError",
86 {
87 operation: BitbucketApiOperation,
88 cause: Schema.Defect(),
89 },
90) {
91 get detail(): string {
92 return "Failed to send the Bitbucket request.";
93 }
94
95 override get message(): string {
96 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
97 }
98}
99
100export class BitbucketResponseError extends Schema.TaggedError<BitbucketResponseError>()(
101 "BitbucketResponseError",
102 {
103 operation: BitbucketApiOperation,
104 status: Schema.Int,
105 responseBodyLength: NonNegativeInt,
106 retryAt: Schema.optional(Schema.Number),
107 },
108) {
109 get detail(): string {
110 return `Bitbucket returned HTTP ${this.status}.`;
111 }
112
113 override get message(): string {
114 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
115 }
116}
117
118export class BitbucketResponseBodyReadError extends Schema.TaggedError<BitbucketResponseBodyReadError>()(
119 "BitbucketResponseBodyReadError",
120 {
121 operation: BitbucketApiOperation,
122 status: Schema.Int,
123 retryAt: Schema.optional(Schema.Number),
124 cause: Schema.Defect(),
125 },
126) {
127 get detail(): string {
128 return `Bitbucket returned HTTP ${this.status}.`;
129 }
130
131 override get message(): string {
132 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
133 }
134}
135
136export class BitbucketResponseDecodeError extends Schema.TaggedError<BitbucketResponseDecodeError>()(
137 "BitbucketResponseDecodeError",
138 {
139 operation: BitbucketApiOperation,
140 status: Schema.Int,
141 cause: Schema.Defect(),
142 },
143) {
144 get detail(): string {
145 return "Bitbucket returned invalid JSON for the requested resource.";
146 }
147
148 override get message(): string {
149 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
150 }
151}
152
153export class BitbucketRepositoryVcsResolveError extends Schema.TaggedError<BitbucketRepositoryVcsResolveError>()(
154 "BitbucketRepositoryVcsResolveError",
155 {
156 cwd: Schema.String,
157 cause: Schema.Defect(),
158 },
159) {
160 get detail(): string {
161 return `Failed to resolve VCS repository for ${this.cwd}.`;
162 }
163
164 override get message(): string {
165 return `Bitbucket API failed in resolveRepository: ${this.detail}`;
166 }
167}
168
169export class BitbucketRepositoryRemotesListError extends Schema.TaggedError<BitbucketRepositoryRemotesListError>()(
170 "BitbucketRepositoryRemotesListError",
171 {
172 cwd: Schema.String,
173 cause: Schema.Defect(),
174 },
175) {
176 get detail(): string {
177 return `Failed to list remotes for ${this.cwd}.`;
178 }
179
180 override get message(): string {
181 return `Bitbucket API failed in resolveRepository: ${this.detail}`;
182 }
183}
184
185export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedError<BitbucketRepositoryRemoteNotFoundError>()(
186 "BitbucketRepositoryRemoteNotFoundError",
187 {
188 cwd: Schema.String,
189 },
190) {
191 get detail(): string {
192 return `No Bitbucket repository remote was detected for ${this.cwd}.`;
193 }
194
195 override get message(): string {
196 return `Bitbucket API failed in resolveRepository: ${this.detail}`;
197 }
198}
199
200export class BitbucketPullRequestBodyReadError extends Schema.TaggedError<BitbucketPullRequestBodyReadError>()(
201 "BitbucketPullRequestBodyReadError",
202 {
203 cwd: Schema.String,
204 bodyFile: Schema.String,
205 cause: Schema.Defect(),
206 },
207) {
208 get detail(): string {
209 return `Failed to read pull request body file ${this.bodyFile}.`;
210 }
211
212 override get message(): string {
213 return `Bitbucket API failed in createPullRequest: ${this.detail}`;
214 }
215}
216
217export class BitbucketCheckoutError extends Schema.TaggedError<BitbucketCheckoutError>()(
218 "BitbucketCheckoutError",
219 {
220 cwd: Schema.String,
221 reference: Schema.String,
222 cause: Schema.Defect(),
223 },
224) {
225 get detail(): string {
226 return "Failed to check out the Bitbucket pull request.";
227 }
228
229 override get message(): string {
230 return `Bitbucket API failed in checkoutPullRequest: ${this.detail}`;
231 }
232}
233
234/**
235 * A url that does not belong to the configured Bitbucket. Refused rather than followed, because
236 * the request carries the account's credentials and a url that came back in a response — a
237 * pagination cursor, or the target of a redirect — is not this server's to trust.
238 */
239export class BitbucketUntrustedUrlError extends Schema.TaggedError<BitbucketUntrustedUrlError>()(
240 "BitbucketUntrustedUrlError",
241 {
242 /** The host only. A rejected hop is often a signed url, whose query carries a credential. */
243 host: Schema.String,
244 },
245) {
246 get detail(): string {
247 return `The response pointed at ${this.host}, outside the configured Bitbucket.`;
248 }
249
250 override get message(): string {
251 return `Bitbucket API failed in request: ${this.detail}`;
252 }
253}
254
255export const BitbucketApiError = Schema.Union([
256 BitbucketUntrustedUrlError,
257 BitbucketRepositoryLocatorError,
258 BitbucketRequestError,
259 BitbucketResponseError,
260 BitbucketResponseBodyReadError,
261 BitbucketResponseDecodeError,
262 BitbucketRepositoryVcsResolveError,
263 BitbucketRepositoryRemotesListError,
264 BitbucketRepositoryRemoteNotFoundError,
265 BitbucketPullRequestBodyReadError,
266 BitbucketCheckoutError,
267]);
268export type BitbucketApiError = typeof BitbucketApiError.Type;
269const isBitbucketApiError = Schema.is(BitbucketApiError);
270
271const RawBitbucketRepositorySchema = Schema.Struct({
272 full_name: TrimmedNonEmptyString,
273 links: Schema.Struct({
274 html: Schema.optional(
275 Schema.Struct({
276 href: TrimmedNonEmptyString,
277 }),
278 ),
279 clone: Schema.optional(
280 Schema.Array(
281 Schema.Struct({
282 name: TrimmedNonEmptyString,
283 href: TrimmedNonEmptyString,
284 }),
285 ),
286 ),
287 }),
288 mainbranch: Schema.optional(
289 Schema.NullOr(
290 Schema.Struct({
291 name: TrimmedNonEmptyString,
292 }),
293 ),
294 ),
295});
296
297const RawBitbucketBranchingModelSchema = Schema.Struct({
298 development: Schema.optional(
299 Schema.Struct({
300 branch: Schema.optional(
301 Schema.NullOr(
302 Schema.Struct({
303 name: Schema.optional(TrimmedNonEmptyString),
304 }),
305 ),
306 ),
307 is_valid: Schema.optional(Schema.Boolean),
308 name: Schema.optional(Schema.NullOr(Schema.String)),
309 use_mainbranch: Schema.optional(Schema.Boolean),
310 }),
311 ),
312});
313
314const BitbucketUserSchema = Schema.Struct({
315 username: Schema.optional(TrimmedNonEmptyString),
316 display_name: Schema.optional(TrimmedNonEmptyString),
317 account_id: Schema.optional(TrimmedNonEmptyString),
318});
319
320export interface BitbucketRepositoryLocator {
321 readonly workspace: string;
322 readonly repoSlug: string;
323}
324
325export class BitbucketApi extends Context.Service<
326 BitbucketApi,
327 {
328 readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
329
330 /**
331 * One authenticated request, returning the body verbatim. Bitbucket answers most endpoints
332 * with JSON and a few — a pull request diff, for one — with plain text, so the body is
333 * handed back undecoded for the caller to read as it sees fit.
334 */
335 readonly request: (input: {
336 readonly method: "GET" | "POST" | "PUT" | "DELETE";
337 /**
338 * A path below the API base, or a whole URL as a paged response reports its next page.
339 * A whole URL is refused unless it belongs to the configured Bitbucket.
340 */
341 readonly url: string;
342 /** A JSON document, for the endpoints that take one. */
343 readonly body?: string;
344 /** Response bytes to keep; past this the body comes back cut short and marked. */
345 readonly maxBytes?: number;
346 }) => Effect.Effect<{ readonly body: string; readonly truncated: boolean }, BitbucketApiError>;
347 readonly listPullRequests: (input: {
348 readonly cwd: string;
349 readonly context?: SourceControlProvider.SourceControlProviderContext;
350 readonly headSelector: string;
351 readonly source?: SourceControlProvider.SourceControlRefSelector;
352 readonly state: "open" | "closed" | "merged" | "all";
353 readonly limit?: number;
354 }) => Effect.Effect<ReadonlyArray<NormalizedBitbucketPullRequestRecord>, BitbucketApiError>;
355 readonly getPullRequest: (input: {
356 readonly cwd: string;
357 readonly context?: SourceControlProvider.SourceControlProviderContext;
358 readonly reference: string;
359 }) => Effect.Effect<NormalizedBitbucketPullRequestRecord, BitbucketApiError>;
360 readonly getRepositoryCloneUrls: (input: {
361 readonly cwd: string;
362 readonly context?: SourceControlProvider.SourceControlProviderContext;
363 readonly repository: string;
364 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
365 readonly createRepository: (input: {
366 readonly cwd: string;
367 readonly repository: string;
368 readonly visibility: SourceControlRepositoryVisibility;
369 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
370 readonly createPullRequest: (input: {
371 readonly cwd: string;
372 readonly context?: SourceControlProvider.SourceControlProviderContext;
373 readonly baseBranch: string;
374 readonly headSelector: string;
375 readonly source?: SourceControlProvider.SourceControlRefSelector;
376 readonly target?: SourceControlProvider.SourceControlRefSelector;
377 readonly title: string;
378 readonly bodyFile: string;
379 }) => Effect.Effect<void, BitbucketApiError>;
380 readonly getDefaultBranch: (input: {
381 readonly cwd: string;
382 readonly context?: SourceControlProvider.SourceControlProviderContext;
383 }) => Effect.Effect<string | null, BitbucketApiError>;
384 readonly checkoutPullRequest: (input: {
385 readonly cwd: string;
386 readonly context?: SourceControlProvider.SourceControlProviderContext;
387 readonly reference: string;
388 readonly force?: boolean;
389 }) => Effect.Effect<void, BitbucketApiError>;
390 }
391>()("@t3tools/source-control-bitbucket/server/BitbucketApi") {}
392
393function nonEmpty(value: string | undefined): Option.Option<string> {
394 const trimmed = value?.trim();
395 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
396}
397
398function normalizeChangeRequestId(reference: string): string {
399 const trimmed = reference.trim().replace(/^#/, "");
400 const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec(
401 trimmed,
402 );
403 return urlMatch?.[1] ?? trimmed;
404}
405
406function sourceWorkspace(input: {
407 readonly headSelector: string;
408 readonly source?: SourceControlProvider.SourceControlRefSelector;
409}): string | undefined {
410 if (input.source?.owner) return input.source.owner;
411 return SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner;
412}
413
414function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> {
415 switch (state) {
416 case "open":
417 return ["OPEN"];
418 case "closed":
419 return ["DECLINED", "SUPERSEDED"];
420 case "merged":
421 return ["MERGED"];
422 case "all":
423 return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"];
424 }
425}
426
427function bitbucketQueryString(filters: ReadonlyArray<string>): string {
428 return filters.join(" AND ");
429}
430
431function bitbucketStateFilter(states: ReadonlyArray<string>): string {
432 return states.length === 1
433 ? `state = "${states[0]}"`
434 : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`;
435}
436
437function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null {
438 const normalized = value.trim().replace(/\.git$/u, "");
439 const parts = normalized.split("/").filter((part) => part.length > 0);
440 if (parts.length < 2) return null;
441 const workspace = parts.at(-2);
442 const repoSlug = parts.at(-1);
443 return workspace && repoSlug ? { workspace, repoSlug } : null;
444}
445
446function requireRepositoryLocator(
447 repository: string,
448): Effect.Effect<BitbucketRepositoryLocator, BitbucketApiError> {
449 const locator = parseBitbucketRepositorySlug(repository);
450 return locator
451 ? Effect.succeed(locator)
452 : Effect.fail(
453 new BitbucketRepositoryLocatorError({
454 repository,
455 }),
456 );
457}
458
459function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null {
460 const trimmed = remoteUrl.trim();
461 const scpMatch = /^[a-zA-Z0-9._-]+@[^:/]+:(.+)$/.exec(trimmed);
462 if (scpMatch?.[1]) {
463 return parseBitbucketRepositorySlug(scpMatch[1]);
464 }
465
466 try {
467 return parseBitbucketRepositorySlug(new URL(trimmed).pathname);
468 } catch {
469 return null;
470 }
471}
472
473function normalizeRepositoryCloneUrls(
474 raw: typeof RawBitbucketRepositorySchema.Type,
475): SourceControlRepositoryCloneUrls {
476 const httpClone =
477 raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ??
478 raw.links.html?.href;
479 const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href;
480
481 return {
482 nameWithOwner: raw.full_name,
483 url: httpClone ?? raw.links.html?.href ?? raw.full_name,
484 sshUrl: sshClone ?? httpClone ?? raw.full_name,
485 };
486}
487
488function defaultChangeRequestTargetBranch(input: {
489 readonly repository: typeof RawBitbucketRepositorySchema.Type;
490 readonly branchingModel: typeof RawBitbucketBranchingModelSchema.Type | null;
491}): string | null {
492 const repositoryMainBranch = input.repository.mainbranch?.name ?? null;
493 const development = input.branchingModel?.development;
494 if (!development || development.use_mainbranch === true || development.is_valid === false) {
495 return repositoryMainBranch;
496 }
497
498 const developmentBranch = development.branch?.name?.trim() ?? development.name?.trim() ?? "";
499 if (developmentBranch.length === 0 || developmentBranch === "null") {
500 return repositoryMainBranch;
501 }
502
503 return developmentBranch;
504}
505
506function shouldPreferSshRemote(originRemoteUrl: string | null): boolean {
507 if (!originRemoteUrl) return false;
508 return isSshRemoteUrl(originRemoteUrl);
509}
510
511function selectCloneUrl(input: {
512 readonly cloneUrls: SourceControlRepositoryCloneUrls;
513 readonly originRemoteUrl: string | null;
514}): string {
515 return shouldPreferSshRemote(input.originRemoteUrl)
516 ? input.cloneUrls.sshUrl
517 : input.cloneUrls.url;
518}
519
520function checkoutBranchName(input: {
521 readonly pullRequestId: number;
522 readonly headBranch: string;
523 readonly isCrossRepository: boolean;
524}): string {
525 if (!input.isCrossRepository) {
526 return input.headBranch;
527 }
528
529 return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`;
530}
531
532function repositoryNameWithOwner(
533 repository: Schema.Schema.Type<typeof BitbucketPullRequestSchema>["source"]["repository"],
534): string | null {
535 const fullName = repository?.full_name?.trim() ?? "";
536 return fullName.length > 0 ? fullName : null;
537}
538
539function repositoryOwnerName(repositoryName: string): string {
540 return repositoryName.split("/")[0]?.trim() || "bitbucket";
541}
542
543type BitbucketCredential =
544 | { readonly kind: "access-token"; readonly accessToken: string }
545 | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string };
546
547/**
548 * Visible ASCII only. A value the HTTP stack rejects makes it throw an error quoting the whole
549 * header, and that error travels to clients as a cause, so an unusable token is treated as unset.
550 */
551const HEADER_SAFE = /^[\x21-\x7e]+$/u;
552
553function credentialFrom(input: {
554 readonly accessToken: string;
555 readonly email: string;
556 readonly apiToken: string;
557}): BitbucketCredential | null {
558 if (HEADER_SAFE.test(input.accessToken)) {
559 return { kind: "access-token", accessToken: input.accessToken };
560 }
561 if (HEADER_SAFE.test(input.email) && HEADER_SAFE.test(input.apiToken)) {
562 return { kind: "api-token", email: input.email, apiToken: input.apiToken };
563 }
564 return null;
565}
566
567/**
568 * Credentials saved in settings win over the `T3CODE_BITBUCKET_*` environment variables, which
569 * stay as a fallback. Within each source the access token wins.
570 */
571function resolveCredential(
572 settings: BitbucketSettings,
573 env: Config.Success<typeof BitbucketApiEnvConfig>,
574): BitbucketCredential | null {
575 return (
576 credentialFrom(settings) ??
577 credentialFrom({
578 accessToken: Option.getOrElse(env.accessToken, () => ""),
579 email: Option.getOrElse(env.email, () => ""),
580 apiToken: Option.getOrElse(env.apiToken, () => ""),
581 })
582 );
583}
584
585function authFromCredential(credential: BitbucketCredential | null): SourceControlProviderAuth {
586 if (credential?.kind === "access-token") {
587 return {
588 status: "unknown",
589 account: Option.none(),
590 host: Option.some("bitbucket.org"),
591 detail: Option.some("An access token is configured."),
592 };
593 }
594
595 if (credential?.kind === "api-token") {
596 return {
597 status: "unknown",
598 account: Option.some(credential.email),
599 host: Option.some("bitbucket.org"),
600 detail: Option.some("An API token is configured."),
601 };
602 }
603
604 return {
605 status: "unauthenticated",
606 account: Option.none(),
607 host: Option.some("bitbucket.org"),
608 detail: Option.some(
609 "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.",
610 ),
611 };
612}
613
614/** Null for anything that is not a url at all, which is never the configured Bitbucket. */
615function originOf(value: string): string | null {
616 try {
617 return new URL(value).origin;
618 } catch {
619 return null;
620 }
621}
622
623function responseError(
624 operation: BitbucketApiOperation,
625 response: HttpClientResponse.HttpClientResponse,
626): Effect.Effect<never, BitbucketApiError> {
627 // Bounded like any other body: an error response is no smaller than a successful one, and
628 // only its length is reported anyway.
629 return Effect.gen(function* () {
630 const now = yield* Clock.currentTimeMillis;
631 const retryAt = retryAtFromHeader(response.headers["retry-after"], now);
632 const collected = yield* collectUint8StreamText({
633 stream: response.stream,
634 maxBytes: DEFAULT_MAX_RESPONSE_BYTES,
635 }).pipe(
636 Effect.mapError(
637 (cause) =>
638 new BitbucketResponseBodyReadError({
639 operation,
640 status: response.status,
641 retryAt,
642 cause,
643 }),
644 ),
645 );
646 return yield* new BitbucketResponseError({
647 operation,
648 status: response.status,
649 responseBodyLength: collected.text.length,
650 retryAt,
651 });
652 });
653}
654
655/** @public Service construction is part of the canonical Effect module API. */
656export const make = Effect.gen(function* () {
657 const config = yield* BitbucketApiEnvConfig;
658 const host = yield* SourceControlHost.SourceControlHost;
659 const git = host.git;
660 const httpClient = yield* HttpClient.HttpClient;
661 const fileSystem = yield* FileSystem.FileSystem;
662
663 const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`;
664
665 // Read on every request so credentials saved in settings apply without a restart.
666 const currentCredential = host.settings.get.pipe(
667 Effect.map((settings) => resolveCredential(settings.bitbucket, config)),
668 Effect.catch((error) =>
669 // No cause: a settings decode error can quote a hand-edited token.
670 Effect.logWarning("failed to read Bitbucket credentials from settings", {
671 operation: error.operation,
672 }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))),
673 ),
674 );
675
676 const withAuth = (request: HttpClientRequest.HttpClientRequest) =>
677 currentCredential.pipe(
678 Effect.map((credential) =>
679 credential === null
680 ? request
681 : credential.kind === "access-token"
682 ? request.pipe(HttpClientRequest.bearerToken(credential.accessToken))
683 : request.pipe(HttpClientRequest.basicAuth(credential.email, credential.apiToken)),
684 ),
685 );
686
687 const decodeResponse = <S extends Schema.Top>(
688 operation: BitbucketApiOperation,
689 schema: S,
690 response: HttpClientResponse.HttpClientResponse,
691 ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> =>
692 HttpClientResponse.matchStatus({
693 "2xx": (success) =>
694 HttpClientResponse.schemaBodyJson(schema)(success).pipe(
695 Effect.mapError(
696 (cause) =>
697 new BitbucketResponseDecodeError({
698 operation,
699 status: success.status,
700 cause,
701 }),
702 ),
703 ),
704 orElse: (failed) => responseError(operation, failed),
705 })(response);
706
707 const executeJson = <S extends Schema.Top>(
708 operation: BitbucketApiOperation,
709 request: HttpClientRequest.HttpClientRequest,
710 schema: S,
711 ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> =>
712 withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe(
713 Effect.flatMap(httpClient.execute),
714 Effect.mapError(
715 (cause) =>
716 new BitbucketRequestError({
717 operation,
718 cause,
719 }),
720 ),
721 Effect.flatMap((response) => decodeResponse(operation, schema, response)),
722 );
723
724 const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: {
725 readonly cwd: string;
726 readonly context?: SourceControlProvider.SourceControlProviderContext;
727 readonly repository?: string;
728 }) {
729 if (input.repository !== undefined) {
730 return yield* requireRepositoryLocator(input.repository);
731 }
732
733 const fromContext =
734 input.context?.provider.kind === "bitbucket"
735 ? parseBitbucketRemoteUrl(input.context.remoteUrl)
736 : null;
737 if (fromContext) return fromContext;
738
739 const listRemotes = yield* git.remotes(input.cwd).pipe(
740 Effect.mapError(
741 (cause) =>
742 new BitbucketRepositoryVcsResolveError({
743 cwd: input.cwd,
744 cause,
745 }),
746 ),
747 );
748 const remotes = yield* listRemotes.pipe(
749 Effect.mapError(
750 (cause) =>
751 new BitbucketRepositoryRemotesListError({
752 cwd: input.cwd,
753 cause,
754 }),
755 ),
756 );
757
758 for (const remote of remotes.remotes) {
759 if (detectSourceControlProviderFromRemoteUrl(remote.url)?.kind !== "bitbucket") continue;
760 const parsed = parseBitbucketRemoteUrl(remote.url);
761 if (parsed) return parsed;
762 }
763
764 return yield* new BitbucketRepositoryRemoteNotFoundError({
765 cwd: input.cwd,
766 });
767 });
768
769 const getRepositoryFromLocator = (repository: BitbucketRepositoryLocator) =>
770 executeJson(
771 "getRepository",
772 HttpClientRequest.get(
773 apiUrl(
774 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`,
775 ),
776 ),
777 RawBitbucketRepositorySchema,
778 );
779
780 const getRepository = (input: {
781 readonly cwd: string;
782 readonly context?: SourceControlProvider.SourceControlProviderContext;
783 readonly repository?: string;
784 }) => resolveRepository(input).pipe(Effect.flatMap(getRepositoryFromLocator));
785
786 const getBranchingModelFromLocator = (repository: BitbucketRepositoryLocator) =>
787 executeJson(
788 "getBranchingModel",
789 HttpClientRequest.get(
790 apiUrl(
791 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/branching-model`,
792 ),
793 ),
794 RawBitbucketBranchingModelSchema,
795 );
796
797 const getRawPullRequestFromRepository = (
798 repository: BitbucketRepositoryLocator,
799 reference: string,
800 ) =>
801 executeJson(
802 "getPullRequest",
803 HttpClientRequest.get(
804 apiUrl(
805 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`,
806 ),
807 ),
808 BitbucketPullRequestSchema,
809 );
810
811 const getRawPullRequest = (input: {
812 readonly cwd: string;
813 readonly context?: SourceControlProvider.SourceControlProviderContext;
814 readonly reference: string;
815 }) =>
816 resolveRepository(input).pipe(
817 Effect.flatMap((repository) => getRawPullRequestFromRepository(repository, input.reference)),
818 );
819
820 const readConfigValueNullable = (cwd: string, key: string) =>
821 git.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null));
822
823 const resolveCheckoutRemote = Effect.fn("BitbucketApi.resolveCheckoutRemote")(function* (input: {
824 readonly cwd: string;
825 readonly context?: SourceControlProvider.SourceControlProviderContext;
826 readonly destinationRepository: BitbucketRepositoryLocator;
827 readonly sourceRepositoryName: string;
828 readonly isCrossRepository: boolean;
829 }) {
830 if (
831 input.context?.provider.kind === "bitbucket" &&
832 !input.isCrossRepository &&
833 parseBitbucketRemoteUrl(input.context.remoteUrl) !== null
834 ) {
835 return input.context.remoteName;
836 }
837
838 if (!input.isCrossRepository) {
839 const remoteName = yield* git
840 .resolvePrimaryRemoteName(input.cwd)
841 .pipe(Effect.orElseSucceed(() => null));
842 if (remoteName) return remoteName;
843 }
844
845 const cloneUrls = yield* getRepository({
846 cwd: input.cwd,
847 repository: input.sourceRepositoryName,
848 ...(input.context ? { context: input.context } : {}),
849 }).pipe(Effect.map(normalizeRepositoryCloneUrls));
850 const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url");
851 return yield* git.ensureRemote({
852 cwd: input.cwd,
853 preferredName: input.isCrossRepository
854 ? repositoryOwnerName(input.sourceRepositoryName)
855 : input.destinationRepository.workspace,
856 url: selectCloneUrl({ cloneUrls, originRemoteUrl }),
857 });
858 });
859
860 // A pull request's diff, diffstat and conflicts are served as redirects to a commit-range
861 // URL, and the client does not follow redirects unless asked. The hop stays on the same host,
862 // so the credentials travel with it.
863 /**
864 * The one host these credentials may be sent to. A url that came back inside a response — a
865 * pagination cursor, or the target of a redirect — is data, not instruction, so it is checked
866 * against this before the account's token travels with it.
867 */
868 const apiOrigin = originOf(config.baseUrl);
869
870 const trustedUrl = (value: string): string | null => {
871 if (!/^https?:\/\//u.test(value)) return apiUrl(value);
872 const origin = originOf(value);
873 return origin !== null && origin === apiOrigin ? value : null;
874 };
875
876 /**
877 * Redirects are followed here rather than by the client, which forwards every header to
878 * whatever host it is sent to. A pull request diff, diffstat and conflicts are all served as
879 * redirects, so they have to be followed — but only back to the same Bitbucket.
880 */
881 const send = (input: {
882 readonly method: "GET" | "POST" | "PUT" | "DELETE";
883 readonly url: string;
884 readonly body?: string;
885 readonly redirects: number;
886 }): Effect.Effect<HttpClientResponse.HttpClientResponse, BitbucketApiError> => {
887 const url = trustedUrl(input.url);
888 if (url === null) {
889 return Effect.fail(
890 new BitbucketUntrustedUrlError({ host: originOf(input.url) ?? "an unreadable url" }),
891 );
892 }
893 const base =
894 input.method === "GET"
895 ? HttpClientRequest.get(url)
896 : input.method === "POST"
897 ? HttpClientRequest.post(url)
898 : input.method === "DELETE"
899 ? HttpClientRequest.make("DELETE")(url)
900 : HttpClientRequest.put(url);
901 // No `Accept: application/json`: the diff endpoints answer with a patch, not JSON.
902 const withBody =
903 input.body === undefined
904 ? base
905 : base.pipe(HttpClientRequest.bodyText(input.body, "application/json"));
906 return withAuth(withBody).pipe(
907 Effect.flatMap(httpClient.execute),
908 Effect.mapError(
909 (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }),
910 ),
911 Effect.flatMap((response) => {
912 const location = response.headers.location;
913 if (
914 response.status >= 300 &&
915 response.status < 400 &&
916 location !== undefined &&
917 input.redirects < MAX_REDIRECTS
918 ) {
919 return send({
920 ...input,
921 url: new URL(location, url).toString(),
922 redirects: input.redirects + 1,
923 });
924 }
925 return Effect.succeed(response);
926 }),
927 );
928 };
929
930 const request: BitbucketApi["Service"]["request"] = (input) =>
931 send({ ...input, redirects: 0 }).pipe(
932 Effect.flatMap((response) =>
933 HttpClientResponse.matchStatus({
934 // Read through the body stream rather than `text`, so an oversized diff is stopped
935 // as it arrives instead of being materialized whole and then cut. The same collector
936 // the process runner bounds command output with.
937 "2xx": (success) =>
938 collectUint8StreamText({
939 stream: success.stream,
940 maxBytes: input.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
941 }).pipe(
942 Effect.mapError(
943 (cause) =>
944 new BitbucketResponseBodyReadError({
945 operation: "request",
946 status: success.status,
947 cause,
948 }),
949 ),
950 Effect.map((collected) => ({
951 body: collected.text,
952 truncated: collected.truncated,
953 })),
954 ),
955 orElse: (failed) => responseError("request", failed),
956 })(response),
957 ),
958 );
959
960 return BitbucketApi.of({
961 request,
962 probeAuth: executeJson(
963 "probeAuth",
964 HttpClientRequest.get(apiUrl("/user")),
965 BitbucketUserSchema,
966 ).pipe(
967 Effect.map((user) => ({
968 status: "authenticated" as const,
969 account: nonEmpty(user.username ?? user.display_name ?? user.account_id),
970 host: Option.some("bitbucket.org"),
971 detail: Option.none<string>(),
972 })),
973 Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))),
974 ),
975 listPullRequests: (input) =>
976 resolveRepository(input).pipe(
977 Effect.flatMap((repository) => {
978 const states = toBitbucketStates(input.state);
979 const query: Record<string, string | ReadonlyArray<string>> = {
980 pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))),
981 sort: "-updated_on",
982 q: bitbucketQueryString([
983 `source.branch.name = "${SourceControlProvider.sourceBranch(input).replaceAll('"', '\\"')}"`,
984 bitbucketStateFilter(states),
985 ]),
986 state: states,
987 };
988
989 return executeJson(
990 "listPullRequests",
991 HttpClientRequest.get(
992 apiUrl(
993 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
994 ),
995 { urlParams: query },
996 ),
997 BitbucketPullRequestListSchema,
998 );
999 }),
1000 Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)),
1001 ),
1002 getPullRequest: (input) =>
1003 getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)),
1004 getRepositoryCloneUrls: (input) =>
1005 getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)),
1006 createRepository: (input) =>
1007 requireRepositoryLocator(input.repository).pipe(
1008 Effect.flatMap((repository) =>
1009 executeJson(
1010 "createRepository",
1011 HttpClientRequest.post(
1012 apiUrl(
1013 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`,
1014 ),
1015 ).pipe(
1016 HttpClientRequest.bodyJsonUnsafe({
1017 scm: "git",
1018 is_private: input.visibility === "private",
1019 }),
1020 ),
1021 RawBitbucketRepositorySchema,
1022 ),
1023 ),
1024 Effect.map(normalizeRepositoryCloneUrls),
1025 ),
1026 createPullRequest: (input) =>
1027 Effect.gen(function* () {
1028 const repository = yield* resolveRepository(input);
1029 const description = yield* fileSystem.readFileString(input.bodyFile).pipe(
1030 Effect.mapError(
1031 (cause) =>
1032 new BitbucketPullRequestBodyReadError({
1033 cwd: input.cwd,
1034 bodyFile: input.bodyFile,
1035 cause,
1036 }),
1037 ),
1038 );
1039 const sourceOwner = sourceWorkspace(input);
1040 const body = {
1041 title: input.title,
1042 description,
1043 source: {
1044 branch: {
1045 name: SourceControlProvider.sourceBranch(input),
1046 },
1047 ...(sourceOwner
1048 ? {
1049 repository: {
1050 full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`,
1051 },
1052 }
1053 : {}),
1054 },
1055 destination: {
1056 branch: {
1057 name: input.target?.refName ?? input.baseBranch,
1058 },
1059 },
1060 };
1061
1062 yield* executeJson(
1063 "createPullRequest",
1064 HttpClientRequest.post(
1065 apiUrl(
1066 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
1067 ),
1068 ).pipe(HttpClientRequest.bodyJsonUnsafe(body)),
1069 BitbucketPullRequestSchema,
1070 );
1071 }),
1072 getDefaultBranch: (input) =>
1073 resolveRepository(input).pipe(
1074 Effect.flatMap((locator) =>
1075 Effect.all(
1076 {
1077 repository: getRepositoryFromLocator(locator),
1078 branchingModel: getBranchingModelFromLocator(locator).pipe(
1079 Effect.orElseSucceed(
1080 (): typeof RawBitbucketBranchingModelSchema.Type | null => null,
1081 ),
1082 ),
1083 },
1084 { concurrency: "unbounded" },
1085 ),
1086 ),
1087 Effect.map(defaultChangeRequestTargetBranch),
1088 ),
1089 // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide
1090 // an official checkout CLI. This provider-local path uses GitVcsDriver as a
1091 // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this
1092 // as the source-control provider model: if we support non-Git-compatible
1093 // hosting providers or native JJ/Sapling checkout flows, move this into a
1094 // VCS-specific change-request checkout capability.
1095 checkoutPullRequest: (input) =>
1096 Effect.gen(function* () {
1097 const destinationRepository = yield* resolveRepository(input);
1098 const pullRequest = yield* getRawPullRequestFromRepository(
1099 destinationRepository,
1100 input.reference,
1101 );
1102 const destinationRepositoryName =
1103 repositoryNameWithOwner(pullRequest.destination.repository) ??
1104 `${destinationRepository.workspace}/${destinationRepository.repoSlug}`;
1105 const sourceRepositoryName =
1106 repositoryNameWithOwner(pullRequest.source.repository) ?? destinationRepositoryName;
1107 const isCrossRepository = sourceRepositoryName !== destinationRepositoryName;
1108 const remoteName = yield* resolveCheckoutRemote({
1109 cwd: input.cwd,
1110 destinationRepository,
1111 sourceRepositoryName,
1112 isCrossRepository,
1113 ...(input.context ? { context: input.context } : {}),
1114 });
1115 const remoteBranch = pullRequest.source.branch.name;
1116 const localBranch = checkoutBranchName({
1117 pullRequestId: pullRequest.id,
1118 headBranch: remoteBranch,
1119 isCrossRepository,
1120 });
1121 const localBranchNames = yield* git.listLocalBranchNames(input.cwd);
1122 const localBranchExists = localBranchNames.includes(localBranch);
1123
1124 if (input.force === true || !localBranchExists) {
1125 yield* git.fetchRemoteBranch({
1126 cwd: input.cwd,
1127 remoteName,
1128 remoteBranch,
1129 localBranch,
1130 });
1131 } else {
1132 yield* git.fetchRemoteTrackingBranch({
1133 cwd: input.cwd,
1134 remoteName,
1135 remoteBranch,
1136 });
1137 }
1138
1139 yield* git.setBranchUpstream({
1140 cwd: input.cwd,
1141 branch: localBranch,
1142 remoteName,
1143 remoteBranch,
1144 });
1145 yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch }));
1146 }).pipe(
1147 Effect.mapError((cause) =>
1148 isBitbucketApiError(cause)
1149 ? cause
1150 : new BitbucketCheckoutError({
1151 cwd: input.cwd,
1152 reference: input.reference,
1153 cause,
1154 }),
1155 ),
1156 ),
1157 });
1158});
1159
1160export const layer = Layer.effect(BitbucketApi, make);
packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts created+1127
...@@ -0,0 +1,1127 @@
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(callAt(0).url).toContain("/user/permissions/repositories");
1002 assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/web"');
1003 }),
1004 );
1005
1006 it.effect("escapes a repository name before it goes inside a filter literal", () =>
1007 Effect.gen(function* () {
1008 mockedRequest.mockReturnValue(Effect.succeed(response(JSON.stringify({ values: [] }))));
1009 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1010
1011 yield* api.getRepositoryPermission({ repository: 'acme/we"b' });
1012
1013 // A quote would otherwise end the literal and leave the rest standing as filter syntax.
1014 assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/we\\"b"');
1015 }),
1016 );
1017
1018 it.effect(
1019 "reads a removed permissions endpoint as granted rather than failing the merge on it",
1020 () =>
1021 Effect.gen(function* () {
1022 // Bitbucket retired /user/permissions/repositories under CHANGE-2770: every account now
1023 // gets HTTP 410 here, whatever it may do.
1024 mockedRequest.mockReturnValue(
1025 Effect.fail(
1026 new BitbucketApi.BitbucketResponseError({
1027 operation: "request",
1028 status: 410,
1029 responseBodyLength: 0,
1030 }),
1031 ),
1032 );
1033 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1034
1035 assert.isTrue(yield* api.getRepositoryPermission({ repository: "acme/web" }));
1036 }),
1037 );
1038
1039 it.effect("still fails the permission read on a failure that is not the removed endpoint", () =>
1040 Effect.gen(function* () {
1041 mockedRequest.mockReturnValue(
1042 Effect.fail(
1043 new BitbucketApi.BitbucketResponseError({
1044 operation: "request",
1045 status: 401,
1046 responseBodyLength: 0,
1047 }),
1048 ),
1049 );
1050 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1051
1052 const error = yield* Effect.flip(api.getRepositoryPermission({ repository: "acme/web" }));
1053
1054 assert.strictEqual(error._tag, "BitbucketResponseError");
1055 }),
1056 );
1057
1058 it.effect("reads the workspace's people and marks whoever is already a reviewer", () =>
1059 Effect.gen(function* () {
1060 mockedRequest
1061 .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] }))))
1062 .mockReturnValueOnce(
1063 Effect.succeed(
1064 response(
1065 JSON.stringify({ values: [{ user: bilal }, { user: octocat }, { user: hubot }] }),
1066 ),
1067 ),
1068 );
1069 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1070
1071 const list = yield* api.listReviewerCandidates({ repository: "acme/web", number: 7 });
1072
1073 // The people live on the workspace: nothing on a repository lists who may review it.
1074 expect(callAt(1).url).toBe("/workspaces/acme/members?pagelen=50");
1075 expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([
1076 ["{octocat}", true],
1077 ["{hubot}", false],
1078 ]);
1079 assert.isFalse(list.truncated);
1080 }),
1081 );
1082
1083 it.effect("writes the reviewer set back with the one being asked added to it", () =>
1084 Effect.gen(function* () {
1085 mockedRequest
1086 .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] }))))
1087 .mockReturnValueOnce(Effect.succeed(response("{}")));
1088 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1089
1090 yield* api.setReviewerRequest({
1091 repository: "acme/web",
1092 number: 7,
1093 reviewers: [{ id: "{hubot}" }],
1094 requested: true,
1095 });
1096
1097 // Bitbucket writes `reviewers` whole, so the one already on the pull request travels with
1098 // the new one or the request would take them off it.
1099 const call = callAt(1);
1100 expect(call.method).toBe("PUT");
1101 expect(call.url).toBe("/repositories/acme/web/pullrequests/7");
1102 expect(JSON.parse(call.body ?? "")).toEqual({
1103 reviewers: [{ uuid: "{octocat}" }, { uuid: "{hubot}" }],
1104 });
1105 }),
1106 );
1107
1108 it.effect("takes a reviewer out of the set rather than clearing it", () =>
1109 Effect.gen(function* () {
1110 mockedRequest
1111 .mockReturnValueOnce(
1112 Effect.succeed(response(pullRequestJson({ reviewers: [octocat, hubot] }))),
1113 )
1114 .mockReturnValueOnce(Effect.succeed(response("{}")));
1115 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1116
1117 yield* api.setReviewerRequest({
1118 repository: "acme/web",
1119 number: 7,
1120 reviewers: [{ id: "{hubot}" }],
1121 requested: false,
1122 });
1123
1124 expect(JSON.parse(callAt(1).body ?? "")).toEqual({ reviewers: [{ uuid: "{octocat}" }] });
1125 }),
1126 );
1127});
packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts created+943
...@@ -0,0 +1,943 @@
1import * as Cache from "effect/Cache";
2import * as Context from "effect/Context";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Layer from "effect/Layer";
7import * as Result from "effect/Result";
8import * as Schema from "effect/Schema";
9import type {
10 PullRequestAction,
11 PullRequestCheck,
12 PullRequestComment,
13 PullRequestCommit,
14 PullRequestListState,
15 PullRequestMergeMethod,
16 PullRequestMergeability,
17 PullRequestReviewCommentDraft,
18 PullRequestReviewPosition,
19 PullRequestReviewThread,
20 PullRequestReviewVerdict,
21 PullRequestReviewerCandidateList,
22} from "@t3tools/contracts";
23
24import * as BitbucketApi from "./BitbucketApi.ts";
25import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions";
26import {
27 buildReviewThreads,
28 decodeCommentsJson,
29 decodeCommitsJson,
30 decodeConflictsJson,
31 decodeDiffstatJson,
32 decodePullRequestJson,
33 decodePullRequestPageJson,
34 decodeRepositoryPermissionJson,
35 decodeStatusesJson,
36 decodeViewerJson,
37 decodeWorkspaceMembersJson,
38 type BitbucketDiffStat,
39 type BitbucketPullRequest,
40 type BitbucketRawComment,
41} from "./bitbucketPullRequestJson.ts";
42import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider";
43
44/**
45 * Names the read that produced unusable output, so a failure reports the call it came from
46 * rather than borrowing another operation's message.
47 */
48export class BitbucketPullRequestReadError extends Schema.TaggedError<BitbucketPullRequestReadError>()(
49 "BitbucketPullRequestReadError",
50 {
51 operation: Schema.String,
52 cause: Schema.Defect(),
53 },
54) {
55 get detail(): string {
56 return `Bitbucket returned an unreadable ${this.operation} response.`;
57 }
58
59 override get message(): string {
60 return `Bitbucket failed in ${this.operation}: ${this.detail}`;
61 }
62}
63
64/** Not a decode failure: Bitbucket answered, the account it answered for just has no handle. */
65export class BitbucketViewerUnavailableError extends Schema.TaggedError<BitbucketViewerUnavailableError>()(
66 "BitbucketViewerUnavailableError",
67 {},
68) {
69 get detail(): string {
70 return "Bitbucket returned no account name for the configured credentials.";
71 }
72
73 override get message(): string {
74 return `Bitbucket failed in getViewer: ${this.detail}`;
75 }
76}
77
78/** A repository that is not `workspace/slug`, which is the only form Bitbucket addresses. */
79export class BitbucketRepositoryUnsupportedError extends Schema.TaggedError<BitbucketRepositoryUnsupportedError>()(
80 "BitbucketRepositoryUnsupportedError",
81 {
82 repository: Schema.String,
83 },
84) {
85 get detail(): string {
86 return "A Bitbucket repository is addressed as workspace/repository.";
87 }
88
89 override get message(): string {
90 return `Bitbucket failed in resolveRepository: ${this.detail}`;
91 }
92}
93
94/** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */
95export class BitbucketDiffCommitError extends Schema.TaggedError<BitbucketDiffCommitError>()(
96 "BitbucketDiffCommitError",
97 {},
98) {
99 get detail(): string {
100 return "The named commit was not a commit sha.";
101 }
102
103 override get message(): string {
104 return `Bitbucket failed in getPullRequestDiff: ${this.detail}`;
105 }
106}
107
108export type BitbucketPullRequestApiError =
109 | BitbucketApi.BitbucketApiError
110 | BitbucketPullRequestReadError
111 | BitbucketViewerUnavailableError
112 | BitbucketRepositoryUnsupportedError
113 | BitbucketDiffCommitError;
114
115/**
116 * `/user/permissions/repositories` answering CHANGE-2770's removal notice rather than a
117 * permission — Bitbucket sends this for every account now, not only ones it would have refused.
118 */
119function isRepositoryPermissionRemovedError(
120 error: BitbucketPullRequestApiError,
121): error is BitbucketApi.BitbucketResponseError {
122 return error._tag === "BitbucketResponseError" && error.status === 410;
123}
124
125/**
126 * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no
127 * error at all, so this is a number to respect rather than to push against.
128 */
129const MAX_PAGE_SIZE = 50;
130/** Pages to walk before a listing is reported as truncated. */
131const MAX_LIST_PAGES = 10;
132/** The page size for pull request conversations, commits, and checks. */
133const CONVERSATION_PAGE_SIZE = 50;
134/**
135 * Pages of the conversation to follow before it is reported as truncated. Bitbucket serves
136 * fifty comments a page, so this is five hundred — beyond any pull request a person is reading,
137 * and an end to a walk whose only other stop is Bitbucket running out.
138 */
139const CONVERSATION_PAGES = 10;
140/** The same ceiling the gh and glab diff reads use. */
141const DIFF_MAX_BYTES = 8 * 1024 * 1024;
142/**
143 * Deliberately far shorter than the window the caller holds versions for: a refresh drops what the
144 * caller holds precisely so the next read reaches Bitbucket, and this must not be what answers it
145 * instead.
146 */
147const REVISION_PATCH_TTL = Duration.seconds(5);
148const REVISION_PATCH_CAPACITY = 16;
149export interface BitbucketPullRequestBatch {
150 readonly items: ReadonlyArray<BitbucketPullRequest>;
151 readonly truncated: boolean;
152}
153
154export class BitbucketPullRequestApi extends Context.Service<
155 BitbucketPullRequestApi,
156 {
157 /** A function rather than a value, so the request is built per call and not at layer time. */
158 readonly getViewer: () => Effect.Effect<string, BitbucketPullRequestApiError>;
159
160 readonly listPullRequests: (input: {
161 readonly repository: string;
162 readonly state: PullRequestListState;
163 readonly limit: number;
164 /** Free text, matched against a pull request's title and description. */
165 readonly query?: string | undefined;
166 /** Where to carry on from, as a predicate on `updated_on` beside any other. */
167 readonly cursor?: ProviderListCursor | undefined;
168 }) => Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError>;
169
170 readonly getPullRequest: (input: {
171 readonly repository: string;
172 readonly number: number;
173 }) => Effect.Effect<BitbucketPullRequest, BitbucketPullRequestApiError>;
174
175 /** True where the credentials can write to the repository, which is what merging needs. */
176 readonly getRepositoryPermission: (input: {
177 readonly repository: string;
178 }) => Effect.Effect<boolean, BitbucketPullRequestApiError>;
179
180 readonly getPullRequestDiff: (input: {
181 readonly repository: string;
182 readonly number: number;
183 /** One commit's own changes, rather than everything the pull request carries. */
184 readonly commit?: string | undefined;
185 }) => Effect.Effect<
186 { readonly patch: string; readonly truncated: boolean },
187 BitbucketPullRequestApiError
188 >;
189
190 readonly getDiffStat: (input: {
191 readonly repository: string;
192 readonly number: number;
193 }) => Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError>;
194
195 /**
196 * What the pull request's head has of each of these paths, as opaque ids, read off the pull
197 * request's own patch, the only place Bitbucket states a file's version. A path the patch does
198 * not carry is answered as the empty revision, and left out altogether when the patch was cut
199 * short at the byte ceiling and so cannot be spoken for.
200 *
201 * Answers with every file the patch carries rather than only the paths asked about, since
202 * reading one file's version here means parsing all of them. `complete` is false for a patch
203 * cut short, which cannot speak for what came after the cut.
204 */
205 readonly getFileRevisions: (input: {
206 readonly repository: string;
207 readonly number: number;
208 readonly paths: ReadonlyArray<string>;
209 }) => Effect.Effect<
210 { readonly revisions: ReadonlyMap<string, string>; readonly complete: boolean },
211 BitbucketPullRequestApiError
212 >;
213
214 readonly getMergeability: (input: {
215 readonly repository: string;
216 readonly number: number;
217 }) => Effect.Effect<PullRequestMergeability, BitbucketPullRequestApiError>;
218
219 readonly listComments: (input: {
220 readonly repository: string;
221 readonly number: number;
222 }) => Effect.Effect<
223 {
224 readonly comments: ReadonlyArray<PullRequestComment>;
225 readonly threads: ReadonlyArray<PullRequestReviewThread>;
226 readonly truncated: boolean;
227 },
228 BitbucketPullRequestApiError
229 >;
230
231 readonly listCommits: (input: {
232 readonly repository: string;
233 readonly number: number;
234 }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, BitbucketPullRequestApiError>;
235
236 readonly listChecks: (input: {
237 readonly repository: string;
238 readonly number: number;
239 }) => Effect.Effect<ReadonlyArray<PullRequestCheck>, BitbucketPullRequestApiError>;
240
241 /**
242 * Who this pull request may be sent to, and who it has already been sent to. Two reads at
243 * once, because Bitbucket keeps the people on the workspace and the reviewers on the pull
244 * request, and neither answers for the other.
245 */
246 readonly listReviewerCandidates: (input: {
247 readonly repository: string;
248 readonly number: number;
249 }) => Effect.Effect<PullRequestReviewerCandidateList, BitbucketPullRequestApiError>;
250
251 readonly setReviewerRequest: (input: {
252 readonly repository: string;
253 readonly number: number;
254 readonly reviewers: ReadonlyArray<{ readonly id: string }>;
255 readonly requested: boolean;
256 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
257
258 readonly runAction: (input: {
259 readonly repository: string;
260 readonly number: number;
261 readonly action: PullRequestAction;
262 readonly mergeMethod?: PullRequestMergeMethod;
263 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
264
265 readonly updateChangeRequest: (input: {
266 readonly repository: string;
267 readonly number: number;
268 readonly title?: string | undefined;
269 readonly body?: string | undefined;
270 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
271
272 readonly comment: (input: {
273 readonly repository: string;
274 readonly number: number;
275 readonly body: string;
276 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
277
278 readonly updateComment: (input: {
279 readonly repository: string;
280 readonly number: number;
281 readonly commentId: string;
282 readonly body: string;
283 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
284
285 readonly submitReview: (input: {
286 readonly repository: string;
287 readonly number: number;
288 readonly verdict: PullRequestReviewVerdict;
289 readonly body: string;
290 readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;
291 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
292
293 readonly replyToComment: (input: {
294 readonly repository: string;
295 readonly number: number;
296 readonly commentId: string;
297 readonly body: string;
298 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
299
300 readonly setCommentResolution: (input: {
301 readonly repository: string;
302 readonly number: number;
303 readonly commentId: string;
304 readonly resolved: boolean;
305 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
306 }
307>()("@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi") {}
308
309/** `workspace/slug`; Bitbucket has no deeper nesting to address. */
310function repositorySegments(
311 repository: string,
312): Result.Result<
313 { readonly workspace: string; readonly slug: string },
314 BitbucketRepositoryUnsupportedError
315> {
316 const segments = repository
317 .split("/")
318 .map((segment) => segment.trim())
319 .filter((segment) => segment.length > 0);
320 const [workspace, slug] = segments;
321 if (segments.length !== 2 || workspace === undefined || slug === undefined) {
322 return Result.fail(new BitbucketRepositoryUnsupportedError({ repository }));
323 }
324 return Result.succeed({ workspace, slug });
325}
326
327function repositoryPathOf(segments: { readonly workspace: string; readonly slug: string }): string {
328 return `/repositories/${encodeURIComponent(segments.workspace)}/${encodeURIComponent(
329 segments.slug,
330 )}`;
331}
332
333/**
334 * A commit sha arrives from the reader and goes straight into a request path, so it is checked
335 * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a
336 * whole sha.
337 */
338function isCommitSha(value: string): boolean {
339 return /^[0-9a-f]{7,64}$/i.test(value);
340}
341
342/**
343 * Bitbucket unions repeated `state` parameters, so a tab that spans several of its states asks
344 * for each. It separates a declined pull request from one superseded by another, and both read
345 * as closed here.
346 */
347function stateParams(state: PullRequestListState): ReadonlyArray<string> {
348 switch (state) {
349 case "open":
350 return ["OPEN"];
351 case "merged":
352 return ["MERGED"];
353 case "closed":
354 return ["DECLINED", "SUPERSEDED"];
355 case "all":
356 return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"];
357 }
358}
359
360/**
361 * Bitbucket has no search term, only a filter expression, so free text becomes one: a
362 * case-insensitive contains against the two fields a pull request carries words in. The
363 * parentheses matter, because the expression is ANDed with the state filter beside it and an
364 * unbracketed `OR` would swallow it.
365 *
366 * A string literal in that grammar is delimited by double quotes, so the reader's text is
367 * escaped before it goes inside one — a quote would otherwise end the literal and leave the
368 * rest of the text standing as filter syntax. The whole expression is then URL-encoded, so
369 * nothing in it reaches the query string as a parameter of its own.
370 */
371function searchFilter(query: string): string {
372 const literal = filterLiteral(query);
373 return `(title ~ "${literal}" OR description ~ "${literal}")`;
374}
375
376/**
377 * Text as a string literal of Bitbucket's filter grammar. The backslash is escaped first, or
378 * escaping the quote would only produce a literal backslash followed by a live quote.
379 */
380function filterLiteral(value: string): string {
381 return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
382}
383
384/** Bitbucket's merge strategies, named differently from the three the contract carries. */
385function mergeStrategy(method: PullRequestMergeMethod | undefined): string {
386 switch (method) {
387 case "squash":
388 return "squash";
389 case "rebase":
390 // The linear history GitHub calls "rebase and merge".
391 return "rebase_fast_forward";
392 default:
393 return "merge_commit";
394 }
395}
396
397function bitbucketReviewPosition(
398 position: PullRequestReviewPosition,
399): { readonly from: number } | { readonly to: number } {
400 switch (position.kind) {
401 case "added":
402 return { to: position.newLine };
403 case "deleted":
404 return { from: position.oldLine };
405 case "context":
406 return position.side === "left" ? { from: position.oldLine } : { to: position.newLine };
407 }
408}
409
410/** @public Service construction is part of the canonical Effect module API. */
411export const make = Effect.gen(function* () {
412 const bitbucket = yield* BitbucketApi.BitbucketApi;
413
414 /**
415 * The repository's own path, and the workspace above it — which the people who may review are
416 * kept on rather than on the repository, so both are handed over at once.
417 */
418 const withRepository = <A>(
419 repository: string,
420 use: (path: string, workspace: string) => Effect.Effect<A, BitbucketPullRequestApiError>,
421 ): Effect.Effect<A, BitbucketPullRequestApiError> => {
422 const segments = repositorySegments(repository);
423 return Result.isSuccess(segments)
424 ? use(repositoryPathOf(segments.success), segments.success.workspace)
425 : Effect.fail(segments.failure);
426 };
427
428 /**
429 * Bitbucket pages with a cursor rather than an offset, so the walk follows the `next` URL it
430 * sends. It stops once the caller's page is filled, when Bitbucket reports no next page, or at
431 * the page cap — and anything but running out of pages means there is more to be had.
432 */
433 const listPage = (input: {
434 readonly url: string;
435 readonly limit: number;
436 readonly page: number;
437 readonly collected: ReadonlyArray<BitbucketPullRequest>;
438 }): Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError> =>
439 bitbucket.request({ method: "GET", url: input.url }).pipe(
440 Effect.flatMap((response) => {
441 const decoded = decodePullRequestPageJson(response.body);
442 if (!Result.isSuccess(decoded)) {
443 return Effect.fail(
444 new BitbucketPullRequestReadError({
445 operation: "listPullRequests",
446 cause: decoded.failure,
447 }),
448 );
449 }
450 const collected = [...input.collected, ...decoded.success.items];
451 const next = decoded.success.next;
452 if (next === null || collected.length >= input.limit || input.page >= MAX_LIST_PAGES) {
453 return Effect.succeed({
454 items: collected.slice(0, input.limit),
455 // Bitbucket pages in fifties whatever was asked for, so a walk that stopped on the
456 // count rather than on the last page is holding rows it is about to drop. Those are
457 // more results just as surely as another page would be.
458 truncated: next !== null || collected.length > input.limit,
459 });
460 }
461 return listPage({ ...input, url: next, page: input.page + 1, collected });
462 }),
463 );
464
465 const readPage = <A>(input: {
466 readonly operation: string;
467 readonly url: string;
468 readonly decode: (body: string) => Result.Result<A, unknown>;
469 }): Effect.Effect<A, BitbucketPullRequestApiError> =>
470 bitbucket.request({ method: "GET", url: input.url }).pipe(
471 Effect.flatMap((response) => {
472 const decoded = input.decode(response.body);
473 return Result.isSuccess(decoded)
474 ? Effect.succeed(decoded.success)
475 : Effect.fail(
476 new BitbucketPullRequestReadError({
477 operation: input.operation,
478 cause: decoded.failure,
479 }),
480 );
481 }),
482 );
483
484 /**
485 * The conversation, following the `next` Bitbucket sends until it sends none. Threads are
486 * assembled once at the end rather than per page, because a reply and the remark it answers
487 * can land either side of a page boundary.
488 */
489 const commentsPage = (input: {
490 readonly url: string;
491 readonly page: number;
492 readonly comments: ReadonlyArray<PullRequestComment>;
493 readonly entries: ReadonlyArray<BitbucketRawComment>;
494 }): Effect.Effect<
495 {
496 readonly comments: ReadonlyArray<PullRequestComment>;
497 readonly threads: ReadonlyArray<PullRequestReviewThread>;
498 readonly truncated: boolean;
499 },
500 BitbucketPullRequestApiError
501 > =>
502 readPage({ operation: "listComments", url: input.url, decode: decodeCommentsJson }).pipe(
503 Effect.flatMap((page) => {
504 const comments = [...input.comments, ...page.comments];
505 const entries = [...input.entries, ...page.entries];
506 if (page.next !== null && input.page < CONVERSATION_PAGES) {
507 return commentsPage({ url: page.next, page: input.page + 1, comments, entries });
508 }
509 return Effect.succeed({
510 comments,
511 threads: buildReviewThreads(entries),
512 truncated: page.next !== null,
513 });
514 }),
515 );
516
517 /** Walks a Bitbucket cursor to its end and combines every decoded item. */
518 const itemPages = <A>(input: {
519 readonly operation: string;
520 readonly url: string;
521 readonly decode: (
522 body: string,
523 ) => Result.Result<{ readonly items: ReadonlyArray<A>; readonly next: string | null }, unknown>;
524 readonly items: ReadonlyArray<A>;
525 /** Commit pages are individually oldest-first, so older pages are prepended. */
526 readonly prepend: boolean;
527 }): Effect.Effect<ReadonlyArray<A>, BitbucketPullRequestApiError> =>
528 readPage({ operation: input.operation, url: input.url, decode: input.decode }).pipe(
529 Effect.flatMap((page) => {
530 const items = input.prepend
531 ? [...page.items, ...input.items]
532 : [...input.items, ...page.items];
533 return page.next === null
534 ? Effect.succeed(items)
535 : itemPages({ ...input, url: page.next, items });
536 }),
537 );
538
539 /** Diffstat has one aggregate per page, so its totals are folded while following `next`. */
540 const diffStatPages = (input: {
541 readonly url: string;
542 readonly totals: BitbucketDiffStat;
543 }): Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError> =>
544 readPage({ operation: "getDiffStat", url: input.url, decode: decodeDiffstatJson }).pipe(
545 Effect.flatMap((page) => {
546 const totals = {
547 additions: input.totals.additions + page.additions,
548 deletions: input.totals.deletions + page.deletions,
549 changedFiles: input.totals.changedFiles + page.changedFiles,
550 };
551 return page.next === null
552 ? Effect.succeed(totals)
553 : diffStatPages({ url: page.next, totals });
554 }),
555 );
556
557 const pullRequestDiff = (input: {
558 readonly repository: string;
559 readonly number: number;
560 readonly commit?: string | undefined;
561 }): Effect.Effect<
562 { readonly patch: string; readonly truncated: boolean },
563 BitbucketPullRequestApiError
564 > =>
565 input.commit !== undefined && !isCommitSha(input.commit)
566 ? Effect.fail(new BitbucketDiffCommitError())
567 : withRepository(input.repository, (path) =>
568 // Already a unified patch, so it needs no decoding at all, only a bound, which a
569 // diff of any size would otherwise ignore. A commit's own patch sits beside the pull
570 // request's at `/diff/{sha}` and reads the same way.
571 bitbucket
572 .request({
573 method: "GET",
574 url:
575 input.commit === undefined
576 ? `${path}/pullrequests/${input.number}/diff`
577 : `${path}/diff/${input.commit}`,
578 maxBytes: DIFF_MAX_BYTES,
579 })
580 .pipe(
581 Effect.map((response) => ({ patch: response.body, truncated: response.truncated })),
582 ),
583 );
584
585 /**
586 * What the version reads that come one tick at a time want out of the pull request's whole
587 * patch, shared between them. The parsed answer rather than the patch, which at this capacity
588 * would hold sixteen bodies of up to the byte ceiling each resident, and saves walking a patch
589 * of a hundred thousand lines again on every tick.
590 */
591 const revisionPatches = yield* Cache.makeWith(
592 (key: string) => {
593 const [repository, number] = JSON.parse(key) as [string, number];
594 return pullRequestDiff({ repository, number }).pipe(
595 Effect.map((diff) => ({
596 revisions: parseDiffFileRevisions(diff.patch),
597 truncated: diff.truncated,
598 })),
599 );
600 },
601 {
602 capacity: REVISION_PATCH_CAPACITY,
603 // A failure is not held: the tick after it should reach Bitbucket rather than be handed the
604 // same error for as long as a good patch would have lasted.
605 timeToLive: (exit) => (Exit.isSuccess(exit) ? REVISION_PATCH_TTL : Duration.zero),
606 },
607 );
608
609 return BitbucketPullRequestApi.of({
610 getViewer: () =>
611 bitbucket.request({ method: "GET", url: "/user" }).pipe(
612 Effect.flatMap((response): Effect.Effect<string, BitbucketPullRequestApiError> => {
613 const decoded = decodeViewerJson(response.body);
614 if (!Result.isSuccess(decoded)) {
615 return Effect.fail(
616 new BitbucketPullRequestReadError({ operation: "getViewer", cause: decoded.failure }),
617 );
618 }
619 return decoded.success === null
620 ? Effect.fail(new BitbucketViewerUnavailableError())
621 : Effect.succeed(decoded.success);
622 }),
623 ),
624
625 listPullRequests: (input) =>
626 withRepository(input.repository, (path) => {
627 const search = input.query?.trim() ?? "";
628 // Both narrowings share the one `q` Bitbucket takes, so they are ANDed rather than one
629 // replacing the other. The boundary instant is read inclusively — the rows already sent
630 // at it come back and the caller drops them, which is what keeps their neighbours at the
631 // same instant from being skipped. A date is a bare literal in this grammar, and this one
632 // was checked against a timestamp's shape before it got here.
633 const predicates = [
634 ...(search.length === 0 ? [] : [searchFilter(search)]),
635 ...(input.cursor === undefined ? [] : [`updated_on <= ${input.cursor.updatedBefore}`]),
636 ];
637 return listPage({
638 // Reviewers are not on a listing by default, and `viewerReviewRequested` needs them.
639 url: `${path}/pullrequests?${stateParams(input.state)
640 .map((state) => `state=${state}`)
641 .join("&")}&pagelen=${MAX_PAGE_SIZE}&sort=-updated_on&fields=%2Bvalues.reviewers${
642 predicates.length === 0 ? "" : `&q=${encodeURIComponent(predicates.join(" AND "))}`
643 }`,
644 limit: input.limit,
645 page: 1,
646 collected: [],
647 });
648 }),
649
650 getPullRequest: (input) =>
651 withRepository(input.repository, (path) =>
652 readPage({
653 operation: "getPullRequest",
654 url: `${path}/pullrequests/${input.number}`,
655 decode: decodePullRequestJson,
656 }),
657 ),
658
659 // Nothing on the repository, the pull request or the workspace states what the credentials
660 // may do, so this endpoint is the one request Bitbucket makes unavoidable. It is asked
661 // alongside the reads the detail was already making, so it costs no round trip of its own.
662 //
663 // Bitbucket permanently removed this endpoint (CHANGE-2770): every account now gets HTTP 410
664 // in place of an answer, whatever it may do. That is the deprecated-endpoint signal, not a
665 // permission being refused, so it is read the same way an unreachable read already is
666 // elsewhere — as a permission that could not be learned, which grants rather than blocks, and
667 // leaves the actual merge or write to say why if the account may not do it. Any other failure
668 // (a bad token, a network fault, an unreadable body) still fails as it did before.
669 getRepositoryPermission: (input) =>
670 withRepository(input.repository, () =>
671 readPage({
672 operation: "getRepositoryPermission",
673 url: `/user/permissions/repositories?q=${encodeURIComponent(
674 `repository.full_name="${filterLiteral(input.repository.trim())}"`,
675 )}`,
676 decode: decodeRepositoryPermissionJson,
677 }),
678 ).pipe(Effect.catchIf(isRepositoryPermissionRemovedError, () => Effect.succeed(true))),
679
680 getPullRequestDiff: pullRequestDiff,
681
682 getFileRevisions: (input) =>
683 input.paths.length === 0
684 ? Effect.succeed({ revisions: new Map(), complete: false })
685 : Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe(
686 Effect.map((diff) => {
687 const revisions = new Map(diff.revisions);
688 // A patch cut short at the byte ceiling says nothing about the files past the cut,
689 // so those paths are left out rather than reported as removed.
690 if (!diff.truncated) {
691 for (const path of input.paths) {
692 if (!revisions.has(path)) revisions.set(path, "");
693 }
694 }
695 return { revisions, complete: !diff.truncated };
696 }),
697 ),
698
699 getDiffStat: (input) =>
700 withRepository(input.repository, (path) =>
701 diffStatPages({
702 url: `${path}/pullrequests/${input.number}/diffstat?pagelen=${MAX_PAGE_SIZE}`,
703 totals: { additions: 0, deletions: 0, changedFiles: 0 },
704 }),
705 ),
706
707 getMergeability: (input) =>
708 withRepository(input.repository, (path) =>
709 readPage({
710 operation: "getMergeability",
711 url: `${path}/pullrequests/${input.number}/conflicts`,
712 decode: decodeConflictsJson,
713 }),
714 ),
715
716 listComments: (input) =>
717 withRepository(input.repository, (path) =>
718 commentsPage({
719 url: `${path}/pullrequests/${input.number}/comments?pagelen=${CONVERSATION_PAGE_SIZE}`,
720 page: 1,
721 comments: [],
722 entries: [],
723 }),
724 ),
725
726 listCommits: (input) =>
727 withRepository(input.repository, (path) =>
728 itemPages({
729 operation: "listCommits",
730 url: `${path}/pullrequests/${input.number}/commits?pagelen=${CONVERSATION_PAGE_SIZE}`,
731 decode: decodeCommitsJson,
732 items: [],
733 prepend: true,
734 }),
735 ),
736
737 listChecks: (input) =>
738 withRepository(input.repository, (path) =>
739 itemPages({
740 operation: "listChecks",
741 url: `${path}/pullrequests/${input.number}/statuses?pagelen=${CONVERSATION_PAGE_SIZE}`,
742 decode: decodeStatusesJson,
743 items: [],
744 prepend: false,
745 }),
746 ),
747
748 listReviewerCandidates: (input) =>
749 withRepository(input.repository, (path, workspace) =>
750 Effect.all(
751 [
752 readPage({
753 operation: "getPullRequest",
754 url: `${path}/pullrequests/${input.number}`,
755 decode: decodePullRequestJson,
756 }),
757 readPage({
758 operation: "listReviewerCandidates",
759 url: `/workspaces/${encodeURIComponent(workspace)}/members?pagelen=${MAX_PAGE_SIZE}`,
760 decode: decodeWorkspaceMembersJson,
761 }),
762 ],
763 { concurrency: 2 },
764 ).pipe(
765 Effect.map(([pullRequest, members]) => {
766 const requested = new Set(pullRequest.reviewerIds);
767 const author = pullRequest.author?.login;
768 return {
769 // The author is dropped rather than shown unusable: Bitbucket refuses to make the
770 // person who opened a pull request its reviewer.
771 candidates: members.items.flatMap((candidate) =>
772 candidate.login === author
773 ? []
774 : [{ ...candidate, isRequested: requested.has(candidate.id) }],
775 ),
776 truncated: members.next !== null,
777 };
778 }),
779 ),
780 ),
781
782 setReviewerRequest: (input) =>
783 withRepository(input.repository, (path) => {
784 const pullRequest = `${path}/pullrequests/${input.number}`;
785 return readPage({
786 operation: "getPullRequest",
787 url: pullRequest,
788 decode: decodePullRequestJson,
789 }).pipe(
790 Effect.flatMap((current) => {
791 // Bitbucket has no endpoint that adds or removes one reviewer: the pull request's
792 // `reviewers` is written whole, so the set that is already there is read first and
793 // the change applied to it. Everything else about the pull request is left out of
794 // the body, which leaves it as it was.
795 const uuids = new Set(current.reviewerIds);
796 for (const reviewer of input.reviewers) {
797 if (input.requested) uuids.add(reviewer.id);
798 else uuids.delete(reviewer.id);
799 }
800 return bitbucket.request({
801 method: "PUT",
802 url: pullRequest,
803 body: JSON.stringify({ reviewers: [...uuids].map((uuid) => ({ uuid })) }),
804 });
805 }),
806 Effect.asVoid,
807 );
808 }),
809
810 runAction: (input) =>
811 withRepository(input.repository, (path) => {
812 const pullRequest = `${path}/pullrequests/${input.number}`;
813 // Only merge and close reach here: the provider declares the others unsupported, so the
814 // surface never offers them.
815 if (input.action === "merge") {
816 return bitbucket
817 .request({
818 method: "POST",
819 url: `${pullRequest}/merge`,
820 body: JSON.stringify({ merge_strategy: mergeStrategy(input.mergeMethod) }),
821 })
822 .pipe(Effect.asVoid);
823 }
824 return bitbucket
825 .request({ method: "POST", url: `${pullRequest}/decline` })
826 .pipe(Effect.asVoid);
827 }),
828
829 updateChangeRequest: (input) =>
830 withRepository(input.repository, (path) =>
831 // Only the words this call rewrites travel in the body: as `setReviewerRequest` above
832 // relies on, Bitbucket's PUT is a partial update, so any field left out is left as it
833 // was — sending `reviewers` back here would overwrite a change another user made to it
834 // between this call being issued and the request landing.
835 bitbucket
836 .request({
837 method: "PUT",
838 url: `${path}/pullrequests/${input.number}`,
839 body: JSON.stringify({
840 ...(input.title === undefined ? {} : { title: input.title }),
841 ...(input.body === undefined ? {} : { description: input.body }),
842 }),
843 })
844 .pipe(Effect.asVoid),
845 ),
846
847 comment: (input) =>
848 withRepository(input.repository, (path) =>
849 bitbucket
850 .request({
851 method: "POST",
852 url: `${path}/pullrequests/${input.number}/comments`,
853 // A JSON document rather than a form field, so the body stays text whatever it says.
854 body: JSON.stringify({ content: { raw: input.body } }),
855 })
856 .pipe(Effect.asVoid),
857 ),
858
859 updateComment: (input) =>
860 withRepository(input.repository, (path) =>
861 bitbucket
862 .request({
863 // Bitbucket keeps a pull request's remarks and its line comments in the one
864 // collection, so this endpoint rewrites either kind.
865 method: "PUT",
866 url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent(
867 input.commentId,
868 )}`,
869 body: JSON.stringify({ content: { raw: input.body } }),
870 })
871 .pipe(Effect.asVoid),
872 ),
873
874 submitReview: (input) =>
875 withRepository(input.repository, (path) =>
876 Effect.gen(function* () {
877 const pullRequest = `${path}/pullrequests/${input.number}`;
878 // Bitbucket has no pending review, so a review is replayed as the requests it is
879 // made of: the line comments, then the summary, then the verdict. The verdict goes
880 // last so a review that fails part-way is never left standing as an approval.
881 yield* Effect.forEach(
882 input.comments,
883 (comment) =>
884 bitbucket.request({
885 method: "POST",
886 url: `${pullRequest}/comments`,
887 body: JSON.stringify({
888 content: { raw: comment.body },
889 inline: {
890 path: comment.path,
891 ...bitbucketReviewPosition(comment.position),
892 },
893 }),
894 }),
895 { discard: true },
896 );
897 if (input.body.trim().length > 0) {
898 yield* bitbucket.request({
899 method: "POST",
900 url: `${pullRequest}/comments`,
901 // @effect-diagnostics-next-line preferSchemaOverJson:off
902 body: JSON.stringify({ content: { raw: input.body } }),
903 });
904 }
905 if (input.verdict === "approve") {
906 yield* bitbucket.request({ method: "POST", url: `${pullRequest}/approve` });
907 }
908 if (input.verdict === "request-changes") {
909 yield* bitbucket.request({ method: "POST", url: `${pullRequest}/request-changes` });
910 }
911 }),
912 ),
913
914 replyToComment: (input) =>
915 withRepository(input.repository, (path) =>
916 bitbucket
917 .request({
918 method: "POST",
919 url: `${path}/pullrequests/${input.number}/comments`,
920 body: JSON.stringify({
921 content: { raw: input.body },
922 parent: { id: Number(input.commentId) },
923 }),
924 })
925 .pipe(Effect.asVoid),
926 ),
927
928 setCommentResolution: (input) =>
929 withRepository(input.repository, (path) =>
930 bitbucket
931 .request({
932 // Resolving is a sub-resource that is created and deleted, rather than a field.
933 method: input.resolved ? "POST" : "DELETE",
934 url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent(
935 input.commentId,
936 )}/resolve`,
937 })
938 .pipe(Effect.asVoid),
939 ),
940 });
941});
942
943export const layer = Layer.effect(BitbucketPullRequestApi, make);
packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts created+175
...@@ -0,0 +1,175 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Result from "effect/Result";
5
6import * as BitbucketApi from "./BitbucketApi.ts";
7import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
8import { decodePullRequestJson } from "./bitbucketPullRequestJson.ts";
9import {
10 bitbucketProviderFailure,
11 bitbucketViewerPermissions,
12 make,
13} from "./BitbucketPullRequestProvider.ts";
14
15for (const operation of [
16 "getMergeability",
17 "listChecks",
18 "getRepositoryPermission",
19 "listComments",
20 "listCommits",
21] as const) {
22 it.effect.each(["response", "body read"])(
23 `preserves rate limits from ${operation} on %s errors while recovering other optional-read failures`,
24 (variant) =>
25 Effect.gen(function* () {
26 const pullRequest = Result.getOrThrow(
27 decodePullRequestJson(`{
28 "id": 1, "title": "Check polling", "state": "OPEN",
29 "source": { "branch": { "name": "feature" } },
30 "destination": { "branch": { "name": "main" } },
31 "created_on": "2026-09-16T00:00:00Z",
32 "updated_on": "2026-09-16T00:00:00Z",
33 "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } }
34 }`),
35 );
36 for (const status of [429, 403]) {
37 const provider = yield* make.pipe(
38 Effect.provide(
39 Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({
40 getPullRequest: () => Effect.succeed(pullRequest),
41 getDiffStat: () => Effect.succeed({ additions: 0, deletions: 0, changedFiles: 0 }),
42 getMergeability: () => Effect.succeed("unknown" as const),
43 listChecks: () => Effect.succeed([]),
44 getRepositoryPermission: () => Effect.succeed(true),
45 listComments: () => Effect.succeed({ comments: [], threads: [], truncated: false }),
46 listCommits: () => Effect.succeed([]),
47 [operation]: () =>
48 Effect.fail(
49 variant === "response"
50 ? new BitbucketApi.BitbucketResponseError({
51 operation: "request",
52 status,
53 responseBodyLength: 0,
54 retryAt: 120_000,
55 })
56 : new BitbucketApi.BitbucketResponseBodyReadError({
57 operation: "request",
58 status,
59 cause: new Error("response stream failed"),
60 retryAt: 120_000,
61 }),
62 ),
63 }),
64 ),
65 );
66 const reference = {
67 cwd: "/repo",
68 repository: "acme/web",
69 number: 1,
70 host: "bitbucket.org",
71 };
72 const result = yield* operation === "listComments" || operation === "listCommits"
73 ? Effect.result(provider.getChangeRequestActivity(reference))
74 : Effect.result(provider.getChangeRequest(reference));
75 if (status === 429) {
76 expect(result).toMatchObject({
77 _tag: "Failure",
78 failure: { reason: "rate-limited", retryAt: 120_000 },
79 });
80 } else {
81 expect(result._tag).toBe("Success");
82 }
83 }
84 }),
85 );
86}
87
88it.effect("reads checks and PR state without diff, mergeability, or permission requests", () =>
89 Effect.gen(function* () {
90 const pullRequest = Result.getOrThrow(
91 decodePullRequestJson(`{
92 "id": 1, "title": "Checks", "state": "OPEN",
93 "source": { "branch": { "name": "feature" } },
94 "destination": { "branch": { "name": "main" } },
95 "created_on": "2026-09-16T00:00:00Z", "updated_on": "2026-09-16T00:00:00Z",
96 "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } }
97 }`),
98 );
99 let limited = false;
100 const provider = yield* make.pipe(
101 Effect.provide(
102 Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({
103 getPullRequest: () => Effect.succeed(pullRequest),
104 listChecks: () =>
105 limited
106 ? Effect.fail(
107 new BitbucketApi.BitbucketResponseError({
108 operation: "request",
109 status: 429,
110 responseBodyLength: 0,
111 retryAt: 120_000,
112 }),
113 )
114 : Effect.succeed([
115 { name: "build", status: "failure" as const, description: null, url: null },
116 ]),
117 }),
118 ),
119 );
120 const read = provider.getChangeRequestChecks;
121 if (read === undefined) return yield* Effect.die("checks read missing");
122 const input = { cwd: "/repo", repository: "acme/web", host: "bitbucket.org", number: 1 };
123 expect((yield* read(input)).checks[0]?.status).toBe("failure");
124 limited = true;
125 expect(yield* Effect.flip(read(input))).toMatchObject({
126 reason: "rate-limited",
127 retryAt: 120_000,
128 });
129 }),
130);
131
132describe("bitbucketProviderFailure", () => {
133 it("treats only an HTTP 401 as unusable credentials", () => {
134 const responseError = (status: number) =>
135 new BitbucketApi.BitbucketResponseError({
136 operation: "request",
137 status,
138 responseBodyLength: 0,
139 });
140
141 expect(bitbucketProviderFailure(responseError(401)).reason).toBe("unauthenticated");
142 expect(bitbucketProviderFailure(responseError(403)).reason).toBe("failed");
143 });
144});
145
146describe("bitbucketViewerPermissions", () => {
147 it("offers both actions to credentials with write access", () => {
148 expect(bitbucketViewerPermissions({ canWrite: true })).toEqual({
149 actions: ["merge", "close"],
150 comment: true,
151 resolve: true,
152 verdicts: ["comment", "approve", "request-changes"],
153 // Bitbucket says nothing about who may set a reviewer, and an unreported permission is
154 // granted.
155 requestReviewers: true,
156 });
157 });
158
159 it("keeps merge from credentials that can only read the repository", () => {
160 expect(bitbucketViewerPermissions({ canWrite: false })).toEqual({
161 actions: ["close"],
162 comment: true,
163 resolve: true,
164 verdicts: ["comment", "approve", "request-changes"],
165 requestReviewers: true,
166 });
167 });
168
169 it("treats an author with read access as any other reader, which is all Bitbucket says", () => {
170 // The repository permission is the whole of what Bitbucket reports per account; it says
171 // nothing about who opened this pull request, and its author may decline it with read access
172 // alone — so declining stays offered rather than being taken from them.
173 expect(bitbucketViewerPermissions({ canWrite: false }).actions).toEqual(["close"]);
174 });
175});
packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts created+385
...@@ -0,0 +1,385 @@
1import * as Effect from "effect/Effect";
2import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
3
4import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
5import {
6 PullRequestProviderError,
7 type PullRequestProviderFailure,
8 type ProviderChangeRequest,
9 type ProviderChangeRequestActivity,
10 type ProviderChangeRequestDetail,
11 type PullRequestProviderApi,
12} from "@t3tools/source-control-core/server/PullRequestProvider";
13import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts";
14
15const CAPABILITIES: PullRequestCapabilities = {
16 diff: true,
17 comment: true,
18 // Bitbucket has no endpoint that reopens a declined pull request, and nothing documented that
19 // moves one in or out of draft, so neither is offered rather than failing when pressed.
20 actions: ["merge", "close"],
21 mergeMethods: ["merge", "squash", "rebase"],
22 search: true,
23 // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is
24 // read and none is offered.
25 reactions: false,
26 review: {
27 inlineComment: true,
28 reply: true,
29 resolve: true,
30 verdicts: ["comment", "approve", "request-changes"],
31 },
32 reviewers: { request: true, listCandidates: true },
33 edit: { changeRequest: true, comment: true },
34 // Bitbucket Cloud states nothing about what a reviewer has already read: no endpoint carries a
35 // viewed file, and the per-pull-request properties it does offer are one value shared by
36 // everyone rather than one per reader. So the marks are kept here, and the client says whose
37 // they are rather than implying bitbucket.org will show them.
38 viewedFiles: "environment",
39};
40
41/**
42 * What the configured account may do here, from the one thing Bitbucket states per viewer: the
43 * repository permission. Merging needs `write` or `admin`, so that is what narrows.
44 *
45 * Declining stays offered whatever the permission. Bitbucket lets the author of a pull request
46 * decline their own with no more than read access, and the permission response says nothing about
47 * who opened this one — so withholding the control from the one person entitled to it is the
48 * worse of the two mistakes. Commenting and reviewing are not narrowed either: read access is
49 * enough to say something, to approve and to ask for changes.
50 *
51 * Asking for a review is left open for the same reason: Bitbucket takes a reviewer set from the
52 * author of a pull request as well as from whoever can write, and says nothing here about which
53 * of the two this account is.
54 */
55export function bitbucketViewerPermissions(input: {
56 readonly canWrite: boolean;
57}): PullRequestViewerPermissions {
58 return {
59 actions: CAPABILITIES.actions.filter((action) => action !== "merge" || input.canWrite),
60 comment: true,
61 resolve: true,
62 verdicts: CAPABILITIES.review.verdicts,
63 requestReviewers: true,
64 };
65}
66
67/** The failures that mean the credentials are the problem, rather than one request. */
68export function bitbucketProviderFailure(
69 error: BitbucketPullRequestApi.BitbucketPullRequestApiError,
70): PullRequestProviderFailure {
71 // Bitbucket is read over HTTP with credentials from the environment, so there is no tool to be
72 // missing: unusable always means the credentials are absent or refused.
73 if (error._tag === "BitbucketResponseError" && error.status === 401) {
74 return { reason: "unauthenticated" };
75 }
76 if (
77 (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") &&
78 error.status === 429
79 ) {
80 return {
81 reason: "rate-limited",
82 ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),
83 };
84 }
85 if (
86 (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") &&
87 error.status === 404
88 ) {
89 return { reason: "not-found" };
90 }
91 return { reason: "failed" };
92}
93
94function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeRequest {
95 return {
96 number: pullRequest.number,
97 title: pullRequest.title,
98 url: pullRequest.url,
99 author: pullRequest.author,
100 headBranch: pullRequest.headBranch,
101 ...(pullRequest.headRepositoryNameWithOwner
102 ? { headRepositoryNameWithOwner: pullRequest.headRepositoryNameWithOwner }
103 : {}),
104 baseBranch: pullRequest.baseBranch,
105 state: pullRequest.state,
106 isDraft: pullRequest.isDraft,
107 mergeability: pullRequest.mergeability,
108 // Line counts are a separate read, which only the detail is worth spending on.
109 additions: 0,
110 deletions: 0,
111 createdAt: pullRequest.createdAt,
112 updatedAt: pullRequest.updatedAt,
113 reviewRequestLogins: pullRequest.reviewRequestLogins,
114 // Bitbucket has no labels on a pull request.
115 labels: [],
116 };
117}
118
119export const make = Effect.gen(function* () {
120 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
121
122 const fail =
123 (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) =>
124 new PullRequestProviderError({
125 provider: "bitbucket",
126 operation,
127 ...bitbucketProviderFailure(error),
128 // Every Bitbucket failure states its own fact; this names the operation around it, so
129 // the two do not stack into "failed in x: failed in y: ...".
130 detail: error.detail,
131 cause: error,
132 });
133
134 const recoverRead = <A>(
135 read: Effect.Effect<A, BitbucketPullRequestApi.BitbucketPullRequestApiError>,
136 fallback: A,
137 ) => {
138 const recover = () => Effect.succeed(fallback);
139 return Effect.catchTags(read, {
140 BitbucketResponseError: (error) => (error.status === 429 ? Effect.fail(error) : recover()),
141 BitbucketUntrustedUrlError: recover,
142 BitbucketRepositoryLocatorError: recover,
143 BitbucketRequestError: recover,
144 BitbucketResponseBodyReadError: (error) =>
145 error.status === 429 ? Effect.fail(error) : recover(),
146 BitbucketResponseDecodeError: recover,
147 BitbucketRepositoryVcsResolveError: recover,
148 BitbucketRepositoryRemotesListError: recover,
149 BitbucketRepositoryRemoteNotFoundError: recover,
150 BitbucketPullRequestBodyReadError: recover,
151 BitbucketCheckoutError: recover,
152 BitbucketPullRequestReadError: recover,
153 BitbucketViewerUnavailableError: recover,
154 BitbucketRepositoryUnsupportedError: recover,
155 BitbucketDiffCommitError: recover,
156 });
157 };
158
159 const provider: PullRequestProviderApi = {
160 kind: "bitbucket",
161 capabilities: CAPABILITIES,
162
163 // Bitbucket credentials come from the server's environment rather than a checkout, so the
164 // account is the same whichever workspace asks.
165 getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))),
166
167 listChangeRequests: (input) =>
168 api
169 .listPullRequests({
170 repository: input.repository,
171 state: input.state,
172 limit: input.limit,
173 query: input.query,
174 cursor: input.cursor,
175 })
176 .pipe(
177 Effect.mapError(fail("listChangeRequests")),
178 Effect.map((batch) => ({
179 items: batch.items.map(toChangeRequest),
180 truncated: batch.truncated,
181 // Bitbucket is asked for `-updated_on` whether or not it is being carried on from,
182 // so every page it answers is one a cursor can continue.
183 continues: true,
184 })),
185 ),
186
187 getChangeRequestChecks: (input) =>
188 Effect.all([api.getPullRequest(input), api.listChecks(input)], { concurrency: 2 }).pipe(
189 Effect.map(([pullRequest, checks]) => ({ state: pullRequest.state, checks })),
190 Effect.mapError(fail("getChangeRequestChecks")),
191 ),
192
193 getChangeRequest: (input) => {
194 const target = { repository: input.repository, number: input.number };
195 return Effect.all(
196 [
197 api.getPullRequest(target),
198 api.getDiffStat(target),
199 recoverRead(api.getMergeability(target), "unknown" as const),
200 recoverRead(api.listChecks(target), []),
201 // A permission that could not be read is an unknown one, which is granted: a hidden
202 // Merge leaves someone entitled to it with no way through, and one Bitbucket refuses
203 // at least says why.
204 recoverRead(api.getRepositoryPermission(target), true),
205 ],
206 { concurrency: 5 },
207 ).pipe(
208 Effect.mapError(fail("getChangeRequest")),
209 Effect.map(
210 ([
211 pullRequest,
212 diffStat,
213 mergeability,
214 checks,
215 canWrite,
216 ]): ProviderChangeRequestDetail => ({
217 ...toChangeRequest(pullRequest),
218 mergeability,
219 additions: diffStat.additions,
220 deletions: diffStat.deletions,
221 changedFiles: diffStat.changedFiles,
222 body: pullRequest.body,
223 mergedAt: null,
224 closedAt: null,
225 reviewers: pullRequest.reviewers,
226 checks,
227 // Bitbucket publishes no per-repository list of allowed strategies, so the ones it
228 // supports are all offered and a strategy the repository forbids fails on merge.
229 mergeCapabilities: { merge: true, squash: true, rebase: true },
230 viewerPermissions: bitbucketViewerPermissions({ canWrite }),
231 }),
232 ),
233 );
234 },
235
236 getChangeRequestActivity: (input) => {
237 const target = { repository: input.repository, number: input.number };
238 return Effect.all(
239 [
240 // Reviews ride on the pull request itself, so this inexpensive core read is repeated
241 // here rather than making the core response wait for the conversation endpoints.
242 api.getPullRequest(target),
243 recoverRead(api.listComments(target), { comments: [], threads: [], truncated: true }),
244 recoverRead(api.listCommits(target), []),
245 ],
246 { concurrency: 3 },
247 ).pipe(
248 Effect.mapError(fail("getChangeRequestActivity")),
249 Effect.map(([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({
250 comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) =>
251 left.createdAt.localeCompare(right.createdAt),
252 ),
253 commentCount: comments.comments.length + pullRequest.reviews.length,
254 commentsTruncated: comments.truncated,
255 reviewThreads: comments.threads,
256 commits,
257 })),
258 );
259 },
260
261 getViewerPermissions: (input) =>
262 api.getRepositoryPermission({ repository: input.repository }).pipe(
263 Effect.mapError(fail("getViewerPermissions")),
264 Effect.map((canWrite) => bitbucketViewerPermissions({ canWrite })),
265 ),
266
267 // `/diff` answers with the whole patch and pages nothing, so the first slice is the last.
268 getDiff: (input) =>
269 api
270 .getPullRequestDiff({
271 repository: input.repository,
272 number: input.number,
273 ...(input.commit === undefined ? {} : { commit: input.commit }),
274 })
275 .pipe(
276 Effect.mapError(fail("getDiff")),
277 Effect.map((diff) => ({ ...diff, nextCursor: null })),
278 ),
279
280 getFileRevisions: (input) =>
281 api
282 .getFileRevisions({
283 repository: input.repository,
284 number: input.number,
285 paths: input.paths,
286 })
287 .pipe(Effect.mapError(fail("getFileRevisions"))),
288
289 // Users only: Bitbucket requests a review of an account, and has no group that stands in for
290 // one on a pull request.
291 listReviewerCandidates: (input) =>
292 api
293 .listReviewerCandidates({ repository: input.repository, number: input.number })
294 .pipe(Effect.mapError(fail("listReviewerCandidates"))),
295
296 setReviewerRequest: (input) =>
297 api
298 .setReviewerRequest({
299 repository: input.repository,
300 number: input.number,
301 reviewers: input.reviewers,
302 requested: input.requested,
303 })
304 .pipe(Effect.mapError(fail("setReviewerRequest"))),
305
306 runAction: (input) =>
307 api
308 .runAction({
309 repository: input.repository,
310 number: input.number,
311 action: input.action,
312 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
313 })
314 .pipe(Effect.mapError(fail("runAction"))),
315
316 updateChangeRequest: (input) =>
317 api
318 .updateChangeRequest({
319 repository: input.repository,
320 number: input.number,
321 title: input.title,
322 body: input.body,
323 })
324 .pipe(Effect.mapError(fail("updateChangeRequest"))),
325
326 comment: (input) =>
327 api
328 .comment({ repository: input.repository, number: input.number, body: input.body })
329 .pipe(Effect.mapError(fail("comment"))),
330
331 updateComment: (input) =>
332 api
333 .updateComment({
334 repository: input.repository,
335 number: input.number,
336 commentId: input.commentId,
337 body: input.body,
338 })
339 .pipe(Effect.mapError(fail("updateComment"))),
340
341 submitReview: (input) =>
342 api
343 .submitReview({
344 repository: input.repository,
345 number: input.number,
346 verdict: input.verdict,
347 body: input.body,
348 comments: input.comments,
349 })
350 .pipe(Effect.mapError(fail("submitReview"))),
351
352 replyToThread: (input) =>
353 api
354 .replyToComment({
355 repository: input.repository,
356 number: input.number,
357 commentId: input.threadId,
358 body: input.body,
359 })
360 .pipe(Effect.mapError(fail("replyToThread"))),
361
362 // Never called: `capabilities.reactions` is false, and the service refuses without it.
363 setReaction: () =>
364 Effect.fail(
365 new PullRequestProviderError({
366 provider: "bitbucket",
367 operation: "setReaction",
368 reason: "failed",
369 detail: "Bitbucket does not support reactions.",
370 }),
371 ),
372
373 setThreadResolution: (input) =>
374 api
375 .setCommentResolution({
376 repository: input.repository,
377 number: input.number,
378 commentId: input.threadId,
379 resolved: input.resolved,
380 })
381 .pipe(Effect.mapError(fail("setThreadResolution"))),
382 };
383
384 return provider;
385});
packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.test.ts created+151
...@@ -0,0 +1,151 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5
6import * as BitbucketApi from "./BitbucketApi.ts";
7import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
8
9function makeProvider(bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>) {
10 return BitbucketSourceControlProvider.make.pipe(
11 Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)),
12 );
13}
14
15it.effect("maps Bitbucket PR summaries into provider-neutral change requests", () =>
16 Effect.gen(function* () {
17 const provider = yield* makeProvider({
18 getPullRequest: () =>
19 Effect.succeed({
20 number: 42,
21 title: "Add Bitbucket provider",
22 url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
23 baseRefName: "main",
24 headRefName: "feature/source-control",
25 state: "open",
26 updatedAt: Option.none(),
27 isCrossRepository: true,
28 headRepositoryNameWithOwner: "fork/t3code",
29 headRepositoryOwnerLogin: "fork",
30 }),
31 });
32
33 const changeRequest = yield* provider.getChangeRequest({
34 cwd: "/repo",
35 reference: "42",
36 });
37
38 assert.deepStrictEqual(changeRequest, {
39 provider: "bitbucket",
40 number: 42,
41 title: "Add Bitbucket provider",
42 url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
43 baseRefName: "main",
44 headRefName: "feature/source-control",
45 state: "open",
46 updatedAt: Option.none(),
47 isCrossRepository: true,
48 headRepositoryNameWithOwner: "fork/t3code",
49 headRepositoryOwnerLogin: "fork",
50 });
51 }),
52);
53
54it.effect("adds repository context while retaining Bitbucket API causes", () =>
55 Effect.gen(function* () {
56 const upstreamCause = new Error("raw upstream failure");
57 const cause = new BitbucketApi.BitbucketRequestError({
58 operation: "getRepository",
59 cause: upstreamCause,
60 });
61 const provider = yield* makeProvider({
62 getRepositoryCloneUrls: () => Effect.fail(cause),
63 });
64
65 const error = yield* provider
66 .getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" })
67 .pipe(Effect.flip);
68
69 assert.deepStrictEqual(
70 {
71 provider: error.provider,
72 operation: error.operation,
73 command: error.command,
74 cwd: error.cwd,
75 repository: error.repository,
76 detail: error.detail,
77 },
78 {
79 provider: "bitbucket",
80 operation: "getRepositoryCloneUrls",
81 command: undefined,
82 cwd: "/repo",
83 repository: "owner/repo",
84 detail: "Failed to get repository clone URLs.",
85 },
86 );
87 assert.strictEqual(error.cause, cause);
88 assert.equal(error.message.includes(upstreamCause.message), false);
89 }),
90);
91
92it.effect("lists Bitbucket PRs through provider-neutral input names", () =>
93 Effect.gen(function* () {
94 let listInput: Parameters<BitbucketApi.BitbucketApi["Service"]["listPullRequests"]>[0] | null =
95 null;
96 const provider = yield* makeProvider({
97 listPullRequests: (input) => {
98 listInput = input;
99 return Effect.succeed([]);
100 },
101 });
102
103 yield* provider.listChangeRequests({
104 cwd: "/repo",
105 headSelector: "feature/provider",
106 state: "all",
107 limit: 10,
108 });
109
110 assert.deepStrictEqual(listInput, {
111 cwd: "/repo",
112 headSelector: "feature/provider",
113 state: "all",
114 limit: 10,
115 });
116 }),
117);
118
119it.effect("creates Bitbucket PRs through provider-neutral input names", () =>
120 Effect.gen(function* () {
121 let createInput:
122 | Parameters<BitbucketApi.BitbucketApi["Service"]["createPullRequest"]>[0]
123 | null = null;
124 const provider = yield* makeProvider({
125 createPullRequest: (input) => {
126 createInput = input;
127 return Effect.void;
128 },
129 });
130
131 yield* provider.createChangeRequest({
132 cwd: "/repo",
133 baseRefName: "main",
134 headSelector: "owner:feature/provider",
135 title: "Provider PR",
136 bodyFile: "/tmp/body.md",
137 });
138
139 assert.deepStrictEqual(createInput, {
140 cwd: "/repo",
141 baseBranch: "main",
142 headSelector: "owner:feature/provider",
143 source: {
144 owner: "owner",
145 refName: "feature/provider",
146 },
147 title: "Provider PR",
148 bodyFile: "/tmp/body.md",
149 });
150 }),
151);
packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.ts created+198
...@@ -0,0 +1,198 @@
1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
4
5import * as BitbucketApi from "./BitbucketApi.ts";
6import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts";
7import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
8import type { SourceControlApiDiscoverySpec } from "@t3tools/source-control-core/server/discovery";
9
10function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest {
11 return {
12 provider: "bitbucket",
13 number: summary.number,
14 title: summary.title,
15 url: summary.url,
16 baseRefName: summary.baseRefName,
17 headRefName: summary.headRefName,
18 state: summary.state,
19 ...(summary.isDraft === true ? { isDraft: true } : {}),
20 updatedAt: summary.updatedAt ?? Option.none(),
21 ...(summary.isCrossRepository !== undefined
22 ? { isCrossRepository: summary.isCrossRepository }
23 : {}),
24 ...(summary.headRepositoryNameWithOwner !== undefined
25 ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
26 : {}),
27 ...(summary.headRepositoryOwnerLogin !== undefined
28 ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
29 : {}),
30 };
31}
32
33export const make = Effect.gen(function* () {
34 const bitbucket = yield* BitbucketApi.BitbucketApi;
35
36 return SourceControlProvider.SourceControlProvider.of({
37 kind: "bitbucket",
38 listChangeRequests: (input) => {
39 const source = SourceControlProvider.sourceControlRefFromInput(input);
40 return bitbucket
41 .listPullRequests({
42 cwd: input.cwd,
43 ...(input.context ? { context: input.context } : {}),
44 headSelector: input.headSelector,
45 ...(source ? { source } : {}),
46 state: input.state,
47 ...(input.limit !== undefined ? { limit: input.limit } : {}),
48 })
49 .pipe(
50 Effect.map((items) => items.map(toChangeRequest)),
51 Effect.mapError(
52 (error) =>
53 new SourceControlProviderError({
54 provider: "bitbucket",
55 operation: "listChangeRequests",
56 cwd: input.cwd,
57 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
58 input.headSelector,
59 ),
60 detail: "Failed to list change requests.",
61 cause: error,
62 }),
63 ),
64 );
65 },
66 getChangeRequest: (input) =>
67 bitbucket.getPullRequest(input).pipe(
68 Effect.map(toChangeRequest),
69 Effect.mapError(
70 (error) =>
71 new SourceControlProviderError({
72 provider: "bitbucket",
73 operation: "getChangeRequest",
74 cwd: input.cwd,
75 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
76 input.reference,
77 ),
78 detail: "Failed to get change request.",
79 cause: error,
80 }),
81 ),
82 ),
83 createChangeRequest: (input) => {
84 const source = SourceControlProvider.sourceControlRefFromInput(input);
85 return bitbucket
86 .createPullRequest({
87 cwd: input.cwd,
88 ...(input.context ? { context: input.context } : {}),
89 baseBranch: input.baseRefName,
90 headSelector: input.headSelector,
91 ...(source ? { source } : {}),
92 ...(input.target ? { target: input.target } : {}),
93 title: input.title,
94 bodyFile: input.bodyFile,
95 })
96 .pipe(
97 Effect.mapError(
98 (error) =>
99 new SourceControlProviderError({
100 provider: "bitbucket",
101 operation: "createChangeRequest",
102 cwd: input.cwd,
103 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
104 input.headSelector,
105 ),
106 detail: "Failed to create change request.",
107 cause: error,
108 }),
109 ),
110 );
111 },
112 getRepositoryCloneUrls: (input) =>
113 bitbucket.getRepositoryCloneUrls(input).pipe(
114 Effect.mapError(
115 (error) =>
116 new SourceControlProviderError({
117 provider: "bitbucket",
118 operation: "getRepositoryCloneUrls",
119 cwd: input.cwd,
120 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
121 input.repository,
122 ),
123 detail: "Failed to get repository clone URLs.",
124 cause: error,
125 }),
126 ),
127 ),
128 createRepository: (input) =>
129 bitbucket.createRepository(input).pipe(
130 Effect.mapError(
131 (error) =>
132 new SourceControlProviderError({
133 provider: "bitbucket",
134 operation: "createRepository",
135 cwd: input.cwd,
136 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
137 input.repository,
138 ),
139 detail: "Failed to create repository.",
140 cause: error,
141 }),
142 ),
143 ),
144 getDefaultBranch: (input) =>
145 bitbucket
146 .getDefaultBranch({
147 cwd: input.cwd,
148 ...(input.context ? { context: input.context } : {}),
149 })
150 .pipe(
151 Effect.mapError(
152 (error) =>
153 new SourceControlProviderError({
154 provider: "bitbucket",
155 operation: "getDefaultBranch",
156 cwd: input.cwd,
157 detail: "Failed to get default branch.",
158 cause: error,
159 }),
160 ),
161 ),
162 checkoutChangeRequest: (input) =>
163 bitbucket
164 .checkoutPullRequest({
165 cwd: input.cwd,
166 ...(input.context ? { context: input.context } : {}),
167 reference: input.reference,
168 ...(input.force !== undefined ? { force: input.force } : {}),
169 })
170 .pipe(
171 Effect.mapError(
172 (error) =>
173 new SourceControlProviderError({
174 provider: "bitbucket",
175 operation: "checkoutChangeRequest",
176 cwd: input.cwd,
177 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
178 input.reference,
179 ),
180 detail: "Failed to check out change request.",
181 cause: error,
182 }),
183 ),
184 ),
185 });
186});
187
188export const makeDiscovery = Effect.gen(function* () {
189 const bitbucket = yield* BitbucketApi.BitbucketApi;
190
191 return {
192 type: "api",
193 kind: "bitbucket",
194 label: "Bitbucket",
195 installHint: "Add a Bitbucket token in Settings → Source Control.",
196 probeAuth: bitbucket.probeAuth,
197 } satisfies SourceControlApiDiscoverySpec;
198});
packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.test.ts created+377
...@@ -0,0 +1,377 @@
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: { branch: { name: "feat/page" }, repository: { full_name: "fork/web" } },
28 destination: { branch: { name: "master" } },
29 links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } },
30 ...overrides,
31 };
32}
33
34function page(values: ReadonlyArray<unknown>, extra: Record<string, unknown> = {}): string {
35 return JSON.stringify({ pagelen: 50, page: 1, size: values.length, values, ...extra });
36}
37
38function expectSuccess<A>(result: Result.Result<A, unknown>): A {
39 expect(Result.isSuccess(result)).toBe(true);
40 if (!Result.isSuccess(result)) throw new Error("expected a successful decode");
41 return result.success;
42}
43
44describe("decodePullRequestPageJson", () => {
45 it("reads a pull request as a change request", () => {
46 const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()])));
47
48 expect(decoded.items).toHaveLength(1);
49 expect(decoded.items[0]).toMatchObject({
50 number: 897,
51 title: "Add trustabl-pipe",
52 url: "https://bitbucket.org/acme/web/pull-requests/897",
53 author: { login: "bilal", name: "Bilal Hassan" },
54 headBranch: "feat/page",
55 headRepositoryNameWithOwner: "fork/web",
56 baseBranch: "master",
57 state: "open",
58 isDraft: false,
59 // Bitbucket says nothing about conflicts on the pull request itself.
60 mergeability: "unknown",
61 });
62 expect(decoded.next).toBeNull();
63 });
64
65 it("normalizes Bitbucket's offset timestamps, which the page sorts against other hosts", () => {
66 const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()])));
67
68 expect(decoded.items[0]).toMatchObject({
69 createdAt: "2026-06-16T05:04:32.258Z",
70 updatedAt: "2026-06-16T05:04:33.750Z",
71 });
72 });
73
74 it("reports the next page as the whole URL Bitbucket sends", () => {
75 const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2";
76 const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()], { next })));
77
78 expect(decoded.next).toBe(next);
79 });
80
81 it.each([
82 ["MERGED", "merged"],
83 ["DECLINED", "closed"],
84 ["SUPERSEDED", "closed"],
85 ["OPEN", "open"],
86 ["something new", "open"],
87 ])("reads the %s state as %s", (state, expected) => {
88 const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest({ state })])));
89
90 expect(decoded.items[0]?.state).toBe(expected);
91 });
92
93 it("skips a malformed row rather than failing the page", () => {
94 const decoded = expectSuccess(
95 decodePullRequestPageJson(page([{ id: "not a number" }, pullRequest()])),
96 );
97
98 expect(decoded.items).toHaveLength(1);
99 });
100
101 it("fails when Bitbucket did not answer with a page", () => {
102 expect(Result.isFailure(decodePullRequestPageJson(JSON.stringify({ error: "nope" })))).toBe(
103 true,
104 );
105 });
106});
107
108describe("decodePullRequestJson", () => {
109 it("reads reviewers as review requests", () => {
110 const decoded = expectSuccess(
111 decodePullRequestJson(
112 JSON.stringify(
113 pullRequest({
114 reviewers: [{ nickname: "julius", display_name: "Julius" }],
115 }),
116 ),
117 ),
118 );
119
120 expect(decoded.reviewRequestLogins).toEqual(["julius"]);
121 expect(decoded.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]);
122 });
123
124 it("reads a participant's vote as a review", () => {
125 const decoded = expectSuccess(
126 decodePullRequestJson(
127 JSON.stringify(
128 pullRequest({
129 participants: [
130 {
131 user: { nickname: "julius", display_name: "Julius" },
132 role: "REVIEWER",
133 approved: true,
134 state: "approved",
135 participated_on: "2026-06-17T09:00:00+00:00",
136 },
137 // Added as a reviewer but has not voted, so there is no verdict to show.
138 {
139 user: { nickname: "sam", display_name: "Sam" },
140 role: "REVIEWER",
141 approved: false,
142 state: null,
143 participated_on: null,
144 },
145 ],
146 }),
147 ),
148 ),
149 );
150
151 expect(decoded.reviews).toHaveLength(1);
152 expect(decoded.reviews[0]).toMatchObject({
153 kind: "review",
154 author: { login: "julius" },
155 reviewState: "approved",
156 createdAt: "2026-06-17T09:00:00.000Z",
157 });
158 });
159});
160
161describe("decodeViewerJson", () => {
162 it("reads the signed-in nickname", () => {
163 const decoded = decodeViewerJson(JSON.stringify({ nickname: "bilal", display_name: "Bilal" }));
164
165 expect(expectSuccess(decoded)).toBe("bilal");
166 });
167
168 it("falls back to the display name, which app accounts have instead", () => {
169 const decoded = decodeViewerJson(JSON.stringify({ display_name: "Release Bot" }));
170
171 expect(expectSuccess(decoded)).toBe("Release Bot");
172 });
173
174 it("returns nothing when the account has neither", () => {
175 expect(expectSuccess(decodeViewerJson(JSON.stringify({})))).toBeNull();
176 });
177});
178
179describe("decodeCommentsJson", () => {
180 it("keeps a posted comment and drops deleted and unposted ones", () => {
181 const decoded = expectSuccess(
182 decodeCommentsJson(
183 page([
184 {
185 id: 797230941,
186 content: { raw: "The issue is ready for review." },
187 user: { display_name: "Release Bot", type: "app_user" },
188 created_on: "2026-05-15T01:58:38.220690+00:00",
189 deleted: false,
190 pending: false,
191 links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/892#c1" } },
192 },
193 {
194 id: 2,
195 content: { raw: "gone" },
196 created_on: "2026-05-15T02:00:00+00:00",
197 deleted: true,
198 },
199 {
200 id: 3,
201 content: { raw: "wip" },
202 created_on: "2026-05-15T02:00:00+00:00",
203 pending: true,
204 },
205 { id: 4, content: { raw: " " }, created_on: "2026-05-15T02:00:00+00:00" },
206 ]),
207 ),
208 );
209
210 expect(decoded.comments).toHaveLength(1);
211 expect(decoded.comments[0]).toMatchObject({
212 id: "797230941",
213 kind: "issue-comment",
214 // An app account has no nickname, so its display name is the only handle it has.
215 author: { login: "Release Bot" },
216 createdAt: "2026-05-15T01:58:38.220Z",
217 });
218 });
219
220 it("reads a comment pinned to a file as a review comment", () => {
221 const decoded = expectSuccess(
222 decodeCommentsJson(
223 page([
224 {
225 id: 5,
226 content: { raw: "Rename this." },
227 created_on: "2026-05-15T02:00:00+00:00",
228 inline: { path: "src/app.ts" },
229 },
230 ]),
231 ),
232 );
233
234 expect(decoded.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" });
235 });
236});
237
238describe("decodeCommitsJson", () => {
239 it("returns commits oldest first with only the subject line", () => {
240 const decoded = expectSuccess(
241 decodeCommitsJson(
242 page([
243 { hash: "bbb", message: "second\n\nbody text\n", date: "2026-06-16T04:51:00+00:00" },
244 {
245 hash: "aaa",
246 message: "first\n",
247 date: "2026-06-16T04:50:49+00:00",
248 author: {
249 raw: "Ada Lovelace <ada@example.com>",
250 user: { nickname: "ada", display_name: "Ada Lovelace" },
251 },
252 },
253 ]),
254 ),
255 );
256
257 expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]);
258 expect(decoded.items[0]?.authors).toEqual([
259 { login: "ada", name: "Ada Lovelace", avatarUrl: null },
260 ]);
261 expect(decoded.items[1]?.messageHeadline).toBe("second");
262 expect(decoded.next).toBeNull();
263 });
264
265 it("skips commits whose hash is empty", () => {
266 const decoded = expectSuccess(
267 decodeCommitsJson(
268 page([
269 { hash: " ", message: "invalid", date: "2026-06-16T04:51:00+00:00" },
270 { hash: "aaa", date: "2026-06-16T04:50:49+00:00" },
271 ]),
272 ),
273 );
274
275 expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa"]);
276 });
277});
278
279describe("decodeStatusesJson", () => {
280 it("reads a build status as a check", () => {
281 const decoded = expectSuccess(
282 decodeStatusesJson(
283 page([
284 {
285 key: "custom:check-version-and-pr",
286 name: "Pipeline - custom: check-version-and-pr",
287 state: "SUCCESSFUL",
288 description: "",
289 url: "https://bitbucket.org/acme/web/pipelines/results/8126",
290 },
291 ]),
292 ),
293 );
294
295 expect(decoded).toEqual({
296 items: [
297 {
298 name: "Pipeline - custom: check-version-and-pr",
299 status: "success",
300 description: null,
301 url: "https://bitbucket.org/acme/web/pipelines/results/8126",
302 },
303 ],
304 next: null,
305 });
306 });
307
308 it.each([
309 ["SUCCESSFUL", "success"],
310 ["FAILED", "failure"],
311 ["INPROGRESS", "pending"],
312 ["STOPPED", "cancelled"],
313 ["something new", "neutral"],
314 ])("reads the %s build state as %s", (state, expected) => {
315 const decoded = expectSuccess(decodeStatusesJson(page([{ name: "Pipeline", state }])));
316
317 expect(decoded.items[0]?.status).toBe(expected);
318 });
319
320 it("keeps two statuses that share a display name but have different keys", () => {
321 const decoded = expectSuccess(
322 decodeStatusesJson(
323 page([
324 { key: "build", name: "Pipeline", state: "SUCCESSFUL" },
325 { key: "deploy", name: "Pipeline", state: "FAILED" },
326 ]),
327 ),
328 );
329
330 expect(decoded.items.map((check) => [check.name, check.status])).toEqual([
331 ["build / Pipeline", "success"],
332 ["deploy / Pipeline", "failure"],
333 ]);
334 });
335});
336
337describe("decodeDiffstatJson", () => {
338 it("adds up the per-file counts", () => {
339 const decoded = expectSuccess(
340 decodeDiffstatJson(
341 page([
342 { lines_added: 9, lines_removed: 2 },
343 { lines_added: 32, lines_removed: 14 },
344 ]),
345 ),
346 );
347
348 expect(decoded).toEqual({ additions: 41, deletions: 16, changedFiles: 2, next: null });
349 });
350});
351
352describe("decodeConflictsJson", () => {
353 it("calls an empty conflict list mergeable", () => {
354 expect(expectSuccess(decodeConflictsJson(page([])))).toBe("mergeable");
355 });
356
357 it("calls any reported conflict conflicting", () => {
358 expect(expectSuccess(decodeConflictsJson(page([{ path: "src/app.ts" }])))).toBe("conflicting");
359 });
360});
361
362describe("repository permission decoding", () => {
363 const permissionPage = (permission: string) =>
364 page([{ type: "repository_permission", permission }]);
365
366 it("counts admin and write as write, and read as not", () => {
367 expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("admin")))).toBe(true);
368 expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("write")))).toBe(true);
369 expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("read")))).toBe(false);
370 });
371
372 it("grants write where Bitbucket named no permission at all", () => {
373 // An empty page is Bitbucket declining to say, which is an unknown standing rather than a
374 // refusal — and an unknown one is granted.
375 expect(expectSuccess(decodeRepositoryPermissionJson(page([])))).toBe(true);
376 });
377});
packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.ts created+631
...@@ -0,0 +1,631 @@
1import * as Cause from "effect/Cause";
2import * as DateTime from "effect/DateTime";
3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import type {
8 PullRequestActor,
9 PullRequestCheck,
10 PullRequestCheckStatus,
11 PullRequestComment,
12 PullRequestCommit,
13 PullRequestMergeability,
14 PullRequestReviewThread,
15 PullRequestReviewerCandidate,
16 PullRequestState,
17} from "@t3tools/contracts";
18import { TrimmedNonEmptyString } from "@t3tools/contracts";
19import { decodeJsonResult } from "@t3tools/shared/schemaJson";
20
21import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
22
23/**
24 * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant
25 * style as the GitHub and GitLab decoders: a new pull request state or build status must not
26 * fail a whole payload.
27 */
28const RawUserSchema = Schema.Struct({
29 /**
30 * How Bitbucket addresses an account when a reviewer set is written; the handles it shows are
31 * not accepted there. Braced, and sent back exactly as it arrived.
32 */
33 uuid: Schema.optional(Schema.NullOr(Schema.String)),
34 /** Absent on an app account, which is why `display_name` has to stand in for it. */
35 nickname: Schema.optional(Schema.NullOr(Schema.String)),
36 display_name: Schema.optional(Schema.NullOr(Schema.String)),
37 links: Schema.optional(
38 Schema.NullOr(
39 Schema.Struct({
40 avatar: Schema.optional(
41 Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })),
42 ),
43 }),
44 ),
45 ),
46});
47
48/**
49 * Required, and required to be non-empty: the wire contract will not carry a change request
50 * without a branch or a link, so a row missing one is skipped rather than breaking the response
51 * it travels in.
52 */
53const RawBranchSchema = Schema.Struct({
54 branch: Schema.Struct({ name: TrimmedNonEmptyString }),
55 repository: Schema.optional(Schema.NullOr(Schema.Struct({ full_name: TrimmedNonEmptyString }))),
56});
57
58const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) });
59
60const RawPullRequestSchema = Schema.Struct({
61 id: Schema.Int,
62 title: Schema.String,
63 description: Schema.optional(Schema.NullOr(Schema.String)),
64 state: Schema.optional(Schema.NullOr(Schema.String)),
65 draft: Schema.optional(Schema.Boolean),
66 author: Schema.optional(Schema.NullOr(RawUserSchema)),
67 source: RawBranchSchema,
68 destination: RawBranchSchema,
69 created_on: Schema.String,
70 updated_on: Schema.String,
71 reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))),
72 participants: Schema.optional(
73 Schema.NullOr(
74 Schema.Array(
75 Schema.Struct({
76 user: Schema.optional(Schema.NullOr(RawUserSchema)),
77 role: Schema.optional(Schema.NullOr(Schema.String)),
78 approved: Schema.optional(Schema.Boolean),
79 state: Schema.optional(Schema.NullOr(Schema.String)),
80 participated_on: Schema.optional(Schema.NullOr(Schema.String)),
81 }),
82 ),
83 ),
84 ),
85 links: Schema.Struct({ html: Schema.Struct({ href: TrimmedNonEmptyString }) }),
86});
87
88const RawPageSchema = Schema.Struct({
89 values: Schema.Array(Schema.Unknown),
90 /** A total count, which Bitbucket omits on some endpoints. */
91 size: Schema.optional(Schema.NullOr(Schema.Int)),
92 /** Present only while a further page exists. */
93 next: Schema.optional(Schema.NullOr(Schema.String)),
94});
95
96const RawCommentSchema = Schema.Struct({
97 id: Schema.Int,
98 content: Schema.optional(Schema.NullOr(Schema.Struct({ raw: Schema.optional(Schema.String) }))),
99 user: Schema.optional(Schema.NullOr(RawUserSchema)),
100 created_on: Schema.String,
101 deleted: Schema.optional(Schema.Boolean),
102 /** A comment still being drafted by its author. */
103 pending: Schema.optional(Schema.Boolean),
104 /** Set on a reply, to the comment it answers — which may itself be a reply. */
105 parent: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.Int }))),
106 inline: Schema.optional(
107 Schema.NullOr(
108 Schema.Struct({
109 path: Schema.optional(Schema.NullOr(Schema.String)),
110 /** The line in the file as it was; set instead of `to` on a removed line. */
111 from: Schema.optional(Schema.NullOr(Schema.Int)),
112 /** The line in the file as it is now. */
113 to: Schema.optional(Schema.NullOr(Schema.Int)),
114 outdated: Schema.optional(Schema.NullOr(Schema.Boolean)),
115 }),
116 ),
117 ),
118 /** Non-null once someone has marked the thread resolved. */
119 resolution: Schema.optional(Schema.NullOr(Schema.Unknown)),
120 links: Schema.optional(
121 Schema.NullOr(Schema.Struct({ html: Schema.optional(Schema.NullOr(RawLinkSchema)) })),
122 ),
123});
124
125const RawCommitSchema = Schema.Struct({
126 hash: TrimmedNonEmptyString,
127 message: Schema.optional(Schema.NullOr(Schema.String)),
128 date: Schema.optional(Schema.NullOr(Schema.String)),
129 author: Schema.optional(
130 Schema.NullOr(
131 Schema.Struct({
132 raw: Schema.optional(Schema.NullOr(Schema.String)),
133 user: Schema.optional(Schema.NullOr(RawUserSchema)),
134 }),
135 ),
136 ),
137});
138
139const RawStatusSchema = Schema.Struct({
140 key: Schema.optional(Schema.NullOr(Schema.String)),
141 name: Schema.optional(Schema.NullOr(Schema.String)),
142 state: Schema.optional(Schema.NullOr(Schema.String)),
143 description: Schema.optional(Schema.NullOr(Schema.String)),
144 url: Schema.optional(Schema.NullOr(Schema.String)),
145});
146
147const RawDiffstatSchema = Schema.Struct({
148 lines_added: Schema.optional(Schema.NullOr(Schema.Int)),
149 lines_removed: Schema.optional(Schema.NullOr(Schema.Int)),
150});
151
152/** One row of `/workspaces/{workspace}/members`, which wraps the account it is about. */
153const RawMemberSchema = Schema.Struct({
154 user: Schema.optional(Schema.NullOr(RawUserSchema)),
155});
156
157const RawViewerSchema = Schema.Struct({
158 nickname: Schema.optional(Schema.NullOr(Schema.String)),
159 display_name: Schema.optional(Schema.NullOr(Schema.String)),
160});
161
162/**
163 * `/user/permissions/repositories` filtered to one repository, which is the only place Bitbucket
164 * states what the credentials may do with it: nothing on the repository, the pull request or the
165 * workspace carries it. One row, or none where Bitbucket names no permission for this account.
166 */
167const RawRepositoryPermissionsSchema = Schema.Struct({
168 values: Schema.optional(
169 Schema.NullOr(
170 Schema.Array(Schema.Struct({ permission: Schema.optional(Schema.NullOr(Schema.String)) })),
171 ),
172 ),
173});
174
175export interface BitbucketPullRequest {
176 readonly number: number;
177 readonly title: string;
178 readonly url: string;
179 readonly author: PullRequestActor | null;
180 readonly headBranch: string;
181 readonly headRepositoryNameWithOwner: string | null;
182 readonly baseBranch: string;
183 readonly state: PullRequestState;
184 readonly isDraft: boolean;
185 /**
186 * Bitbucket reports no conflict state on a pull request, so the list leaves it unknown. The
187 * detail read asks the conflicts endpoint, which does answer.
188 */
189 readonly mergeability: PullRequestMergeability;
190 readonly createdAt: string;
191 readonly updatedAt: string;
192 readonly body: string;
193 readonly reviewRequestLogins: ReadonlyArray<string>;
194 readonly reviewers: ReadonlyArray<PullRequestActor>;
195 /** The reviewers as Bitbucket addresses them, which is what writing the set back takes. */
196 readonly reviewerIds: ReadonlyArray<string>;
197 /** Approvals and change requests, which Bitbucket keeps on its participants. */
198 readonly reviews: ReadonlyArray<PullRequestComment>;
199}
200
201function trimmed(value: string | null | undefined): string | null {
202 const text = value?.trim() ?? "";
203 return text.length > 0 ? text : null;
204}
205
206/**
207 * Bitbucket stamps times as `+00:00` with microseconds. The page sorts change requests from
208 * every host against each other as plain strings, so they are normalized to the same `Z` form
209 * the other hosts already use.
210 */
211function toIsoUtc(value: string): string {
212 return Option.match(DateTime.make(value), {
213 onNone: () => value,
214 onSome: DateTime.formatIso,
215 });
216}
217
218/** An app account has no nickname, so the display name is the only handle it has. */
219function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) {
220 const login = trimmed(raw?.nickname) ?? trimmed(raw?.display_name);
221 return login === null
222 ? null
223 : {
224 login,
225 name: trimmed(raw?.display_name),
226 avatarUrl: trimmed(raw?.links?.avatar?.href),
227 };
228}
229
230function toState(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): PullRequestState {
231 switch (raw.state?.trim().toUpperCase()) {
232 case "MERGED":
233 return "merged";
234 case "DECLINED":
235 case "SUPERSEDED":
236 return "closed";
237 default:
238 return "open";
239 }
240}
241
242function toBuildStatus(value: string | null | undefined): PullRequestCheckStatus {
243 switch (value?.trim().toUpperCase()) {
244 case "SUCCESSFUL":
245 return "success";
246 case "FAILED":
247 return "failure";
248 case "STOPPED":
249 return "cancelled";
250 case "INPROGRESS":
251 return "pending";
252 default:
253 return "neutral";
254 }
255}
256
257/**
258 * A participant who has voted is the closest Bitbucket has to a review, so it reads as one in
259 * the conversation. Participants who have only been added carry no verdict and are skipped.
260 */
261function toReviews(
262 raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
263): ReadonlyArray<PullRequestComment> {
264 return (raw.participants ?? []).flatMap((participant): ReadonlyArray<PullRequestComment> => {
265 const author = toActor(participant.user);
266 const votedAt = trimmed(participant.participated_on);
267 const reviewState =
268 trimmed(participant.state) ?? (participant.approved === true ? "approved" : null);
269 if (author === null || votedAt === null || reviewState === null) return [];
270 return [
271 {
272 id: `${raw.id}:${author.login}`,
273 kind: "review",
274 author,
275 body: "",
276 createdAt: toIsoUtc(votedAt),
277 url: null,
278 path: null,
279 reviewState,
280 },
281 ];
282 });
283}
284
285function toPullRequest(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): BitbucketPullRequest {
286 const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => {
287 const actor = toActor(reviewer);
288 return actor === null ? [] : [actor];
289 });
290 return {
291 number: raw.id,
292 title: raw.title,
293 url: raw.links.html.href,
294 author: toActor(raw.author),
295 headBranch: raw.source.branch.name,
296 headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null,
297 baseBranch: raw.destination.branch.name,
298 state: toState(raw),
299 isDraft: raw.draft ?? false,
300 mergeability: "unknown",
301 createdAt: toIsoUtc(raw.created_on),
302 updatedAt: toIsoUtc(raw.updated_on),
303 body: raw.description ?? "",
304 reviewRequestLogins: reviewers.map((reviewer) => reviewer.login),
305 reviewers,
306 reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => trimmed(reviewer.uuid) ?? []),
307 reviews: toReviews(raw),
308 };
309}
310
311const decodePage = decodeJsonResult(RawPageSchema);
312const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema);
313const decodePullRequest = decodeJsonResult(RawPullRequestSchema);
314const decodeCommentEntry = Schema.decodeUnknownExit(RawCommentSchema);
315const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema);
316const decodeStatusEntry = Schema.decodeUnknownExit(RawStatusSchema);
317const decodeDiffstatEntry = Schema.decodeUnknownExit(RawDiffstatSchema);
318const decodeMemberEntry = Schema.decodeUnknownExit(RawMemberSchema);
319const decodeViewer = decodeJsonResult(RawViewerSchema);
320const decodeConflicts = decodeJsonResult(RawPageSchema);
321const decodeRepositoryPermissions = decodeJsonResult(RawRepositoryPermissionsSchema);
322
323type DecodeFailure = Cause.Cause<Schema.SchemaError>;
324
325export interface BitbucketPage<A> {
326 readonly items: ReadonlyArray<A>;
327 /** The whole URL of the next page, which Bitbucket sends rather than an offset. */
328 readonly next: string | null;
329}
330
331/** Malformed entries are skipped rather than failing the page, as on the other hosts. */
332export function decodePullRequestPageJson(
333 raw: string,
334): Result.Result<BitbucketPage<BitbucketPullRequest>, DecodeFailure> {
335 const decoded = decodePage(raw);
336 if (!Result.isSuccess(decoded)) {
337 return Result.fail(decoded.failure);
338 }
339 const items: BitbucketPullRequest[] = [];
340 for (const entry of decoded.success.values) {
341 const item = decodePullRequestEntry(entry);
342 if (Exit.isSuccess(item)) {
343 items.push(toPullRequest(item.value));
344 }
345 }
346 return Result.succeed({ items, next: trimmed(decoded.success.next) });
347}
348
349export function decodePullRequestJson(
350 raw: string,
351): Result.Result<BitbucketPullRequest, DecodeFailure> {
352 const decoded = decodePullRequest(raw);
353 return Result.isSuccess(decoded)
354 ? Result.succeed(toPullRequest(decoded.success))
355 : Result.fail(decoded.failure);
356}
357
358export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> {
359 const decoded = decodeViewer(raw);
360 return Result.isSuccess(decoded)
361 ? Result.succeed(trimmed(decoded.success.nickname) ?? trimmed(decoded.success.display_name))
362 : Result.fail(decoded.failure);
363}
364
365/**
366 * Whether the configured credentials can write to the repository, which is what merging needs.
367 * Bitbucket answers `admin`, `write` or `read`, and an empty page means it named no permission at
368 * all for this account — an unknown standing, which is granted rather than guessed away.
369 */
370export function decodeRepositoryPermissionJson(raw: string): Result.Result<boolean, DecodeFailure> {
371 const decoded = decodeRepositoryPermissions(raw);
372 if (!Result.isSuccess(decoded)) {
373 return Result.fail(decoded.failure);
374 }
375 const permission = trimmed(decoded.success.values?.[0]?.permission)?.toLowerCase() ?? null;
376 return Result.succeed(permission === null || permission === "admin" || permission === "write");
377}
378
379/**
380 * The workspace's members, which is the nearest thing Bitbucket has to "who may review this".
381 * Nothing on a repository lists the people with access to it — `permissions-config/users` is for
382 * administrators only — and a pull request can be sent to anyone in the workspace, so this is the
383 * list Bitbucket's own reviewer field is filled from too.
384 *
385 * Nobody is marked requested here: who has been asked lives on the pull request, and only the
386 * caller holds both.
387 */
388export function decodeWorkspaceMembersJson(
389 raw: string,
390): Result.Result<BitbucketPage<PullRequestReviewerCandidate>, DecodeFailure> {
391 const decoded = decodePage(raw);
392 if (!Result.isSuccess(decoded)) {
393 return Result.fail(decoded.failure);
394 }
395 const items: PullRequestReviewerCandidate[] = [];
396 for (const entry of decoded.success.values) {
397 const member = decodeMemberEntry(entry);
398 if (Exit.isFailure(member)) continue;
399 const uuid = trimmed(member.value.user?.uuid);
400 const actor = toActor(member.value.user);
401 if (uuid === null || actor === null) continue;
402 items.push({ ...actor, id: uuid, kind: "user", isRequested: false });
403 }
404 return Result.succeed({ items, next: trimmed(decoded.success.next) });
405}
406
407/** One comment as Bitbucket sent it, kept so threads can be assembled across pages. */
408export type BitbucketRawComment = Schema.Schema.Type<typeof RawCommentSchema>;
409
410export interface BitbucketComments {
411 readonly comments: ReadonlyArray<PullRequestComment>;
412 /**
413 * The same comments unread, for `buildReviewThreads`. A reply and the remark it answers can
414 * land on different pages, and only the caller holding every page can put them together.
415 */
416 readonly entries: ReadonlyArray<BitbucketRawComment>;
417 readonly next: string | null;
418}
419
420/**
421 * Bitbucket returns one flat list, so a thread is reassembled from it: a comment pinned to a
422 * line opens a thread, and every reply that leads back to it belongs in it. A reply whose
423 * parent is on a page that was not read has nowhere to go, and is left out rather than shown
424 * as a thread of its own — it still stands in the flat conversation, which needs no parent.
425 */
426export function buildReviewThreads(
427 comments: ReadonlyArray<BitbucketRawComment>,
428): ReadonlyArray<PullRequestReviewThread> {
429 const byId = new Map(comments.map((comment) => [comment.id, comment]));
430 const rootOf = (comment: Schema.Schema.Type<typeof RawCommentSchema>) => {
431 // Bounded by the number of comments read, so a parent cycle cannot spin here.
432 let current = comment;
433 for (let step = 0; step < byId.size; step += 1) {
434 const parent = current.parent === null ? undefined : byId.get(current.parent?.id ?? -1);
435 if (parent === undefined) return current;
436 current = parent;
437 }
438 return current;
439 };
440
441 const threads = new Map<number, PullRequestReviewThread>();
442 const replies = new Map<number, Array<Schema.Schema.Type<typeof RawCommentSchema>>>();
443 for (const comment of comments) {
444 const root = rootOf(comment);
445 const inline = root.inline;
446 const path = trimmed(inline?.path);
447 if (path === null) continue;
448 if (root.id === comment.id) {
449 // `to` is the line as the file stands now, `from` the line it replaced; a comment that
450 // carries only `from` was written against the removed side.
451 const side = inline?.to === null || inline?.to === undefined ? "left" : "right";
452 const line = side === "left" ? inline?.from : inline?.to;
453 threads.set(root.id, {
454 id: String(root.id),
455 path,
456 line: typeof line === "number" && line > 0 ? line : null,
457 side,
458 isResolved: root.resolution !== null && root.resolution !== undefined,
459 isOutdated: inline?.outdated === true,
460 comments: [],
461 });
462 }
463 const bucket = replies.get(root.id);
464 if (bucket === undefined) replies.set(root.id, [comment]);
465 else bucket.push(comment);
466 }
467
468 return [...threads.values()].flatMap((thread) => {
469 const entries = (replies.get(Number(thread.id)) ?? [])
470 .toSorted((left, right) => left.created_on.localeCompare(right.created_on))
471 .map((comment) => ({
472 id: String(comment.id),
473 author: toActor(comment.user),
474 body: comment.content?.raw ?? "",
475 createdAt: toIsoUtc(comment.created_on),
476 url: trimmed(comment.links?.html?.href),
477 }));
478 return entries.length === 0 ? [] : [{ ...thread, comments: entries }];
479 });
480}
481
482/**
483 * Deleted comments and ones their author has not posted yet carry nothing to show. A comment
484 * pinned to a file is a line-level review comment, which is what that kind means.
485 */
486export function decodeCommentsJson(raw: string): Result.Result<BitbucketComments, DecodeFailure> {
487 const decoded = decodePage(raw);
488 if (!Result.isSuccess(decoded)) {
489 return Result.fail(decoded.failure);
490 }
491 const comments: PullRequestComment[] = [];
492 const kept: Array<BitbucketRawComment> = [];
493 for (const entry of decoded.success.values) {
494 const decodedComment = decodeCommentEntry(entry);
495 if (Exit.isFailure(decodedComment)) continue;
496 const comment = decodedComment.value;
497 if (comment.deleted === true || comment.pending === true) continue;
498 const body = comment.content?.raw ?? "";
499 if (body.trim().length === 0) continue;
500 kept.push(comment);
501 const path = trimmed(comment.inline?.path);
502 comments.push({
503 id: String(comment.id),
504 kind: path === null ? "issue-comment" : "review-comment",
505 author: toActor(comment.user),
506 body,
507 createdAt: toIsoUtc(comment.created_on),
508 url: trimmed(comment.links?.html?.href),
509 path,
510 reviewState: null,
511 });
512 }
513 return Result.succeed({ comments, entries: kept, next: trimmed(decoded.success.next) });
514}
515
516export function decodeCommitsJson(
517 raw: string,
518): Result.Result<BitbucketPage<PullRequestCommit>, DecodeFailure> {
519 const decoded = decodePage(raw);
520 if (!Result.isSuccess(decoded)) {
521 return Result.fail(decoded.failure);
522 }
523 const commits: PullRequestCommit[] = [];
524 for (const entry of decoded.success.values) {
525 const decodedCommit = decodeCommitEntry(entry);
526 if (Exit.isFailure(decodedCommit)) continue;
527 const commit = decodedCommit.value;
528 const committedDate = trimmed(commit.date);
529 if (committedDate === null) continue;
530 const linkedAuthor = toActor(commit.author?.user);
531 const rawAuthor = trimmed(commit.author?.raw);
532 commits.push({
533 oid: commit.hash,
534 messageHeadline: (commit.message ?? "").split("\n")[0] ?? "",
535 committedDate: toIsoUtc(committedDate),
536 authors:
537 linkedAuthor !== null
538 ? [linkedAuthor]
539 : rawAuthor === null
540 ? []
541 : [{ login: rawAuthor, name: rawAuthor, avatarUrl: null }],
542 });
543 }
544 // Bitbucket lists a pull request's commits newest first; the timeline reads oldest first.
545 return Result.succeed({ items: commits.toReversed(), next: trimmed(decoded.success.next) });
546}
547
548export function decodeStatusesJson(
549 raw: string,
550): Result.Result<BitbucketPage<PullRequestCheck>, DecodeFailure> {
551 const decoded = decodePage(raw);
552 if (!Result.isSuccess(decoded)) {
553 return Result.fail(decoded.failure);
554 }
555 const checks: Array<{
556 readonly check: PullRequestCheck;
557 readonly workflowName: string | null;
558 readonly at: string | null;
559 }> = [];
560 for (const entry of decoded.success.values) {
561 const decodedStatus = decodeStatusEntry(entry);
562 if (Exit.isFailure(decodedStatus)) continue;
563 const status = decodedStatus.value;
564 const name = trimmed(status.name) ?? trimmed(status.key);
565 if (name === null) continue;
566 // Bitbucket re-uses a status key when a pipeline is run again, so the same check can appear
567 // twice on one page. Nothing decoded here says which copy is newer, so the later one wins,
568 // which is the order Bitbucket writes an update in. The key is kept as the workflow name so
569 // two different pipelines that display the same name are not folded into one.
570 checks.push({
571 check: {
572 name,
573 status: toBuildStatus(status.state),
574 description: trimmed(status.description),
575 url: trimmed(status.url),
576 },
577 workflowName: trimmed(status.key),
578 at: null,
579 });
580 }
581 return Result.succeed({ items: dedupeChecks(checks), next: trimmed(decoded.success.next) });
582}
583
584export interface BitbucketDiffStat {
585 readonly additions: number;
586 readonly deletions: number;
587 readonly changedFiles: number;
588}
589
590export interface BitbucketDiffStatPage extends BitbucketDiffStat {
591 readonly next: string | null;
592}
593
594/** One entry per changed file, each carrying that file's line counts. */
595export function decodeDiffstatJson(
596 raw: string,
597): Result.Result<BitbucketDiffStatPage, DecodeFailure> {
598 const decoded = decodePage(raw);
599 if (!Result.isSuccess(decoded)) {
600 return Result.fail(decoded.failure);
601 }
602 let additions = 0;
603 let deletions = 0;
604 let changedFiles = 0;
605 for (const entry of decoded.success.values) {
606 const decodedStat = decodeDiffstatEntry(entry);
607 if (Exit.isFailure(decodedStat)) continue;
608 additions += decodedStat.value.lines_added ?? 0;
609 deletions += decodedStat.value.lines_removed ?? 0;
610 changedFiles += 1;
611 }
612 return Result.succeed({
613 additions,
614 deletions,
615 changedFiles,
616 next: trimmed(decoded.success.next),
617 });
618}
619
620/**
621 * The conflicts endpoint answers with one entry per conflicting path, so an empty page is the
622 * only statement Bitbucket makes that a pull request merges cleanly.
623 */
624export function decodeConflictsJson(
625 raw: string,
626): Result.Result<PullRequestMergeability, DecodeFailure> {
627 const decoded = decodeConflicts(raw);
628 return Result.isSuccess(decoded)
629 ? Result.succeed(decoded.success.values.length === 0 ? "mergeable" : "conflicting")
630 : Result.fail(decoded.failure);
631}
packages/source-control-bitbucket/src/server/bitbucketPullRequests.ts created+109
...@@ -0,0 +1,109 @@
1import * as DateTime from "effect/DateTime";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
5
6export interface NormalizedBitbucketPullRequestRecord {
7 readonly number: number;
8 readonly title: string;
9 readonly url: string;
10 readonly baseRefName: string;
11 readonly headRefName: string;
12 readonly state: "open" | "closed" | "merged";
13 readonly isDraft?: boolean;
14 readonly updatedAt: Option.Option<DateTime.Utc>;
15 readonly isCrossRepository?: boolean;
16 readonly headRepositoryNameWithOwner?: string | null;
17 readonly headRepositoryOwnerLogin?: string | null;
18}
19
20export const BitbucketRepositoryRefSchema = Schema.Struct({
21 full_name: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
22 workspace: Schema.optional(
23 Schema.NullOr(
24 Schema.Struct({
25 slug: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
26 }),
27 ),
28 ),
29});
30
31export const BitbucketPullRequestBranchSchema = Schema.Struct({
32 repository: Schema.optional(Schema.NullOr(BitbucketRepositoryRefSchema)),
33 branch: Schema.Struct({
34 name: TrimmedNonEmptyString,
35 }),
36});
37
38export const BitbucketPullRequestSchema = Schema.Struct({
39 id: PositiveInt,
40 title: TrimmedNonEmptyString,
41 state: Schema.optional(Schema.NullOr(Schema.String)),
42 draft: Schema.optional(Schema.Boolean),
43 updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
44 links: Schema.Struct({
45 html: Schema.Struct({
46 href: TrimmedNonEmptyString,
47 }),
48 }),
49 source: BitbucketPullRequestBranchSchema,
50 destination: BitbucketPullRequestBranchSchema,
51});
52
53export const BitbucketPullRequestListSchema = Schema.Struct({
54 values: Schema.Array(BitbucketPullRequestSchema),
55 next: Schema.optional(TrimmedNonEmptyString),
56});
57
58function trimOptionalString(value: string | null | undefined): string | null {
59 const trimmed = value?.trim() ?? "";
60 return trimmed.length > 0 ? trimmed : null;
61}
62
63function repositoryOwner(repository: Schema.Schema.Type<typeof BitbucketRepositoryRefSchema>) {
64 return (
65 trimOptionalString(repository.workspace?.slug) ??
66 (repository.full_name?.includes("/") ? (repository.full_name.split("/")[0] ?? null) : null)
67 );
68}
69
70function normalizeBitbucketPullRequestState(state: string | null | undefined) {
71 switch (state?.trim().toUpperCase()) {
72 case "MERGED":
73 return "merged" as const;
74 case "DECLINED":
75 case "SUPERSEDED":
76 return "closed" as const;
77 case "OPEN":
78 default:
79 return "open" as const;
80 }
81}
82
83export function normalizeBitbucketPullRequestRecord(
84 raw: Schema.Schema.Type<typeof BitbucketPullRequestSchema>,
85): NormalizedBitbucketPullRequestRecord {
86 const headRepositoryNameWithOwner = trimOptionalString(raw.source.repository?.full_name);
87 const baseRepositoryNameWithOwner = trimOptionalString(raw.destination.repository?.full_name);
88 const headRepositoryOwnerLogin = raw.source.repository
89 ? repositoryOwner(raw.source.repository)
90 : null;
91 const isCrossRepository =
92 headRepositoryNameWithOwner !== null &&
93 baseRepositoryNameWithOwner !== null &&
94 headRepositoryNameWithOwner !== baseRepositoryNameWithOwner;
95
96 return {
97 number: raw.id,
98 title: raw.title,
99 url: raw.links.html.href,
100 baseRefName: raw.destination.branch.name,
101 headRefName: raw.source.branch.name,
102 state: normalizeBitbucketPullRequestState(raw.state),
103 ...(raw.draft === true ? { isDraft: true } : {}),
104 updatedAt: raw.updated_on ?? Option.none(),
105 ...(isCrossRepository ? { isCrossRepository: true } : {}),
106 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
107 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
108 };
109}
packages/source-control-bitbucket/src/server/driver.ts created+20
...@@ -0,0 +1,20 @@
1/**
2 * The Bitbucket Cloud driver: repository operations, discovery, and pull requests through the
3 * Bitbucket REST API.
4 *
5 * @module source-control-bitbucket/server/driver
6 */
7import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver";
8import * as Effect from "effect/Effect";
9
10import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts";
11import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
12
13export const driver = defineSourceControlDriver({
14 kind: "bitbucket",
15 make: Effect.all({
16 sourceControl: BitbucketSourceControlProvider.make,
17 discovery: BitbucketSourceControlProvider.makeDiscovery,
18 pullRequests: BitbucketPullRequestProvider.make,
19 }),
20});
packages/source-control-bitbucket/tsconfig.json created+7
...@@ -0,0 +1,7 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "types": ["node"]
5 },
6 "include": ["src"]
7}
packages/source-control-core/src/server/SourceControlHost.ts+51-1
...@@ -7,9 +7,20 @@...@@ -7,9 +7,20 @@
7 * its own API and CLI code. HTTP, the filesystem, and paths come from Effect's platform7 * its own API and CLI code. HTTP, the filesystem, and paths come from Effect's platform
8 * services directly.8 * services directly.
9 *9 *
10 * `git` is the subset of the server's git driver that checking out a change request needs; it
11 * grows only when a provider needs another operation.
12 *
10 * @module source-control-core/server/SourceControlHost13 * @module source-control-core/server/SourceControlHost
11 */14 */
12import type { ServerSettings, ServerSettingsError, VcsError } from "@t3tools/contracts";15import type {
16 GitCommandError,
17 ServerSettings,
18 ServerSettingsError,
19 VcsError,
20 VcsListRemotesResult,
21 VcsSwitchRefInput,
22 VcsSwitchRefResult,
23} from "@t3tools/contracts";
13import * as Context from "effect/Context";24import * as Context from "effect/Context";
14import type * as Effect from "effect/Effect";25import type * as Effect from "effect/Effect";
15import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";26import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
...@@ -43,6 +54,12 @@ export interface SourceControlProcessOutput {...@@ -43,6 +54,12 @@ export interface SourceControlProcessOutput {
43 readonly stderrInvalidUtf8?: boolean;54 readonly stderrInvalidUtf8?: boolean;
44}55}
4556
57export interface SourceControlGitRemoteBranch {
58 readonly cwd: string;
59 readonly remoteName: string;
60 readonly remoteBranch: string;
61}
62
46export class SourceControlHost extends Context.Service<63export class SourceControlHost extends Context.Service<
47 SourceControlHost,64 SourceControlHost,
48 {65 {
...@@ -55,5 +72,38 @@ export class SourceControlHost extends Context.Service<...@@ -55,5 +72,38 @@ export class SourceControlHost extends Context.Service<
55 input: SourceControlProcessInput,72 input: SourceControlProcessInput,
56 ) => Effect.Effect<SourceControlProcessOutput, VcsError>;73 ) => Effect.Effect<SourceControlProcessOutput, VcsError>;
57 };74 };
75 readonly git: {
76 /**
77 * Lists the remotes of the repository at `cwd`. The outer effect resolves the repository
78 * and fails when the server cannot drive one there; the inner one lists its remotes.
79 */
80 readonly remotes: (
81 cwd: string,
82 ) => Effect.Effect<Effect.Effect<VcsListRemotesResult, VcsError>, VcsError>;
83 readonly readConfigValue: (
84 cwd: string,
85 key: string,
86 ) => Effect.Effect<string | null, GitCommandError>;
87 readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>;
88 /** Adds a remote for `url` unless one exists, and returns the name it is under. */
89 readonly ensureRemote: (input: {
90 readonly cwd: string;
91 readonly preferredName: string;
92 readonly url: string;
93 }) => Effect.Effect<string, GitCommandError>;
94 readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>;
95 readonly fetchRemoteBranch: (
96 input: SourceControlGitRemoteBranch & { readonly localBranch: string },
97 ) => Effect.Effect<void, GitCommandError>;
98 readonly fetchRemoteTrackingBranch: (
99 input: SourceControlGitRemoteBranch,
100 ) => Effect.Effect<void, GitCommandError>;
101 readonly setBranchUpstream: (
102 input: SourceControlGitRemoteBranch & { readonly branch: string },
103 ) => Effect.Effect<void, GitCommandError>;
104 readonly switchRef: (
105 input: VcsSwitchRefInput,
106 ) => Effect.Effect<VcsSwitchRefResult, GitCommandError>;
107 };
58 }108 }
59>()("@t3tools/source-control-core/server/SourceControlHost") {}109>()("@t3tools/source-control-core/server/SourceControlHost") {}
packages/source-control-testing/src/TestSourceControlHost.ts+21-2
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1/**1/**
2 * A `SourceControlHost.SourceControlHost` for provider tests. Settings are fixed unless the test2 * A `SourceControlHost.SourceControlHost` for provider tests. Settings are fixed unless the test
3 * supplies its own, and every process run goes to the test's `run`, which fails by default so an3 * supplies its own. Process runs and git operations go to what the test supplies; anything else
4 * unexpected CLI call is visible.4 * dies, so an unexpected CLI or git call is visible.
5 *5 *
6 * @module source-control-testing/TestSourceControlHost6 * @module source-control-testing/TestSourceControlHost
7 */7 */
...@@ -14,8 +14,26 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";...@@ -14,8 +14,26 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
14export interface TestSourceControlHostOptions {14export interface TestSourceControlHostOptions {
15 readonly settings?: ServerSettings;15 readonly settings?: ServerSettings;
16 readonly process?: Partial<SourceControlHost.SourceControlHost["Service"]["process"]>;16 readonly process?: Partial<SourceControlHost.SourceControlHost["Service"]["process"]>;
17 readonly git?: Partial<SourceControlHost.SourceControlHost["Service"]["git"]>;
17}18}
1819
20type Git = SourceControlHost.SourceControlHost["Service"]["git"];
21
22const unexpectedGit = (operation: string) => () =>
23 Effect.die(`Unexpected git ${operation} in a test that supplied none.`);
24
25const failingGit: Git = {
26 remotes: unexpectedGit("remotes"),
27 readConfigValue: unexpectedGit("readConfigValue"),
28 resolvePrimaryRemoteName: unexpectedGit("resolvePrimaryRemoteName"),
29 ensureRemote: unexpectedGit("ensureRemote"),
30 listLocalBranchNames: unexpectedGit("listLocalBranchNames"),
31 fetchRemoteBranch: unexpectedGit("fetchRemoteBranch"),
32 fetchRemoteTrackingBranch: unexpectedGit("fetchRemoteTrackingBranch"),
33 setBranchUpstream: unexpectedGit("setBranchUpstream"),
34 switchRef: unexpectedGit("switchRef"),
35};
36
19export const layer = (37export const layer = (
20 options: TestSourceControlHostOptions = {},38 options: TestSourceControlHostOptions = {},
21): Layer.Layer<SourceControlHost.SourceControlHost> =>39): Layer.Layer<SourceControlHost.SourceControlHost> =>
...@@ -28,6 +46,7 @@ export const layer = (...@@ -28,6 +46,7 @@ export const layer = (
28 options.process?.run ??46 options.process?.run ??
29 ((input) => Effect.die(`Unexpected ${input.command} run in ${input.operation}.`)),47 ((input) => Effect.die(`Unexpected ${input.command} run in ${input.operation}.`)),
30 },48 },
49 git: { ...failingGit, ...options.git },
31 }),50 }),
32 );51 );
3352
pnpm-lock.yaml+31
...@@ -651,6 +651,9 @@ importers:...@@ -651,6 +651,9 @@ importers:
651 '@t3tools/source-control-azure-devops':651 '@t3tools/source-control-azure-devops':
652 specifier: workspace:*652 specifier: workspace:*
653 version: link:../../packages/source-control-azure-devops653 version: link:../../packages/source-control-azure-devops
654 '@t3tools/source-control-bitbucket':
655 specifier: workspace:*
656 version: link:../../packages/source-control-bitbucket
654 '@t3tools/source-control-core':657 '@t3tools/source-control-core':
655 specifier: workspace:*658 specifier: workspace:*
656 version: link:../../packages/source-control-core659 version: link:../../packages/source-control-core
...@@ -1556,6 +1559,34 @@ importers:...@@ -1556,6 +1559,34 @@ importers:
1556 specifier: 1.0.01559 specifier: 1.0.0
1557 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)1560 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
15581561
1562 packages/source-control-bitbucket:
1563 dependencies:
1564 '@t3tools/contracts':
1565 specifier: workspace:*
1566 version: link:../contracts
1567 '@t3tools/provider-core':
1568 specifier: workspace:*
1569 version: link:../provider-core
1570 '@t3tools/shared':
1571 specifier: workspace:*
1572 version: link:../shared
1573 '@t3tools/source-control-core':
1574 specifier: workspace:*
1575 version: link:../source-control-core
1576 effect:
1577 specifier: 4.0.2
1578 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
1579 devDependencies:
1580 '@effect/vitest':
1581 specifier: 4.0.2
1582 version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1583 '@types/node':
1584 specifier: 24.12.4
1585 version: 24.12.4
1586 vite-plus:
1587 specifier: 1.0.0
1588 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1589
1559 packages/source-control-core:1590 packages/source-control-core:
1560 dependencies:1591 dependencies:
1561 '@t3tools/contracts':1592 '@t3tools/contracts':
scripts/release-smoke.ts+1
...@@ -38,6 +38,7 @@ const workspaceFiles = [...@@ -38,6 +38,7 @@ const workspaceFiles = [
38 "packages/provider-muse/package.json",38 "packages/provider-muse/package.json",
39 "packages/provider-opencode/package.json",39 "packages/provider-opencode/package.json",
40 "packages/source-control-azure-devops/package.json",40 "packages/source-control-azure-devops/package.json",
41 "packages/source-control-bitbucket/package.json",
41 "packages/source-control-core/package.json",42 "packages/source-control-core/package.json",
42 "packages/source-control-forgejo/package.json",43 "packages/source-control-forgejo/package.json",
43 "packages/source-control-gitlab/package.json",44 "packages/source-control-gitlab/package.json",