Skip to content
Source
Sign inSign up

BitbucketApi.test.ts

1,006 lines34.4 KB

1import { SecretProviderId, 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 Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Queue from "effect/Queue";
13import * as Result from "effect/Result";
14import * as Schema from "effect/Schema";
15import * as TestClock from "effect/testing/TestClock";
16import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
17​
18import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
19import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
20import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
21​
22import * as BitbucketApi from "./BitbucketApi.ts";
23​
24type HostGit = SourceControlHost.SourceControlHost["Service"]["git"];
25​
26const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError);
27​
28const bitbucketPullRequest = {
29 id: 42,
30 title: "Add Bitbucket provider",
31 state: "OPEN",
32 updated_on: "2026-01-02T00:00:00.000Z",
33 links: {
34 html: {
35 href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
36 },
37 },
38 source: {
39 branch: { name: "feature/source-control" },
40 repository: {
41 full_name: "octocat/t3code",
42 workspace: { slug: "octocat" },
43 },
44 },
45 destination: {
46 branch: { name: "main" },
47 repository: {
48 full_name: "pingdotgg/t3code",
49 workspace: { slug: "pingdotgg" },
50 },
51 },
52};
53​
54const repositoryJson = {
55 full_name: "pingdotgg/t3code",
56 links: {
57 html: { href: "https://bitbucket.org/pingdotgg/t3code" },
58 clone: [
59 { name: "https", href: "https://bitbucket.org/pingdotgg/t3code.git" },
60 { name: "ssh", href: "git@bitbucket.org:pingdotgg/t3code.git" },
61 ],
62 },
63 mainbranch: { name: "main" },
64};
65​
66function makeLayer(input: {
67 readonly response: (request: HttpClientRequest.HttpClientRequest) => Response;
68 /** Holds the answer until it resolves, in place of answering at once. */
69 readonly answer?: (response: Response) => Effect.Effect<Response>;
70 readonly requestFailure?: (
71 request: HttpClientRequest.HttpClientRequest,
72 ) => HttpClientError.HttpClientError;
73 readonly stall?: boolean;
74 readonly git?: Partial<HostGit>;
75 readonly env?: Record<string, string>;
76 readonly remoteUrl?: string;
77}) {
78 const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) =>
79 input.stall
80 ? Effect.never
81 : input.requestFailure
82 ? Effect.fail(input.requestFailure(request))
83 : (input.answer ?? Effect.succeed)(input.response(request)).pipe(
84 Effect.map((response) => HttpClientResponse.fromWeb(request, response)),
85 ),
86 );
87 const gitMock = {
88 readConfigValue: vi.fn<HostGit["readConfigValue"]>(() =>
89 Effect.succeed<string | null>("git@bitbucket.org:pingdotgg/t3code.git"),
90 ),
91 resolvePrimaryRemoteName: vi.fn<HostGit["resolvePrimaryRemoteName"]>(() =>
92 Effect.succeed("origin"),
93 ),
94 ensureRemote: vi.fn<HostGit["ensureRemote"]>(() => Effect.succeed("octocat")),
95 fetchRemoteBranch: vi.fn<HostGit["fetchRemoteBranch"]>(() => Effect.void),
96 fetchRemoteTrackingBranch: vi.fn<HostGit["fetchRemoteTrackingBranch"]>(() => Effect.void),
97 setBranchUpstream: vi.fn<HostGit["setBranchUpstream"]>(() => Effect.void),
98 switchRef: vi.fn<HostGit["switchRef"]>((request) =>
99 Effect.succeed({ refName: request.refName }),
100 ),
101 listLocalBranchNames: vi.fn<HostGit["listLocalBranchNames"]>(() => Effect.succeed([])),
102 };
103 const git = {
104 ...gitMock,
105 ...input.git,
106 } satisfies Partial<HostGit>;
107​
108 const listRemotes = vi.fn(() =>
109 Effect.succeed({
110 remotes: [
111 {
112 name: "origin",
113 url: input.remoteUrl ?? "git@bitbucket.org:pingdotgg/t3code.git",
114 pushUrl: Option.none(),
115 isPrimary: true,
116 },
117 ],
118 freshness: {
119 source: "live-local" as const,
120 observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"),
121 expiresAt: Option.none(),
122 },
123 }),
124 );
125 // The outer effect resolves the repository; the inner one lists its remotes.
126 const resolve = vi.fn((_cwd: string) => Effect.succeed(Effect.suspend(listRemotes)));
127​
128 const layer = BitbucketApi.layer.pipe(
129 Layer.provide(SourceControlRateLimit.layer),
130 Layer.provide(
131 Layer.succeed(
132 HttpClient.HttpClient,
133 HttpClient.make((request) => execute(request)),
134 ),
135 ),
136 Layer.provide(
137 ConfigProvider.layer(
138 ConfigProvider.fromEnv({
139 env: input.env ?? {
140 T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0",
141 T3CODE_BITBUCKET_EMAIL: "user@example.com",
142 T3CODE_BITBUCKET_API_TOKEN: "token",
143 },
144 }),
145 ),
146 ),
147 Layer.provideMerge(TestSourceControlHost.layer({ git: { ...git, remotes: resolve } })),
148 Layer.provideMerge(NodeServices.layer),
149 );
150​
151 return { execute, git: gitMock, resolve, listRemotes, layer };
153​
154it.effect("parses pull request responses from the Bitbucket REST API", () => {
155 const { execute, layer } = makeLayer({
156 response: () =>
157 Response.json({
158 ...bitbucketPullRequest,
159 }),
160 });
161​
162 return Effect.gen(function* () {
163 const bitbucket = yield* BitbucketApi.BitbucketApi;
164 const result = yield* bitbucket.getPullRequest({
165 cwd: "/repo",
166 reference: "#42",
167 });
168​
169 assert.deepStrictEqual(result, {
170 number: 42,
171 title: "Add Bitbucket provider",
172 url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
173 baseRefName: "main",
174 headRefName: "feature/source-control",
175 state: "open",
176 updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")),
177 isCrossRepository: true,
178 headRepositoryNameWithOwner: "octocat/t3code",
179 headRepositoryOwnerLogin: "octocat",
180 });
181 assert.strictEqual(
182 execute.mock.calls[0]?.[0].url,
183 "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests/42",
184 );
185 }).pipe(Effect.provide(layer));
186});
187​
188it.effect.each([false, true])("honors PR URL repositories, context=%s", (withContext) => {
189 const { execute, git, layer } = makeLayer({
190 response: (request) =>
191 Response.json(
192 request.url.endsWith("/pullrequests/42")
193 ? {
194 ...bitbucketPullRequest,
195 source: {
196 branch: { name: "feature/source-control" },
197 repository: { full_name: "another/app" },
198 },
199 destination: { branch: { name: "main" }, repository: { full_name: "another/app" } },
200 }
201 : {
202 ...repositoryJson,
203 full_name: "another/app",
204 links: { clone: [{ name: "ssh", href: "git@bitbucket.org:another/app.git" }] },
205 },
206 ),
207 });
208 return Effect.gen(function* () {
209 const bitbucket = yield* BitbucketApi.BitbucketApi;
210 const input = {
211 cwd: "/repo",
212 reference: "https://bitbucket.org/another/app/pull-requests/42",
213 ...(withContext
214 ? {
215 context: {
216 provider: {
217 kind: SourceControlProviderKind.make("bitbucket"),
218 name: "Bitbucket",
219 baseUrl: "https://bitbucket.org",
220 },
221 remoteName: "origin",
222 remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git",
223 },
224 }
225 : {}),
226 };
227 yield* bitbucket.getPullRequest(input);
228 yield* bitbucket.checkoutPullRequest(input);
229 assert.deepStrictEqual(
230 execute.mock.calls.slice(0, 2).map(([request]) => request.url),
231 [
232 "https://api.test.local/2.0/repositories/another/app/pullrequests/42",
233 "https://api.test.local/2.0/repositories/another/app/pullrequests/42",
234 ],
235 );
236 assert.deepStrictEqual(git.ensureRemote.mock.calls[0]?.[0], {
237 cwd: "/repo",
238 preferredName: "another",
239 url: "git@bitbucket.org:another/app.git",
240 });
241 assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], {
242 cwd: "/repo",
243 remoteName: "octocat",
244 remoteBranch: "feature/source-control",
245 localBranch: "feature/source-control",
246 });
247 assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], {
248 cwd: "/repo",
249 branch: "feature/source-control",
250 remoteName: "octocat",
251 remoteBranch: "feature/source-control",
252 });
253 }).pipe(Effect.provide(layer));
254});
255​
256it.effect.each([false, true])(
257 "reuses matching checkout remotes for numeric references, context=%s",
258 (withContext) => {
259 const { git, layer } = makeLayer({
260 response: () =>
261 Response.json({
262 ...bitbucketPullRequest,
263 source: { ...bitbucketPullRequest.source, repository: { full_name: "pingdotgg/t3code" } },
264 }),
265 });
266 return Effect.gen(function* () {
267 const bitbucket = yield* BitbucketApi.BitbucketApi;
268 yield* bitbucket.checkoutPullRequest({
269 cwd: "/repo",
270 reference: "#42",
271 ...(withContext
272 ? {
273 context: {
274 provider: {
275 kind: SourceControlProviderKind.make("bitbucket"),
276 name: "Bitbucket",
277 baseUrl: "https://bitbucket.org",
278 },
279 remoteName: "upstream",
280 remoteUrl: "git@bitbucket.org:PingDotGG/T3Code.git",
281 },
282 }
283 : {}),
284 });
285 assert.strictEqual(git.ensureRemote.mock.calls.length, 0);
286 assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], {
287 cwd: "/repo",
288 remoteName: withContext ? "upstream" : "origin",
289 remoteBranch: "feature/source-control",
290 localBranch: "feature/source-control",
291 });
292 }).pipe(Effect.provide(layer));
293 },
294);
295​
296it.effect("lists pull requests with Bitbucket state and source branch query params", () => {
297 const { execute, layer } = makeLayer({
298 response: () =>
299 Response.json({
300 values: [
301 {
302 ...bitbucketPullRequest,
303 id: 7,
304 state: "MERGED",
305 source: {
306 branch: { name: "feature/merged" },
307 repository: { full_name: "pingdotgg/t3code" },
308 },
309 },
310 ],
311 }),
312 });
313​
314 return Effect.gen(function* () {
315 const bitbucket = yield* BitbucketApi.BitbucketApi;
316 const result = yield* bitbucket.listPullRequests({
317 cwd: "/repo",
318 headSelector: "origin:feature/merged",
319 state: "merged",
320 limit: 10,
321 });
322​
323 assert.strictEqual(result[0]?.state, "merged");
324 const request = execute.mock.calls[0]?.[0];
325 assert.strictEqual(
326 request?.url,
327 "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests",
328 );
329 assert.deepStrictEqual(request?.urlParams.params, [
330 ["pagelen", "10"],
331 ["sort", "-updated_on"],
332 ["q", 'source.branch.name = "feature/merged" AND state = "MERGED"'],
333 ["state", "MERGED"],
334 ]);
335 }).pipe(Effect.provide(layer));
336});
337​
338it.effect("reads repository clone URLs and default branch", () => {
339 const { layer } = makeLayer({
340 response: (request) =>
341 Response.json(
342 request.url.endsWith("/branching-model")
343 ? {
344 development: {
345 branch: { name: "main" },
346 name: "main",
347 use_mainbranch: true,
348 },
349 }
350 : repositoryJson,
351 ),
352 });
353​
354 return Effect.gen(function* () {
355 const bitbucket = yield* BitbucketApi.BitbucketApi;
356 const cloneUrls = yield* bitbucket.getRepositoryCloneUrls({
357 cwd: "/repo",
358 repository: "pingdotgg/t3code",
359 });
360 const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" });
361​
362 assert.deepStrictEqual(cloneUrls, {
363 nameWithOwner: "pingdotgg/t3code",
364 url: "https://bitbucket.org/pingdotgg/t3code.git",
365 sshUrl: "git@bitbucket.org:pingdotgg/t3code.git",
366 });
367 assert.strictEqual(defaultBranch, "main");
368 }).pipe(Effect.provide(layer));
369});
370​
371it.effect.each([false, true])(
372 "rejects invalid explicit repositories before fallback, context=%s",
373 (withContext) => {
374 const { execute, resolve, listRemotes, layer } = makeLayer({
375 response: () => Response.json(repositoryJson),
376 });
377​
378 return Effect.gen(function* () {
379 const bitbucket = yield* BitbucketApi.BitbucketApi;
380 for (const repository of ["t3code", "", " \t "]) {
381 const error = yield* bitbucket
382 .getRepositoryCloneUrls({
383 cwd: "/repo",
384 repository,
385 ...(withContext
386 ? {
387 context: {
388 provider: {
389 kind: SourceControlProviderKind.make("bitbucket"),
390 name: "Bitbucket",
391 baseUrl: "https://bitbucket.org",
392 },
393 remoteName: "origin",
394 remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git",
395 },
396 }
397 : {}),
398 })
399 .pipe(Effect.match({ onFailure: (error) => error, onSuccess: () => null }));
400​
401 assert.instanceOf(error, BitbucketApi.BitbucketRepositoryLocatorError);
402 assert.strictEqual(
403 isBitbucketRepositoryLocatorError(error) ? error.repository : null,
404 repository,
405 );
406 assert.strictEqual(resolve.mock.calls.length, 0);
407 assert.strictEqual(listRemotes.mock.calls.length, 0);
408 assert.strictEqual(execute.mock.calls.length, 0);
409 }
410 }).pipe(Effect.provide(layer));
411 },
412);
413​
414it.effect("prefers an explicit repository and uses context when the repository is omitted", () => {
415 const { execute, resolve, listRemotes, layer } = makeLayer({
416 response: (request) =>
417 Response.json(request.url.endsWith("/branching-model") ? {} : repositoryJson),
418 });
419​
420 return Effect.gen(function* () {
421 const bitbucket = yield* BitbucketApi.BitbucketApi;
422 const context = {
423 provider: {
424 kind: SourceControlProviderKind.make("bitbucket"),
425 name: "Bitbucket",
426 baseUrl: "https://bitbucket.org",
427 },
428 remoteName: "origin",
429 remoteUrl: "git@bitbucket.org:another/context.git",
430 };
431 for (const repository of ["pingdotgg/t3code", "https://bitbucket.org/pingdotgg/t3code.git"]) {
432 yield* bitbucket.getRepositoryCloneUrls({ cwd: "/repo", context, repository });
433 }
434 yield* bitbucket.getDefaultBranch({ cwd: "/repo", context });
435​
436 assert.deepStrictEqual(
437 execute.mock.calls.map(([request]) => request.url).toSorted(),
438 [
439 "https://api.test.local/2.0/repositories/pingdotgg/t3code",
440 "https://api.test.local/2.0/repositories/pingdotgg/t3code",
441 "https://api.test.local/2.0/repositories/another/context",
442 "https://api.test.local/2.0/repositories/another/context/branching-model",
443 ].toSorted(),
444 );
445 assert.strictEqual(resolve.mock.calls.length, 0);
446 assert.strictEqual(listRemotes.mock.calls.length, 0);
447 }).pipe(Effect.provide(layer));
448});
449​
450it.effect("sends a self-hosted Bitbucket remote nowhere unless an API base url is configured", () =>
451 Effect.gen(function* () {
452 const remoteUrl = "https://bitbucket.example.com/scm/proj/repo.git";
453 const context = {
454 provider: {
455 kind: SourceControlProviderKind.make("bitbucket"),
456 name: "Bitbucket Self-Hosted",
457 baseUrl: "https://bitbucket.example.com",
458 },
459 remoteName: "origin",
460 remoteUrl,
461 };
462 const cloudEnv = {
463 T3CODE_BITBUCKET_EMAIL: "user@example.com",
464 T3CODE_BITBUCKET_API_TOKEN: "t",
465 };
466 const response = (request: HttpClientRequest.HttpClientRequest) =>
467 Response.json(request.url.endsWith("/branching-model") ? {} : repositoryJson);
468 const defaultBranch = (setup: ReturnType<typeof makeLayer>, withContext: boolean) =>
469 BitbucketApi.BitbucketApi.pipe(
470 Effect.flatMap((bitbucket) =>
471 bitbucket.getDefaultBranch({ cwd: "/repo", ...(withContext ? { context } : {}) }),
472 ),
473 Effect.provide(setup.layer),
474 );
475 const requestedUrls = (setup: ReturnType<typeof makeLayer>) =>
476 setup.execute.mock.calls.map(([request]) => request.url).toSorted();
477​
478 // A self-hosted remote in the checkout, then a self-hosted context beside a Cloud remote.
479 for (const [setup, withContext] of [
480 [makeLayer({ response, env: cloudEnv, remoteUrl }), false],
481 [makeLayer({ response, env: cloudEnv }), true],
482 ] as const) {
483 const result = yield* defaultBranch(setup, withContext).pipe(Effect.result);
484 assert.deepStrictEqual(requestedUrls(setup), []);
485 assert.strictEqual(
486 Result.isFailure(result) && result.failure._tag,
487 "BitbucketRepositoryRemoteNotFoundError",
488 );
489 }
490​
491 // An explicit repository does not let a self-hosted context reach the Cloud API.
492 const explicit = makeLayer({ response, env: cloudEnv });
493 const cloneUrls = yield* BitbucketApi.BitbucketApi.pipe(
494 Effect.flatMap((bitbucket) =>
495 bitbucket.getRepositoryCloneUrls({ cwd: "/repo", context, repository: "proj/repo" }),
496 ),
497 Effect.provide(explicit.layer),
498 Effect.result,
499 );
500 assert.deepStrictEqual(requestedUrls(explicit), []);
501 assert.strictEqual(
502 Result.isFailure(cloneUrls) && cloneUrls.failure._tag,
503 "BitbucketRepositoryRemoteNotFoundError",
504 );
505​
506 const cloud = makeLayer({ response, env: cloudEnv });
507 yield* defaultBranch(cloud, false);
508 assert.deepStrictEqual(requestedUrls(cloud), [
509 "https://api.bitbucket.org/2.0/repositories/pingdotgg/t3code",
510 "https://api.bitbucket.org/2.0/repositories/pingdotgg/t3code/branching-model",
511 ]);
512​
513 const configured = makeLayer({ response, remoteUrl });
514 yield* defaultBranch(configured, true);
515 assert.deepStrictEqual(requestedUrls(configured), [
516 "https://api.test.local/2.0/repositories/proj/repo",
517 "https://api.test.local/2.0/repositories/proj/repo/branching-model",
518 ]);
519 }),
520);
521​
522it.effect(
523 "prefers the Bitbucket branching model development branch as the default PR target",
524 () => {
525 const { execute, layer } = makeLayer({
526 response: (request) =>
527 Response.json(
528 request.url.endsWith("/branching-model")
529 ? {
530 development: {
531 branch: { name: "develop" },
532 name: "develop",
533 use_mainbranch: false,
534 },
535 }
536 : repositoryJson,
537 ),
538 });
539​
540 return Effect.gen(function* () {
541 const bitbucket = yield* BitbucketApi.BitbucketApi;
542 const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" });
543​
544 assert.strictEqual(defaultBranch, "develop");
545 assert.deepStrictEqual(
546 execute.mock.calls.map((call) => call[0].url).toSorted(),
547 [
548 "https://api.test.local/2.0/repositories/pingdotgg/t3code",
549 "https://api.test.local/2.0/repositories/pingdotgg/t3code/branching-model",
550 ].toSorted(),
551 );
552 }).pipe(Effect.provide(layer));
553 },
554);
555​
556it.effect(
557 "falls back to the repository main branch when the Bitbucket development branch is invalid",
558 () => {
559 const { layer } = makeLayer({
560 response: (request) =>
561 Response.json(
562 request.url.endsWith("/branching-model")
563 ? {
564 development: {
565 name: "develop",
566 use_mainbranch: false,
567 is_valid: false,
568 },
569 }
570 : repositoryJson,
571 ),
572 });
573​
574 return Effect.gen(function* () {
575 const bitbucket = yield* BitbucketApi.BitbucketApi;
576 const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" });
577​
578 assert.strictEqual(defaultBranch, "main");
579 }).pipe(Effect.provide(layer));
580 },
581);
582​
583it.effect("creates pull requests using the official REST payload shape", () => {
584 const { execute, layer } = makeLayer({
585 response: () => Response.json(bitbucketPullRequest),
586 });
587​
588 return Effect.gen(function* () {
589 const fileSystem = yield* FileSystem.FileSystem;
590 const bodyFile = yield* fileSystem.makeTempFileScoped({ prefix: "bitbucket-pr-body-" });
591 yield* fileSystem.writeFileString(bodyFile, "PR body");
592​
593 const bitbucket = yield* BitbucketApi.BitbucketApi;
594 yield* bitbucket.createPullRequest({
595 cwd: "/repo",
596 baseBranch: "main",
597 headSelector: "owner:feature/provider",
598 title: "Provider PR",
599 bodyFile,
600 });
601​
602 const request = execute.mock.calls[0]?.[0];
603 assert.strictEqual(
604 request?.url,
605 "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests",
606 );
607 assert.strictEqual(request?.method, "POST");
608 assert.ok(request);
609 const rawBody = (request.body as { readonly body?: Uint8Array }).body;
610 assert.ok(rawBody);
611 assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), {
612 title: "Provider PR",
613 description: "PR body",
614 source: {
615 branch: { name: "feature/provider" },
616 repository: { full_name: "owner/t3code" },
617 },
618 destination: {
619 branch: { name: "main" },
620 },
621 });
622 }).pipe(Effect.provide(layer));
623});
624​
625it.effect("reports the account Bitbucket confirms for the configured credential", () => {
626 const { layer } = makeLayer({
627 response: () => Response.json({ username: "bitbucket-user" }),
628 });
629​
630 return Effect.gen(function* () {
631 const bitbucket = yield* BitbucketApi.BitbucketApi;
632 const auth = yield* bitbucket.probeAuth;
633 assert.strictEqual(auth.status, "authenticated");
634 assert.deepStrictEqual(auth.account, Option.some("bitbucket-user"));
635 assert.deepStrictEqual(auth.host, Option.some("bitbucket.org"));
636 }).pipe(Effect.provide(layer));
637});
638​
639it.effect("prefers credentials saved in settings over the environment, without a restart", () => {
640 const { execute, layer } = makeLayer({
641 response: () => Response.json({ username: "bitbucket-user" }),
642 });
643 const lastAuthorization = () => execute.mock.calls.at(-1)?.[0].headers.authorization;
644 const basic = (user: string, password: string) => `Basic ${btoa(`${user}:${password}`)}`;
645​
646 return Effect.gen(function* () {
647 const bitbucket = yield* BitbucketApi.BitbucketApi;
648 const settings = yield* TestSourceControlHost.TestSourceControlHostSettings;
649​
650 yield* bitbucket.probeAuth;
651 assert.strictEqual(lastAuthorization(), basic("user@example.com", "token"));
652​
653 yield* settings.update({
654 sourceControlHosts: {
655 bitbucket: { email: "saved@example.com", apiToken: "saved-api-token" },
656 },
657 });
658 yield* bitbucket.probeAuth;
659 assert.strictEqual(lastAuthorization(), basic("saved@example.com", "saved-api-token"));
660​
661 yield* settings.update({
662 sourceControlHosts: { bitbucket: { accessToken: "saved-access-token" } },
663 });
664 yield* bitbucket.probeAuth;
665 assert.strictEqual(lastAuthorization(), "Bearer saved-access-token");
666​
667 yield* settings.update({
668 sourceControlHosts: { bitbucket: { accessToken: "", apiToken: "" } },
669 });
670 yield* bitbucket.probeAuth;
671 assert.strictEqual(lastAuthorization(), basic("user@example.com", "token"));
672​
673 // A reference left in place by getWithSecrets is one its secret provider could not read.
674 const requests = execute.mock.calls.length;
675 yield* settings.update({
676 sourceControlHosts: {
677 bitbucket: {
678 accessToken: { provider: SecretProviderId.make("vault"), reference: "bb#token" },
679 },
680 },
681 });
682 const auth = yield* bitbucket.probeAuth;
683 assert.strictEqual(auth.status, "unauthenticated");
684 assert.include(Option.getOrThrow(auth.detail), '"vault"');
685 const error = yield* Effect.flip(
686 bitbucket.listPullRequests({ cwd: "/repo", headSelector: "feature", state: "open" }),
687 );
688 assert.strictEqual(error._tag, "SettingsSecretUnresolvedError");
689 assert.strictEqual(execute.mock.calls.length, requests);
690 }).pipe(Effect.provide(layer));
691});
692​
693it.effect("never puts a saved token that is unsafe for an HTTP header on the wire", () => {
694 const { execute, layer } = makeLayer({
695 response: () => Response.json({ username: "bitbucket-user" }),
696 });
697​
698 return Effect.gen(function* () {
699 const bitbucket = yield* BitbucketApi.BitbucketApi;
700 const settings = yield* TestSourceControlHost.TestSourceControlHostSettings;
701​
702 // Fetch would reject this header with an error quoting the token, and that error reaches
703 // clients. The unusable token is ignored, so the environment credential is used instead.
704 yield* settings.update({
705 sourceControlHosts: { bitbucket: { accessToken: "saved\ntoken" } },
706 });
707 yield* bitbucket.probeAuth;
708 assert.strictEqual(
709 execute.mock.calls.at(-1)?.[0].headers.authorization,
710 `Basic ${btoa("user@example.com:token")}`,
711 );
712 }).pipe(Effect.provide(layer));
713});
714​
715it.effect("reports saved credentials as configured when Bitbucket cannot confirm them", () => {
716 const { execute, layer } = makeLayer({
717 response: () => new Response(null, { status: 401 }),
718 env: { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0" },
719 });
720​
721 return Effect.gen(function* () {
722 const bitbucket = yield* BitbucketApi.BitbucketApi;
723 const settings = yield* TestSourceControlHost.TestSourceControlHostSettings;
724​
725 assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated");
726 assert.strictEqual(execute.mock.calls.length, 0);
727​
728 yield* settings.update({
729 sourceControlHosts: { bitbucket: { accessToken: "saved-access-token" } },
730 });
731 assert.deepStrictEqual(yield* bitbucket.probeAuth, {
732 status: "unknown",
733 account: Option.none(),
734 host: Option.some("bitbucket.org"),
735 detail: Option.some("An access token is configured."),
736 });
737 }).pipe(Effect.provide(layer));
738});
739​
740it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => {
741 const responseBody = '{"error":{"message":"credential=secret-value"}}';
742 const { layer } = makeLayer({
743 response: () => new Response(responseBody, { status: 403 }),
744 });
745​
746 return Effect.gen(function* () {
747 const bitbucket = yield* BitbucketApi.BitbucketApi;
748 const error = yield* bitbucket
749 .checkoutPullRequest({ cwd: "/repo", reference: "42" })
750 .pipe(Effect.flip);
751​
752 assert.instanceOf(error, BitbucketApi.BitbucketResponseError);
753 assert.strictEqual(error.operation, "getPullRequest");
754 assert.strictEqual(error.status, 403);
755 assert.strictEqual(error.responseBodyLength, responseBody.length);
756 assert.notProperty(error, "responseBody");
757 assert.strictEqual(
758 error.message,
759 "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 403.",
760 );
761 assert.notInclude(error.message, "secret-value");
762 }).pipe(Effect.provide(layer));
763});
764​
765it.effect("checks out fork pull requests through an ensured fork remote", () => {
766 const { git, layer } = makeLayer({
767 response: (request) => {
768 if (request.url.endsWith("/repositories/octocat/t3code")) {
769 return Response.json({
770 ...repositoryJson,
771 full_name: "octocat/t3code",
772 links: {
773 html: { href: "https://bitbucket.org/octocat/t3code" },
774 clone: [
775 { name: "https", href: "https://bitbucket.org/octocat/t3code.git" },
776 { name: "ssh", href: "git@bitbucket.org:octocat/t3code.git" },
777 ],
778 },
779 });
780 }
781 return Response.json({
782 ...bitbucketPullRequest,
783 source: {
784 branch: { name: "main" },
785 repository: {
786 full_name: "octocat/t3code",
787 workspace: { slug: "octocat" },
788 },
789 },
790 });
791 },
792 });
793​
794 return Effect.gen(function* () {
795 const bitbucket = yield* BitbucketApi.BitbucketApi;
796 yield* bitbucket.checkoutPullRequest({
797 cwd: "/repo",
798 reference: "42",
799 force: true,
800 });
801​
802 assert.deepStrictEqual(git.ensureRemote.mock.calls[0]?.[0], {
803 cwd: "/repo",
804 preferredName: "octocat",
805 url: "git@bitbucket.org:octocat/t3code.git",
806 });
807 assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], {
808 cwd: "/repo",
809 remoteName: "octocat",
810 remoteBranch: "main",
811 localBranch: "t3code/pr-42/main",
812 });
813 assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], {
814 cwd: "/repo",
815 branch: "t3code/pr-42/main",
816 remoteName: "octocat",
817 remoteBranch: "main",
818 });
819 assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], {
820 cwd: "/repo",
821 refName: "t3code/pr-42/main",
822 });
823 }).pipe(Effect.provide(layer));
824});
825​
826it.effect("refuses a url that points away from the configured Bitbucket", () => {
827 const { layer, execute } = makeLayer({ response: () => new Response("{}", { status: 200 }) });
828 return Effect.gen(function* () {
829 const bitbucket = yield* BitbucketApi.BitbucketApi;
830​
831 const error = yield* Effect.flip(
832 bitbucket.request({ method: "GET", url: "https://attacker.example/2.0/repositories" }),
833 );
834​
835 assert.strictEqual(error._tag, "BitbucketUntrustedUrlError");
836 assert.strictEqual(execute.mock.calls.length, 0);
837 }).pipe(Effect.provide(layer));
838});
839​
840it.effect("keeps only the host of a url it refuses, never its query", () =>
841 Effect.gen(function* () {
842 const bitbucket = yield* BitbucketApi.BitbucketApi;
843​
844 const error = yield* Effect.flip(
845 bitbucket.request({
846 method: "GET",
847 url: "https://attacker.example/asset?signature=secret-token",
848 }),
849 );
850​
851 assert.strictEqual(error._tag, "BitbucketUntrustedUrlError");
852 assert.strictEqual(
853 error._tag === "BitbucketUntrustedUrlError" ? error.host : "",
854 "https://attacker.example",
855 );
856 assert.notInclude(error.message, "secret-token");
857 }).pipe(Effect.provide(makeLayer({ response: () => new Response("{}", { status: 200 }) }).layer)),
858);
859​
860it.effect("does not follow a redirect off the configured Bitbucket", () =>
861 Effect.gen(function* () {
862 const bitbucket = yield* BitbucketApi.BitbucketApi;
863​
864 const error = yield* Effect.flip(
865 bitbucket.request({ method: "GET", url: "/repositories/acme/web/pullrequests/1/diff" }),
866 );
867​
868 assert.strictEqual(error._tag, "BitbucketUntrustedUrlError");
869 }).pipe(
870 Effect.provide(
871 makeLayer({
872 response: () =>
873 new Response(null, {
874 status: 302,
875 headers: { location: "https://attacker.example/stolen" },
876 }),
877 }).layer,
878 ),
879 ),
880);
881​
882it.effect("follows a redirect that stays on the configured Bitbucket", () =>
883 Effect.gen(function* () {
884 const bitbucket = yield* BitbucketApi.BitbucketApi;
885​
886 const result = yield* bitbucket.request({
887 method: "GET",
888 url: "/repositories/acme/web/pullrequests/1/diff",
889 });
890​
891 assert.strictEqual(result.body, "diff --git a/a.ts b/a.ts");
892 assert.isFalse(result.truncated);
893 }).pipe(
894 Effect.provide(
895 makeLayer({
896 response: (request) =>
897 request.url.endsWith("/pullrequests/1/diff")
898 ? new Response(null, {
899 status: 302,
900 headers: { location: "https://api.test.local/2.0/repositories/acme/web/diff/abc" },
901 })
902 : new Response("diff --git a/a.ts b/a.ts", { status: 200 }),
903 }).layer,
904 ),
905 ),
906);
907​
908it.effect("cuts a response short rather than reading an unbounded diff into memory", () =>
909 Effect.gen(function* () {
910 const bitbucket = yield* BitbucketApi.BitbucketApi;
911​
912 const result = yield* bitbucket.request({
913 method: "GET",
914 url: "/repositories/acme/web/pullrequests/1/diff",
915 maxBytes: 8,
916 });
917​
918 assert.strictEqual(result.body, "12345678");
919 assert.isTrue(result.truncated);
920 }).pipe(
921 Effect.provide(
922 makeLayer({ response: () => new Response("1234567890", { status: 200 }) }).layer,
923 ),
924 ),
925);
926​
927it.effect("pauses background reads after a 429 until Bitbucket's retry-after passes", () => {
928 let answered = 0;
929 const { execute, layer } = makeLayer({
930 response: () => {
931 answered += 1;
932 return answered === 1
933 ? new Response("Rate limit exceeded", { status: 429, headers: { "retry-after": "60" } })
934 : Response.json({ values: [] });
935 },
936 });
937​
938 return Effect.gen(function* () {
939 const bitbucket = yield* BitbucketApi.BitbucketApi;
940 const lookup = bitbucket.listPullRequests({
941 cwd: "/repo",
942 headSelector: "feature/a",
943 state: "all",
944 });
945​
946 yield* Effect.flip(lookup);
947 const paused = yield* Effect.flip(lookup);
948 // Keyed by the repository host, where PullRequestService records its own backoff.
949 assert.deepInclude(paused, {
950 _tag: "SourceControlRateLimitPausedError",
951 host: "bitbucket.org",
952 retryAt: 60_000,
953 });
954 assert.strictEqual(execute.mock.calls.length, 1);
955​
956 // A user waiting on an answer is not held back by the background pause, and their success
957 // does not end it early.
958 yield* lookup.pipe(Effect.provideService(SourceControlRateLimit.Interactive, true));
959 yield* bitbucket.request({
960 method: "POST",
961 url: "/repositories/acme/web/pullrequests/1/approve",
962 });
963​
964 yield* TestClock.adjust("59 seconds");
965 assert.strictEqual((yield* Effect.flip(lookup))._tag, "SourceControlRateLimitPausedError");
966​
967 yield* TestClock.adjust("1 second");
968 yield* lookup;
969 }).pipe(Effect.provide(layer));
970});
971​
972it.effect("keeps a sweep over many branches to a few Bitbucket requests at a time", () =>
973 Effect.gen(function* () {
974 const started = yield* Queue.unbounded<void>();
975 const release = yield* Deferred.make<void>();
976 let inFlight = 0;
977 let peak = 0;
978 const { layer } = makeLayer({
979 response: () => Response.json({ values: [] }),
980 answer: (response) =>
981 Effect.gen(function* () {
982 inFlight += 1;
983 peak = Math.max(peak, inFlight);
984 yield* Queue.offer(started, undefined);
985 yield* Deferred.await(release);
986 inFlight -= 1;
987 return response;
988 }),
989 });
990​
991 yield* Effect.gen(function* () {
992 const bitbucket = yield* BitbucketApi.BitbucketApi;
993 const sweep = yield* Effect.forEach(
994 Array.from({ length: 12 }, (_, index) => `feature/${index}`),
995 (branch) =>
996 bitbucket.listPullRequests({ cwd: "/repo", headSelector: branch, state: "all" }),
997 { concurrency: "unbounded", discard: true },
998 ).pipe(Effect.forkChild);
999 for (let index = 0; index < 4; index += 1) yield* Queue.take(started);
1000 yield* Deferred.succeed(release, undefined);
1001 yield* Fiber.join(sweep);
1002 }).pipe(Effect.provide(layer));
1003​
1004 assert.strictEqual(peak, 4);
1005 }),