1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { assert, it, vi } from "@effect/vitest";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";
12import * as TestClock from "effect/testing/TestClock";
13import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
14
15import { GitCommandError } from "@t3tools/contracts";
16import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
17import * as ServerSettings from "../serverSettings.ts";
18import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
19import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
20import type * as VcsDriver from "../vcs/VcsDriver.ts";
21import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
22import * as VcsProcess from "../vcs/VcsProcess.ts";
23
24const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError);
25
26const bitbucketPullRequest = {
27 id: 42,
28 title: "Add Bitbucket provider",
29 state: "OPEN",
30 updated_on: "2026-01-02T00:00:00.000Z",
31 links: {
32 html: {
33 href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
34 },
35 },
36 source: {
37 branch: { name: "feature/source-control" },
38 repository: {
39 full_name: "octocat/t3code",
40 workspace: { slug: "octocat" },
41 },
42 },
43 destination: {
44 branch: { name: "main" },
45 repository: {
46 full_name: "pingdotgg/t3code",
47 workspace: { slug: "pingdotgg" },
48 },
49 },
50};
51
52const repositoryJson = {
53 full_name: "pingdotgg/t3code",
54 links: {
55 html: { href: "https://bitbucket.org/pingdotgg/t3code" },
56 clone: [
57 { name: "https", href: "https://bitbucket.org/pingdotgg/t3code.git" },
58 { name: "ssh", href: "git@bitbucket.org:pingdotgg/t3code.git" },
59 ],
60 },
61 mainbranch: { name: "main" },
62};
63
64function makeLayer(input: {
65 readonly response: (request: HttpClientRequest.HttpClientRequest) => Response;
66 readonly requestFailure?: (
67 request: HttpClientRequest.HttpClientRequest,
68 ) => HttpClientError.HttpClientError;
69 readonly stall?: boolean;
70 readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>;
71 readonly env?: Record<string, string>;
72}) {
73 const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) =>
74 input.stall
75 ? Effect.never
76 : input.requestFailure
77 ? Effect.fail(input.requestFailure(request))
78 : Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))),
79 );
80 const gitMock = {
81 readConfigValue: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"]>(() =>
82 Effect.succeed<string | null>("git@bitbucket.org:pingdotgg/t3code.git"),
83 ),
84 resolvePrimaryRemoteName: vi.fn<
85 GitVcsDriver.GitVcsDriver["Service"]["resolvePrimaryRemoteName"]
86 >(() => Effect.succeed("origin")),
87 ensureRemote: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["ensureRemote"]>(() =>
88 Effect.succeed("octocat"),
89 ),
90 fetchRemoteBranch: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"]>(
91 () => Effect.void,
92 ),
93 fetchRemoteTrackingBranch: vi.fn<
94 GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"]
95 >(() => Effect.void),
96 setBranchUpstream: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["setBranchUpstream"]>(
97 () => Effect.void,
98 ),
99 switchRef: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["switchRef"]>((request) =>
100 Effect.succeed({ refName: request.refName }),
101 ),
102 listLocalBranchNames: vi.fn<GitVcsDriver.GitVcsDriver["Service"]["listLocalBranchNames"]>(() =>
103 Effect.succeed([]),
104 ),
105 };
106 const git = {
107 ...gitMock,
108 ...input.git,
109 } satisfies Partial<GitVcsDriver.GitVcsDriver["Service"]>;
110
111 const driver = {
112 listRemotes: vi.fn(() =>
113 Effect.succeed({
114 remotes: [
115 {
116 name: "origin",
117 url: "git@bitbucket.org:pingdotgg/t3code.git",
118 pushUrl: Option.none(),
119 isPrimary: true,
120 },
121 ],
122 freshness: {
123 source: "live-local" as const,
124 observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"),
125 expiresAt: Option.none(),
126 },
127 }),
128 ),
129 } satisfies Partial<VcsDriver.VcsDriver["Service"]>;
130
131 const resolve = vi.fn(() =>
132 Effect.succeed({
133 kind: "git" as const,
134 repository: {
135 kind: "git" as const,
136 rootPath: "/repo",
137 metadataPath: null,
138 freshness: {
139 source: "live-local" as const,
140 observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"),
141 expiresAt: Option.none(),
142 },
143 },
144 driver: driver as unknown as VcsDriver.VcsDriver["Service"],
145 }),
146 );
147
148 const layer = BitbucketApi.layer.pipe(
149 Layer.provide(ServerSourceControlHost.layer),
150 Layer.provide(Layer.mock(VcsProcess.VcsProcess)({})),
151 Layer.provide(
152 Layer.succeed(
153 HttpClient.HttpClient,
154 HttpClient.make((request) => execute(request)),
155 ),
156 ),
157 Layer.provide(
158 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
159 resolve,
160 }),
161 ),
162 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(git)),
163 Layer.provide(
164 ConfigProvider.layer(
165 ConfigProvider.fromEnv({
166 env: input.env ?? {
167 T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0",
168 T3CODE_BITBUCKET_EMAIL: "user@example.com",
169 T3CODE_BITBUCKET_API_TOKEN: "token",
170 },
171 }),
172 ),
173 ),
174 Layer.provideMerge(ServerSettings.layerTest()),
175 Layer.provideMerge(NodeServices.layer),
176 );
177
178 return { execute, git: gitMock, resolve, listRemotes: driver.listRemotes, layer };
179}
180
181it.effect("parses pull request responses from the Bitbucket REST API", () => {
182 const { execute, layer } = makeLayer({
183 response: () =>
184 Response.json({
185 ...bitbucketPullRequest,
186 }),
187 });
188
189 return Effect.gen(function* () {
190 const bitbucket = yield* BitbucketApi.BitbucketApi;
191 const result = yield* bitbucket.getPullRequest({
192 cwd: "/repo",
193 reference: "#42",
194 });
195
196 assert.deepStrictEqual(result, {
197 number: 42,
198 title: "Add Bitbucket provider",
199 url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
200 baseRefName: "main",
201 headRefName: "feature/source-control",
202 state: "open",
203 updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")),
204 isCrossRepository: true,
205 headRepositoryNameWithOwner: "octocat/t3code",
206 headRepositoryOwnerLogin: "octocat",
207 });
208 assert.strictEqual(
209 execute.mock.calls[0]?.[0].url,
210 "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests/42",
211 );
212 }).pipe(Effect.provide(layer));
213});
214
215it.effect("lists pull requests with Bitbucket state and source branch query params", () => {
216 const { execute, layer } = makeLayer({
217 response: () =>
218 Response.json({
219 values: [
220 {
221 ...bitbucketPullRequest,
222 id: 7,
223 state: "MERGED",
224 source: {
225 branch: { name: "feature/merged" },
226 repository: { full_name: "pingdotgg/t3code" },
227 },
228 },
229 ],
230 }),
231 });
232
233 return Effect.gen(function* () {
234 const bitbucket = yield* BitbucketApi.BitbucketApi;
235 const result = yield* bitbucket.listPullRequests({
236 cwd: "/repo",
237 headSelector: "origin:feature/merged",
238 state: "merged",
239 limit: 10,
240 });
241
242 assert.strictEqual(result[0]?.state, "merged");
243 const request = execute.mock.calls[0]?.[0];
244 assert.strictEqual(
245 request?.url,
246 "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests",
247 );
248 assert.deepStrictEqual(request?.urlParams.params, [
249 ["pagelen", "10"],
250 ["sort", "-updated_on"],
251 ["q", 'source.branch.name = "feature/merged" AND state = "MERGED"'],
252 ["state", "MERGED"],
253 ]);
254 }).pipe(Effect.provide(layer));
255});
256
257it.effect("lists closed pull requests with both closed Bitbucket states", () => {
258 const { execute, layer } = makeLayer({
259 response: () =>
260 Response.json({
261 values: [],
262 }),
263 });
264
265 return Effect.gen(function* () {
266 const bitbucket = yield* BitbucketApi.BitbucketApi;
267 yield* bitbucket.listPullRequests({
268 cwd: "/repo",
269 headSelector: "feature/closed",
270 state: "closed",
271 limit: 10,
272 });
273
274 assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [
275 ["pagelen", "10"],
276 ["sort", "-updated_on"],
277 [
278 "q",
279 'source.branch.name = "feature/closed" AND (state = "DECLINED" OR state = "SUPERSEDED")',
280 ],
281 ["state", "DECLINED"],
282 ["state", "SUPERSEDED"],
283 ]);
284 }).pipe(Effect.provide(layer));
285});
286
287it.effect("expands all-state pull request listing instead of relying on Bitbucket defaults", () => {
288 const { execute, layer } = makeLayer({
289 response: () =>
290 Response.json({
291 values: [],
292 }),
293 });
294
295 return Effect.gen(function* () {
296 const bitbucket = yield* BitbucketApi.BitbucketApi;
297 yield* bitbucket.listPullRequests({
298 cwd: "/repo",
299 headSelector: "feature/all",
300 state: "all",
301 limit: 10,
302 });
303
304 assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [
305 ["pagelen", "10"],
306 ["sort", "-updated_on"],
307 [
308 "q",
309 'source.branch.name = "feature/all" AND (state = "OPEN" OR state = "MERGED" OR state = "DECLINED" OR state = "SUPERSEDED")',
310 ],
311 ["state", "OPEN"],
312 ["state", "MERGED"],
313 ["state", "DECLINED"],
314 ["state", "SUPERSEDED"],
315 ]);
316 }).pipe(Effect.provide(layer));
317});
318
319it.effect("reads repository clone URLs and default branch", () => {
320 const { layer } = makeLayer({
321 response: (request) =>
322 Response.json(
323 request.url.endsWith("/branching-model")
324 ? {
325 development: {
326 branch: { name: "main" },
327 name: "main",
328 use_mainbranch: true,
329 },
330 }
331 : repositoryJson,
332 ),
333 });
334
335 return Effect.gen(function* () {
336 const bitbucket = yield* BitbucketApi.BitbucketApi;
337 const cloneUrls = yield* bitbucket.getRepositoryCloneUrls({
338 cwd: "/repo",
339 repository: "pingdotgg/t3code",
340 });
341 const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" });
342
343 assert.deepStrictEqual(cloneUrls, {
344 nameWithOwner: "pingdotgg/t3code",
345 url: "https://bitbucket.org/pingdotgg/t3code.git",
346 sshUrl: "git@bitbucket.org:pingdotgg/t3code.git",
347 });
348 assert.strictEqual(defaultBranch, "main");
349 }).pipe(Effect.provide(layer));
350});
351
352it.effect.each([false, true])(
353 "rejects invalid explicit repositories before fallback, context=%s",
354 (withContext) => {
355 const { execute, resolve, listRemotes, layer } = makeLayer({
356 response: () => Response.json(repositoryJson),
357 });
358
359 return Effect.gen(function* () {
360 const bitbucket = yield* BitbucketApi.BitbucketApi;
361 for (const repository of ["t3code", "", " \t "]) {
362 const error = yield* bitbucket
363 .getRepositoryCloneUrls({
364 cwd: "/repo",
365 repository,
366 ...(withContext
367 ? {
368 context: {
369 provider: {
370 kind: SourceControlProviderKind.make("bitbucket"),
371 name: "Bitbucket",
372 baseUrl: "https://bitbucket.org",
373 },
374 remoteName: "origin",
375 remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git",
376 },
377 }
378 : {}),
379 })
380 .pipe(Effect.match({ onFailure: (error) => error, onSuccess: () => null }));
381
382 assert.instanceOf(error, BitbucketApi.BitbucketRepositoryLocatorError);
383 assert.strictEqual(
384 isBitbucketRepositoryLocatorError(error) ? error.repository : null,
385 repository,
386 );
387 assert.strictEqual(resolve.mock.calls.length, 0);
388 assert.strictEqual(listRemotes.mock.calls.length, 0);
389 assert.strictEqual(execute.mock.calls.length, 0);
390 }
391 }).pipe(Effect.provide(layer));
392 },
393);
394
395it.effect("prefers an explicit repository and uses context when the repository is omitted", () => {
396 const { execute, resolve, listRemotes, layer } = makeLayer({
397 response: (request) =>
398 Response.json(request.url.endsWith("/branching-model") ? {} : repositoryJson),
399 });
400
401 return Effect.gen(function* () {
402 const bitbucket = yield* BitbucketApi.BitbucketApi;
403 const context = {
404 provider: {
405 kind: SourceControlProviderKind.make("bitbucket"),
406 name: "Bitbucket",
407 baseUrl: "https://bitbucket.org",
408 },
409 remoteName: "origin",
410 remoteUrl: "git@bitbucket.org:another/context.git",
411 };
412 for (const repository of ["pingdotgg/t3code", "https://bitbucket.org/pingdotgg/t3code.git"]) {
413 yield* bitbucket.getRepositoryCloneUrls({ cwd: "/repo", context, repository });
414 }
415 yield* bitbucket.getDefaultBranch({ cwd: "/repo", context });
416
417 assert.deepStrictEqual(
418 execute.mock.calls.map(([request]) => request.url).toSorted(),
419 [
420 "https://api.test.local/2.0/repositories/pingdotgg/t3code",
421 "https://api.test.local/2.0/repositories/pingdotgg/t3code",
422 "https://api.test.local/2.0/repositories/another/context",
423 "https://api.test.local/2.0/repositories/another/context/branching-model",
424 ].toSorted(),
425 );
426 assert.strictEqual(resolve.mock.calls.length, 0);
427 assert.strictEqual(listRemotes.mock.calls.length, 0);
428 }).pipe(Effect.provide(layer));
429});
430
431it.effect("discovers the repository from cwd remotes when the repository is omitted", () => {
432 const { execute, resolve, listRemotes, layer } = makeLayer({
433 response: (request) =>
434 Response.json(request.url.endsWith("/branching-model") ? {} : repositoryJson),
435 });
436
437 return Effect.gen(function* () {
438 const bitbucket = yield* BitbucketApi.BitbucketApi;
439 const branch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" });
440
441 assert.strictEqual(branch, "main");
442 assert.deepStrictEqual(resolve.mock.calls, [[{ cwd: "/repo" }]]);
443 assert.deepStrictEqual(listRemotes.mock.calls, [["/repo"]]);
444 assert.isTrue(
445 execute.mock.calls.every(([request]) =>
446 request.url.startsWith("https://api.test.local/2.0/repositories/pingdotgg/t3code"),
447 ),
448 );
449 }).pipe(Effect.provide(layer));
450});
451
452it.effect(
453 "prefers the Bitbucket branching model development branch as the default PR target",
454 () => {
455 const { execute, layer } = makeLayer({
456 response: (request) =>
457 Response.json(
458 request.url.endsWith("/branching-model")
459 ? {
460 development: {
461 branch: { name: "develop" },
462 name: "develop",
463 use_mainbranch: false,
464 },
465 }
466 : repositoryJson,
467 ),
468 });
469
470 return Effect.gen(function* () {
471 const bitbucket = yield* BitbucketApi.BitbucketApi;
472 const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" });
473
474 assert.strictEqual(defaultBranch, "develop");
475 assert.deepStrictEqual(
476 execute.mock.calls.map((call) => call[0].url).toSorted(),
477 [
478 "https://api.test.local/2.0/repositories/pingdotgg/t3code",
479 "https://api.test.local/2.0/repositories/pingdotgg/t3code/branching-model",
480 ].toSorted(),
481 );
482 }).pipe(Effect.provide(layer));
483 },
484);
485
486it.effect(
487 "falls back to the repository main branch when the Bitbucket development branch is invalid",
488 () => {
489 const { layer } = makeLayer({
490 response: (request) =>
491 Response.json(
492 request.url.endsWith("/branching-model")
493 ? {
494 development: {
495 name: "develop",
496 use_mainbranch: false,
497 is_valid: false,
498 },
499 }
500 : repositoryJson,
501 ),
502 });
503
504 return Effect.gen(function* () {
505 const bitbucket = yield* BitbucketApi.BitbucketApi;
506 const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" });
507
508 assert.strictEqual(defaultBranch, "main");
509 }).pipe(Effect.provide(layer));
510 },
511);
512
513it.effect(
514 "falls back to the repository main branch when the Bitbucket branching model is unavailable",
515 () => {
516 const { layer } = makeLayer({
517 response: (request) =>
518 request.url.endsWith("/branching-model")
519 ? Response.json({ error: { message: "Not found" } }, { status: 404 })
520 : Response.json(repositoryJson),
521 });
522
523 return Effect.gen(function* () {
524 const bitbucket = yield* BitbucketApi.BitbucketApi;
525 const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" });
526
527 assert.strictEqual(defaultBranch, "main");
528 }).pipe(Effect.provide(layer));
529 },
530);
531
532it.effect("creates repositories through the Bitbucket REST API", () => {
533 const { execute, layer } = makeLayer({
534 response: () => Response.json(repositoryJson),
535 });
536
537 return Effect.gen(function* () {
538 const bitbucket = yield* BitbucketApi.BitbucketApi;
539 const cloneUrls = yield* bitbucket.createRepository({
540 cwd: "/repo",
541 repository: "pingdotgg/t3code",
542 visibility: "private",
543 });
544
545 assert.deepStrictEqual(cloneUrls, {
546 nameWithOwner: "pingdotgg/t3code",
547 url: "https://bitbucket.org/pingdotgg/t3code.git",
548 sshUrl: "git@bitbucket.org:pingdotgg/t3code.git",
549 });
550
551 const request = execute.mock.calls[0]?.[0];
552 assert.strictEqual(request?.url, "https://api.test.local/2.0/repositories/pingdotgg/t3code");
553 assert.strictEqual(request?.method, "POST");
554 assert.ok(request);
555 const rawBody = (request.body as { readonly body?: Uint8Array }).body;
556 assert.ok(rawBody);
557 assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), {
558 scm: "git",
559 is_private: true,
560 });
561 }).pipe(Effect.provide(layer));
562});
563
564it.effect("creates pull requests using the official REST payload shape", () => {
565 const { execute, layer } = makeLayer({
566 response: () => Response.json(bitbucketPullRequest),
567 });
568
569 return Effect.gen(function* () {
570 const fileSystem = yield* FileSystem.FileSystem;
571 const bodyFile = yield* fileSystem.makeTempFileScoped({ prefix: "bitbucket-pr-body-" });
572 yield* fileSystem.writeFileString(bodyFile, "PR body");
573
574 const bitbucket = yield* BitbucketApi.BitbucketApi;
575 yield* bitbucket.createPullRequest({
576 cwd: "/repo",
577 baseBranch: "main",
578 headSelector: "owner:feature/provider",
579 title: "Provider PR",
580 bodyFile,
581 });
582
583 const request = execute.mock.calls[0]?.[0];
584 assert.strictEqual(
585 request?.url,
586 "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests",
587 );
588 assert.strictEqual(request?.method, "POST");
589 assert.ok(request);
590 const rawBody = (request.body as { readonly body?: Uint8Array }).body;
591 assert.ok(rawBody);
592 assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), {
593 title: "Provider PR",
594 description: "PR body",
595 source: {
596 branch: { name: "feature/provider" },
597 repository: { full_name: "owner/t3code" },
598 },
599 destination: {
600 branch: { name: "main" },
601 },
602 });
603 }).pipe(Effect.provide(layer), Effect.scoped);
604});
605
606it.effect("reports auth status through the Bitbucket REST /user endpoint", () => {
607 const { layer } = makeLayer({
608 response: () => Response.json({ username: "bitbucket-user" }),
609 });
610
611 return Effect.gen(function* () {
612 const bitbucket = yield* BitbucketApi.BitbucketApi;
613 const auth = yield* bitbucket.probeAuth;
614
615 assert.deepStrictEqual(auth, {
616 status: "authenticated",
617 account: Option.some("bitbucket-user"),
618 host: Option.some("bitbucket.org"),
619 detail: Option.none(),
620 });
621 }).pipe(Effect.provide(layer));
622});
623
624it.effect("prefers credentials saved in settings over the environment, without a restart", () => {
625 const { execute, layer } = makeLayer({
626 response: () => Response.json({ username: "bitbucket-user" }),
627 });
628 const lastAuthorization = () => execute.mock.calls.at(-1)?.[0].headers.authorization;
629 const basic = (user: string, password: string) => `Basic ${btoa(`${user}:${password}`)}`;
630
631 return Effect.gen(function* () {
632 const bitbucket = yield* BitbucketApi.BitbucketApi;
633 const settings = yield* ServerSettings.ServerSettingsService;
634
635 yield* bitbucket.probeAuth;
636 assert.strictEqual(lastAuthorization(), basic("user@example.com", "token"));
637
638 yield* settings.updateSettings({
639 sourceControlHosts: {
640 bitbucket: { email: "saved@example.com", apiToken: "saved-api-token" },
641 },
642 });
643 yield* bitbucket.probeAuth;
644 assert.strictEqual(lastAuthorization(), basic("saved@example.com", "saved-api-token"));
645
646 yield* settings.updateSettings({
647 sourceControlHosts: { bitbucket: { accessToken: "saved-access-token" } },
648 });
649 yield* bitbucket.probeAuth;
650 assert.strictEqual(lastAuthorization(), "Bearer saved-access-token");
651
652 yield* settings.updateSettings({
653 sourceControlHosts: { bitbucket: { accessToken: "", apiToken: "" } },
654 });
655 yield* bitbucket.probeAuth;
656 assert.strictEqual(lastAuthorization(), basic("user@example.com", "token"));
657 }).pipe(Effect.provide(layer));
658});
659
660it.effect("never puts a saved token that is unsafe for an HTTP header on the wire", () => {
661 const { execute, layer } = makeLayer({
662 response: () => Response.json({ username: "bitbucket-user" }),
663 });
664
665 return Effect.gen(function* () {
666 const bitbucket = yield* BitbucketApi.BitbucketApi;
667 const settings = yield* ServerSettings.ServerSettingsService;
668
669 // Fetch would reject this header with an error quoting the token, and that error reaches
670 // clients. The unusable token is ignored, so the environment credential is used instead.
671 yield* settings.updateSettings({
672 sourceControlHosts: { bitbucket: { accessToken: "saved\ntoken" } },
673 });
674 yield* bitbucket.probeAuth;
675 assert.strictEqual(
676 execute.mock.calls.at(-1)?.[0].headers.authorization,
677 `Basic ${btoa("user@example.com:token")}`,
678 );
679 }).pipe(Effect.provide(layer));
680});
681
682it.effect("reports saved credentials as configured when Bitbucket cannot confirm them", () => {
683 const { layer } = makeLayer({
684 response: () => new Response(null, { status: 401 }),
685 env: { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0" },
686 });
687
688 return Effect.gen(function* () {
689 const bitbucket = yield* BitbucketApi.BitbucketApi;
690 const settings = yield* ServerSettings.ServerSettingsService;
691
692 assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated");
693
694 yield* settings.updateSettings({
695 sourceControlHosts: { bitbucket: { accessToken: "saved-access-token" } },
696 });
697 assert.deepStrictEqual(yield* bitbucket.probeAuth, {
698 status: "unknown",
699 account: Option.none(),
700 host: Option.some("bitbucket.org"),
701 detail: Option.some("An access token is configured."),
702 });
703 }).pipe(Effect.provide(layer));
704});
705
706it.effect("preserves the HTTP client failure without deriving the domain message from it", () => {
707 const transportCause = new Error("socket reset by peer");
708 let requestFailure: HttpClientError.HttpClientError | undefined;
709 const { layer } = makeLayer({
710 response: () => Response.json({}),
711 requestFailure: (request) => {
712 requestFailure = new HttpClientError.HttpClientError({
713 reason: new HttpClientError.TransportError({
714 request,
715 cause: transportCause,
716 }),
717 });
718 return requestFailure;
719 },
720 });
721
722 return Effect.gen(function* () {
723 const bitbucket = yield* BitbucketApi.BitbucketApi;
724 const error = yield* Effect.flip(
725 bitbucket.getPullRequest({
726 cwd: "/repo",
727 reference: "42",
728 }),
729 );
730
731 assert.instanceOf(error, BitbucketApi.BitbucketRequestError);
732 assert.strictEqual(error.operation, "getPullRequest");
733 assert.strictEqual(
734 error.message,
735 "Bitbucket API failed in getPullRequest: Failed to send the Bitbucket request.",
736 );
737 assert.strictEqual(error.cause, requestFailure);
738 assert.strictEqual(requestFailure?.cause, transportCause);
739 }).pipe(Effect.provide(layer));
740});
741
742it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => {
743 const responseBody = '{"error":{"message":"credential=secret-value"}}';
744 const { layer } = makeLayer({
745 response: () => new Response(responseBody, { status: 403 }),
746 });
747
748 return Effect.gen(function* () {
749 const bitbucket = yield* BitbucketApi.BitbucketApi;
750 const error = yield* bitbucket
751 .checkoutPullRequest({ cwd: "/repo", reference: "42" })
752 .pipe(Effect.flip);
753
754 assert.instanceOf(error, BitbucketApi.BitbucketResponseError);
755 assert.strictEqual(error.operation, "getPullRequest");
756 assert.strictEqual(error.status, 403);
757 assert.strictEqual(error.responseBodyLength, responseBody.length);
758 assert.notProperty(error, "responseBody");
759 assert.strictEqual(
760 error.message,
761 "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 403.",
762 );
763 assert.notInclude(error.message, "secret-value");
764 }).pipe(Effect.provide(layer));
765});
766
767it.effect("keeps a 429 Retry-After time on the response error", () => {
768 const { layer } = makeLayer({
769 response: () => new Response("busy", { status: 429, headers: { "Retry-After": "120" } }),
770 });
771
772 return Effect.gen(function* () {
773 yield* TestClock.setTime(1_000);
774 const bitbucket = yield* BitbucketApi.BitbucketApi;
775 const error = yield* bitbucket
776 .request({ method: "GET", url: "/repositories/acme/web" })
777 .pipe(Effect.flip);
778
779 assert.instanceOf(error, BitbucketApi.BitbucketResponseError);
780 assert.strictEqual(error.status, 429);
781 assert.strictEqual(error.retryAt, 121_000);
782 }).pipe(Effect.provide(layer));
783});
784
785it.effect("preserves Bitbucket response body read failures as their immediate cause", () => {
786 const cause = new Error("response stream failed");
787 const { layer } = makeLayer({
788 response: () =>
789 new Response(
790 new ReadableStream<Uint8Array>({
791 start: (controller) => controller.error(cause),
792 }),
793 { status: 502 },
794 ),
795 });
796
797 return Effect.gen(function* () {
798 const bitbucket = yield* BitbucketApi.BitbucketApi;
799 const error = yield* bitbucket
800 .getPullRequest({ cwd: "/repo", reference: "42" })
801 .pipe(Effect.flip);
802
803 assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError);
804 assert.strictEqual(error.operation, "getPullRequest");
805 assert.strictEqual(error.status, 502);
806 assert.instanceOf(error.cause, HttpClientError.HttpClientError);
807 assert.strictEqual(error.cause.cause, cause);
808 assert.strictEqual(
809 error.message,
810 "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 502.",
811 );
812 }).pipe(Effect.provide(layer));
813});
814
815it.effect("keeps the 429 retry time when the response body cannot be read", () => {
816 const { layer } = makeLayer({
817 response: () =>
818 new Response(
819 new ReadableStream<Uint8Array>({
820 start: (controller) => controller.error(new Error("response stream failed")),
821 }),
822 { status: 429, headers: { "Retry-After": "120" } },
823 ),
824 });
825
826 return Effect.gen(function* () {
827 yield* TestClock.setTime(1_000);
828 const bitbucket = yield* BitbucketApi.BitbucketApi;
829 const error = yield* bitbucket
830 .request({ method: "GET", url: "/repositories/acme/web" })
831 .pipe(Effect.flip);
832
833 assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError);
834 assert.strictEqual(error.status, 429);
835 assert.strictEqual(error.retryAt, 121_000);
836 }).pipe(Effect.provide(layer));
837});
838
839it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => {
840 const { git, layer } = makeLayer({
841 response: () =>
842 Response.json({
843 ...bitbucketPullRequest,
844 source: {
845 branch: { name: "feature/source-control" },
846 repository: {
847 full_name: "pingdotgg/t3code",
848 workspace: { slug: "pingdotgg" },
849 },
850 },
851 }),
852 });
853
854 return Effect.gen(function* () {
855 const bitbucket = yield* BitbucketApi.BitbucketApi;
856 yield* bitbucket.checkoutPullRequest({
857 cwd: "/repo",
858 context: {
859 provider: {
860 kind: SourceControlProviderKind.make("bitbucket"),
861 name: "Bitbucket",
862 baseUrl: "https://bitbucket.org",
863 },
864 remoteName: "origin",
865 remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git",
866 },
867 reference: "42",
868 force: true,
869 });
870
871 assert.strictEqual(git.ensureRemote.mock.calls.length, 0);
872 assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], {
873 cwd: "/repo",
874 remoteName: "origin",
875 remoteBranch: "feature/source-control",
876 localBranch: "feature/source-control",
877 });
878 assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], {
879 cwd: "/repo",
880 branch: "feature/source-control",
881 remoteName: "origin",
882 remoteBranch: "feature/source-control",
883 });
884 assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], {
885 cwd: "/repo",
886 refName: "feature/source-control",
887 });
888 }).pipe(Effect.provide(layer));
889});
890
891it.effect("preserves Git checkout failures without deriving the domain message from them", () => {
892 const gitCause = new GitCommandError({
893 operation: "fetchRemoteBranch",
894 command: "git fetch origin feature/source-control",
895 cwd: "/repo",
896 detail: "remote rejected the request",
897 });
898 const { layer } = makeLayer({
899 response: () =>
900 Response.json({
901 ...bitbucketPullRequest,
902 source: {
903 branch: { name: "feature/source-control" },
904 repository: {
905 full_name: "pingdotgg/t3code",
906 workspace: { slug: "pingdotgg" },
907 },
908 },
909 }),
910 git: {
911 fetchRemoteBranch: () => Effect.fail(gitCause),
912 },
913 });
914
915 return Effect.gen(function* () {
916 const bitbucket = yield* BitbucketApi.BitbucketApi;
917 const error = yield* Effect.flip(
918 bitbucket.checkoutPullRequest({
919 cwd: "/repo",
920 reference: "42",
921 force: true,
922 }),
923 );
924
925 assert.instanceOf(error, BitbucketApi.BitbucketCheckoutError);
926 assert.strictEqual(error.cwd, "/repo");
927 assert.strictEqual(error.reference, "42");
928 assert.strictEqual(
929 error.message,
930 "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request.",
931 );
932 assert.strictEqual(error.cause, gitCause);
933 }).pipe(Effect.provide(layer));
934});
935
936it.effect("checks out fork pull requests through an ensured fork remote", () => {
937 const { git, layer } = makeLayer({
938 response: (request) => {
939 if (request.url.endsWith("/repositories/octocat/t3code")) {
940 return Response.json({
941 ...repositoryJson,
942 full_name: "octocat/t3code",
943 links: {
944 html: { href: "https://bitbucket.org/octocat/t3code" },
945 clone: [
946 { name: "https", href: "https://bitbucket.org/octocat/t3code.git" },
947 { name: "ssh", href: "git@bitbucket.org:octocat/t3code.git" },
948 ],
949 },
950 });
951 }
952 return Response.json({
953 ...bitbucketPullRequest,
954 source: {
955 branch: { name: "main" },
956 repository: {
957 full_name: "octocat/t3code",
958 workspace: { slug: "octocat" },
959 },
960 },
961 });
962 },
963 });
964
965 return Effect.gen(function* () {
966 const bitbucket = yield* BitbucketApi.BitbucketApi;
967 yield* bitbucket.checkoutPullRequest({
968 cwd: "/repo",
969 reference: "42",
970 force: true,
971 });
972
973 assert.deepStrictEqual(git.ensureRemote.mock.calls[0]?.[0], {
974 cwd: "/repo",
975 preferredName: "octocat",
976 url: "git@bitbucket.org:octocat/t3code.git",
977 });
978 assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], {
979 cwd: "/repo",
980 remoteName: "octocat",
981 remoteBranch: "main",
982 localBranch: "t3code/pr-42/main",
983 });
984 assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], {
985 cwd: "/repo",
986 branch: "t3code/pr-42/main",
987 remoteName: "octocat",
988 remoteBranch: "main",
989 });
990 assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], {
991 cwd: "/repo",
992 refName: "t3code/pr-42/main",
993 });
994 }).pipe(Effect.provide(layer));
995});
996
997it.effect("refuses a url that points away from the configured Bitbucket", () => {
998 // A whole url reaches `request` from inside a response — a pagination cursor, say — so
999 // following one off-host would hand the account's credentials to whoever wrote it.
1000 const { layer, execute } = makeLayer({ response: () => new Response("{}", { status: 200 }) });
1001 return Effect.gen(function* () {
1002 const bitbucket = yield* BitbucketApi.BitbucketApi;
1003
1004 const error = yield* Effect.flip(
1005 bitbucket.request({ method: "GET", url: "https://attacker.example/2.0/repositories" }),
1006 );
1007
1008 assert.strictEqual(error._tag, "BitbucketUntrustedUrlError");
1009 // Nothing was sent at all, so no header travelled anywhere.
1010 assert.strictEqual(execute.mock.calls.length, 0);
1011 }).pipe(Effect.provide(layer));
1012});
1013
1014it.effect("keeps only the host of a url it refuses, never its query", () =>
1015 Effect.gen(function* () {
1016 const bitbucket = yield* BitbucketApi.BitbucketApi;
1017
1018 const error = yield* Effect.flip(
1019 bitbucket.request({
1020 method: "GET",
1021 // A signed link, whose query is the credential.
1022 url: "https://attacker.example/asset?signature=secret-token",
1023 }),
1024 );
1025
1026 assert.strictEqual(error._tag, "BitbucketUntrustedUrlError");
1027 assert.strictEqual(
1028 error._tag === "BitbucketUntrustedUrlError" ? error.host : "",
1029 "https://attacker.example",
1030 );
1031 assert.notInclude(error.message, "secret-token");
1032 }).pipe(Effect.provide(makeLayer({ response: () => new Response("{}", { status: 200 }) }).layer)),
1033);
1034
1035it.effect("does not follow a redirect off the configured Bitbucket", () =>
1036 Effect.gen(function* () {
1037 const bitbucket = yield* BitbucketApi.BitbucketApi;
1038
1039 const error = yield* Effect.flip(
1040 bitbucket.request({ method: "GET", url: "/repositories/acme/web/pullrequests/1/diff" }),
1041 );
1042
1043 // The client would carry every header to the new host, so the hop is checked here instead.
1044 assert.strictEqual(error._tag, "BitbucketUntrustedUrlError");
1045 }).pipe(
1046 Effect.provide(
1047 makeLayer({
1048 response: () =>
1049 new Response(null, {
1050 status: 302,
1051 headers: { location: "https://attacker.example/stolen" },
1052 }),
1053 }).layer,
1054 ),
1055 ),
1056);
1057
1058it.effect("follows a redirect that stays on the configured Bitbucket", () =>
1059 Effect.gen(function* () {
1060 const bitbucket = yield* BitbucketApi.BitbucketApi;
1061
1062 const result = yield* bitbucket.request({
1063 method: "GET",
1064 url: "/repositories/acme/web/pullrequests/1/diff",
1065 });
1066
1067 // Bitbucket serves a diff as a redirect to a commit range, so the hop has to be followed.
1068 assert.strictEqual(result.body, "diff --git a/a.ts b/a.ts");
1069 assert.isFalse(result.truncated);
1070 }).pipe(
1071 Effect.provide(
1072 makeLayer({
1073 response: (request) =>
1074 request.url.endsWith("/pullrequests/1/diff")
1075 ? new Response(null, {
1076 status: 302,
1077 // The same host the harness configures, which is not bitbucket.org: a
1078 // self-hosted base url has to be trusted on its own terms.
1079 headers: { location: "https://api.test.local/2.0/repositories/acme/web/diff/abc" },
1080 })
1081 : new Response("diff --git a/a.ts b/a.ts", { status: 200 }),
1082 }).layer,
1083 ),
1084 ),
1085);
1086
1087it.effect("cuts a response short rather than reading an unbounded diff into memory", () =>
1088 Effect.gen(function* () {
1089 const bitbucket = yield* BitbucketApi.BitbucketApi;
1090
1091 const result = yield* bitbucket.request({
1092 method: "GET",
1093 url: "/repositories/acme/web/pullrequests/1/diff",
1094 maxBytes: 8,
1095 });
1096
1097 assert.strictEqual(result.body, "12345678");
1098 assert.isTrue(result.truncated);
1099 // Bounded as the body arrives, so an oversized diff is never held whole.
1100 }).pipe(
1101 Effect.provide(
1102 makeLayer({ response: () => new Response("1234567890", { status: 200 }) }).layer,
1103 ),
1104 ),
1105);
1106
1107const stalledResponseBody = (status: number): Response =>
1108 new Response(
1109 new ReadableStream<Uint8Array>({
1110 start(controller) {
1111 controller.enqueue(new TextEncoder().encode("{"));
1112 // Deliberately never close: headers arrive before the body stalls.
1113 },
1114 }),
1115 { status, headers: { "content-type": "application/json" } },
1116 );
1117
1118it.effect.each([200, 503])(
1119 "times out a stalled Bitbucket JSON body after HTTP %s headers",
1120 (status) => {
1121 const { execute, layer } = makeLayer({ response: () => stalledResponseBody(status) });
1122 return Effect.gen(function* () {
1123 const api = yield* BitbucketApi.BitbucketApi;
1124 const fiber = yield* Effect.forkChild(
1125 Effect.flip(api.getPullRequest({ cwd: "/repo", reference: "42" })),
1126 );
1127 while (execute.mock.calls.length === 0) yield* Effect.yieldNow;
1128 yield* TestClock.adjust("31 seconds");
1129 const error = yield* Fiber.join(fiber);
1130 assert.instanceOf(error, BitbucketApi.BitbucketRequestError);
1131 assert.strictEqual(error.operation, "getPullRequest");
1132 }).pipe(Effect.provide(layer));
1133 },
1134);
1135
1136it.effect.each([200, 503])(
1137 "times out a stalled Bitbucket raw body after HTTP %s headers",
1138 (status) => {
1139 const { execute, layer } = makeLayer({ response: () => stalledResponseBody(status) });
1140 return Effect.gen(function* () {
1141 const api = yield* BitbucketApi.BitbucketApi;
1142 const fiber = yield* Effect.forkChild(
1143 Effect.flip(api.request({ method: "GET", url: "/user" })),
1144 );
1145 while (execute.mock.calls.length === 0) yield* Effect.yieldNow;
1146 yield* TestClock.adjust("31 seconds");
1147 const error = yield* Fiber.join(fiber);
1148 assert.instanceOf(error, BitbucketApi.BitbucketRequestError);
1149 assert.strictEqual(error.operation, "request");
1150 }).pipe(Effect.provide(layer));
1151 },
1152);
1153
1154it.effect("times out before JSON headers arrive", () => {
1155 const { execute, layer } = makeLayer({ response: () => Response.json({}), stall: true });
1156 return Effect.gen(function* () {
1157 const api = yield* BitbucketApi.BitbucketApi;
1158 const fiber = yield* Effect.forkChild(
1159 Effect.flip(api.getPullRequest({ cwd: "/repo", reference: "42" })),
1160 );
1161 while (execute.mock.calls.length === 0) yield* Effect.yieldNow;
1162 yield* TestClock.adjust("31 seconds");
1163 const error = yield* Fiber.join(fiber);
1164 assert.instanceOf(error, BitbucketApi.BitbucketRequestError);
1165 assert.strictEqual(error.operation, "getPullRequest");
1166 }).pipe(Effect.provide(layer));
1167});
1168
1169it.effect("times out before raw response headers arrive", () => {
1170 const { execute, layer } = makeLayer({ response: () => Response.json({}), stall: true });
1171 return Effect.gen(function* () {
1172 const api = yield* BitbucketApi.BitbucketApi;
1173 const fiber = yield* Effect.forkChild(
1174 Effect.flip(api.request({ method: "GET", url: "/user" })),
1175 );
1176 while (execute.mock.calls.length === 0) yield* Effect.yieldNow;
1177 yield* TestClock.adjust("31 seconds");
1178 const error = yield* Fiber.join(fiber);
1179 assert.instanceOf(error, BitbucketApi.BitbucketRequestError);
1180 assert.strictEqual(error.operation, "request");
1181 }).pipe(Effect.provide(layer));
1182});