Skip to content
Source
Sign inSign up

BitbucketApi.ts

1,250 lines42.0 KB

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 * as Semaphore from "effect/Semaphore";
10import {
11 NonNegativeInt,
12 requireSettingsSecretLiteral,
13 SettingsSecretUnresolvedError,
14 SourceControlProviderKind,
15 TrimmedNonEmptyString,
16 type ServerSettings,
17 type SourceControlProviderAuth,
18 type SourceControlProviderInfo,
19 type SourceControlRepositoryCloneUrls,
20 type SourceControlRepositoryVisibility,
21} from "@t3tools/contracts";
22import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
23import { sanitizeBranchFragment } from "@t3tools/shared/git";
24import { isSshRemoteUrl } from "@t3tools/shared/sourceControl";
25import { recognizeRemoteWith } from "@t3tools/source-control-core/shared/definition";
26import { definition } from "../shared/definition.ts";
27​
28import {
29 BitbucketPullRequestListSchema,
30 BitbucketPullRequestSchema,
31 normalizeBitbucketPullRequestRecord,
32 type NormalizedBitbucketPullRequestRecord,
33} from "./bitbucketPullRequests.ts";
34import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
35import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
36import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
37import { readSourceControlHostSettings } from "@t3tools/source-control-core/shared/definition";
38​
39import * as BitbucketClient from "../shared/definition.ts";
40import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
41​
42const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0";
43const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
44const MAX_REDIRECTS = 3;
45const BITBUCKET_API_REQUEST_TIMEOUT = "30 seconds";
46/** A sweep over many branches would otherwise reach Bitbucket as one burst of lookups. */
47const CONCURRENCY = 4;
48​
49const BitbucketApiEnvConfig = Config.all({
50 baseUrl: Config.String("T3CODE_BITBUCKET_API_BASE_URL").pipe(
51 Config.withDefault(DEFAULT_API_BASE_URL),
52 ),
53 accessToken: Config.String("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option),
54 email: Config.String("T3CODE_BITBUCKET_EMAIL").pipe(Config.option),
55 apiToken: Config.String("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option),
56});
57​
58// Server and Data Center use a different API; a custom Cloud endpoint must be explicit.
59export const BitbucketServesHost = Config.String("T3CODE_BITBUCKET_API_BASE_URL").pipe(
60 Config.option,
61 Config.map(
62 (baseUrl) => (host: string) =>
63 Option.isSome(baseUrl) || host === "bitbucket.org" || host.endsWith(".bitbucket.org"),
64 ),
65);
66​
67const BitbucketApiOperation = Schema.Literals([
68 "resolveRepository",
69 "getRepository",
70 "getBranchingModel",
71 "getPullRequest",
72 "listPullRequests",
73 "createRepository",
74 "createPullRequest",
75 "probeAuth",
76 "checkoutPullRequest",
77 "request",
78]);
79type BitbucketApiOperation = typeof BitbucketApiOperation.Type;
80​
81export class BitbucketRepositoryLocatorError extends Schema.TaggedError<BitbucketRepositoryLocatorError>()(
82 "BitbucketRepositoryLocatorError",
83 {
84 repository: Schema.String,
85 },
86) {
87 static readonly detail = "Bitbucket repositories must be specified as workspace/repository.";
88​
89 get detail(): string {
90 return BitbucketRepositoryLocatorError.detail;
91 }
92​
93 get userFacingDetail(): string {
94 return BitbucketRepositoryLocatorError.detail;
95 }
96​
97 override get message(): string {
98 return `Bitbucket API failed: ${this.detail}`;
99 }
101​
102export class BitbucketRequestError extends Schema.TaggedError<BitbucketRequestError>()(
103 "BitbucketRequestError",
104 {
105 operation: BitbucketApiOperation,
106 cause: Schema.Defect(),
107 },
108) {
109 get detail(): string {
110 return "Failed to send the Bitbucket request.";
111 }
112​
113 override get message(): string {
114 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
115 }
117​
118export class BitbucketResponseError extends Schema.TaggedError<BitbucketResponseError>()(
119 "BitbucketResponseError",
120 {
121 operation: BitbucketApiOperation,
122 status: Schema.Int,
123 responseBodyLength: NonNegativeInt,
124 retryAt: Schema.optional(Schema.Number),
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 }
135​
136export class BitbucketResponseBodyReadError extends Schema.TaggedError<BitbucketResponseBodyReadError>()(
137 "BitbucketResponseBodyReadError",
138 {
139 operation: BitbucketApiOperation,
140 status: Schema.Int,
141 retryAt: Schema.optional(Schema.Number),
142 cause: Schema.Defect(),
143 },
144) {
145 get detail(): string {
146 return `Bitbucket returned HTTP ${this.status}.`;
147 }
148​
149 override get message(): string {
150 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
151 }
153​
154export class BitbucketResponseDecodeError extends Schema.TaggedError<BitbucketResponseDecodeError>()(
155 "BitbucketResponseDecodeError",
156 {
157 operation: BitbucketApiOperation,
158 status: Schema.Int,
159 cause: Schema.Defect(),
160 },
161) {
162 get detail(): string {
163 return "Bitbucket returned invalid JSON for the requested resource.";
164 }
165​
166 override get message(): string {
167 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
168 }
170​
171export class BitbucketRepositoryVcsResolveError extends Schema.TaggedError<BitbucketRepositoryVcsResolveError>()(
172 "BitbucketRepositoryVcsResolveError",
173 {
174 cwd: Schema.String,
175 cause: Schema.Defect(),
176 },
177) {
178 get detail(): string {
179 return `Failed to resolve VCS repository for ${this.cwd}.`;
180 }
181​
182 override get message(): string {
183 return `Bitbucket API failed in resolveRepository: ${this.detail}`;
184 }
186​
187export class BitbucketRepositoryRemotesListError extends Schema.TaggedError<BitbucketRepositoryRemotesListError>()(
188 "BitbucketRepositoryRemotesListError",
189 {
190 cwd: Schema.String,
191 cause: Schema.Defect(),
192 },
193) {
194 get detail(): string {
195 return `Failed to list remotes for ${this.cwd}.`;
196 }
197​
198 override get message(): string {
199 return `Bitbucket API failed in resolveRepository: ${this.detail}`;
200 }
202​
203export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedError<BitbucketRepositoryRemoteNotFoundError>()(
204 "BitbucketRepositoryRemoteNotFoundError",
205 {
206 cwd: Schema.String,
207 },
208) {
209 get detail(): string {
210 return `No Bitbucket repository remote was detected for ${this.cwd}.`;
211 }
212​
213 override get message(): string {
214 return `Bitbucket API failed in resolveRepository: ${this.detail}`;
215 }
217​
218export class BitbucketPullRequestBodyReadError extends Schema.TaggedError<BitbucketPullRequestBodyReadError>()(
219 "BitbucketPullRequestBodyReadError",
220 {
221 cwd: Schema.String,
222 bodyFile: Schema.String,
223 cause: Schema.Defect(),
224 },
225) {
226 get detail(): string {
227 return `Failed to read pull request body file ${this.bodyFile}.`;
228 }
229​
230 override get message(): string {
231 return `Bitbucket API failed in createPullRequest: ${this.detail}`;
232 }
234​
235export class BitbucketCheckoutError extends Schema.TaggedError<BitbucketCheckoutError>()(
236 "BitbucketCheckoutError",
237 {
238 cwd: Schema.String,
239 reference: Schema.String,
240 cause: Schema.Defect(),
241 },
242) {
243 get detail(): string {
244 return "Failed to check out the Bitbucket pull request.";
245 }
246​
247 override get message(): string {
248 return `Bitbucket API failed in checkoutPullRequest: ${this.detail}`;
249 }
251​
252/** Reject response-supplied URLs that could leak credentials. */
253export class BitbucketUntrustedUrlError extends Schema.TaggedError<BitbucketUntrustedUrlError>()(
254 "BitbucketUntrustedUrlError",
255 {
256 /** Host only; signed URL queries can contain credentials. */
257 host: Schema.String,
258 },
259) {
260 get detail(): string {
261 return `The response pointed at ${this.host}, outside the configured Bitbucket.`;
262 }
263​
264 override get message(): string {
265 return `Bitbucket API failed in request: ${this.detail}`;
266 }
268​
269export const BitbucketApiError = Schema.Union([
270 BitbucketUntrustedUrlError,
271 BitbucketRepositoryLocatorError,
272 BitbucketRequestError,
273 BitbucketResponseError,
274 BitbucketResponseBodyReadError,
275 BitbucketResponseDecodeError,
276 BitbucketRepositoryVcsResolveError,
277 BitbucketRepositoryRemotesListError,
278 BitbucketRepositoryRemoteNotFoundError,
279 BitbucketPullRequestBodyReadError,
280 BitbucketCheckoutError,
281 SourceControlRateLimit.SourceControlRateLimitPausedError,
282 SettingsSecretUnresolvedError,
283]);
284export type BitbucketApiError = typeof BitbucketApiError.Type;
285const isBitbucketApiError = Schema.is(BitbucketApiError);
286​
287const RawBitbucketRepositorySchema = Schema.Struct({
288 full_name: TrimmedNonEmptyString,
289 links: Schema.Struct({
290 html: Schema.optional(
291 Schema.Struct({
292 href: TrimmedNonEmptyString,
293 }),
294 ),
295 clone: Schema.optional(
296 Schema.Array(
297 Schema.Struct({
298 name: TrimmedNonEmptyString,
299 href: TrimmedNonEmptyString,
300 }),
301 ),
302 ),
303 }),
304 mainbranch: Schema.optional(
305 Schema.NullOr(
306 Schema.Struct({
307 name: TrimmedNonEmptyString,
308 }),
309 ),
310 ),
311});
312​
313const RawBitbucketBranchingModelSchema = Schema.Struct({
314 development: Schema.optional(
315 Schema.Struct({
316 branch: Schema.optional(
317 Schema.NullOr(
318 Schema.Struct({
319 name: Schema.optional(TrimmedNonEmptyString),
320 }),
321 ),
322 ),
323 is_valid: Schema.optional(Schema.Boolean),
324 name: Schema.optional(Schema.NullOr(Schema.String)),
325 use_mainbranch: Schema.optional(Schema.Boolean),
326 }),
327 ),
328});
329​
330const BitbucketUserSchema = Schema.Struct({
331 username: Schema.optional(TrimmedNonEmptyString),
332 display_name: Schema.optional(TrimmedNonEmptyString),
333 account_id: Schema.optional(TrimmedNonEmptyString),
334});
335​
336export interface BitbucketRepositoryLocator {
337 readonly workspace: string;
338 readonly repoSlug: string;
340​
341export class BitbucketApi extends Context.Service<
342 BitbucketApi,
343 {
344 readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
345​
346 readonly request: (input: {
347 readonly method: "GET" | "POST" | "PUT" | "DELETE";
348 readonly url: string;
349 readonly body?: string;
350 readonly maxBytes?: number;
351 }) => Effect.Effect<{ readonly body: string; readonly truncated: boolean }, BitbucketApiError>;
352 readonly listPullRequests: (input: {
353 readonly cwd: string;
354 readonly context?: SourceControlProvider.SourceControlProviderContext;
355 readonly headSelector: string;
356 readonly source?: SourceControlProvider.SourceControlRefSelector;
357 readonly state: "open" | "closed" | "merged" | "all";
358 readonly limit?: number;
359 }) => Effect.Effect<ReadonlyArray<NormalizedBitbucketPullRequestRecord>, BitbucketApiError>;
360 readonly getPullRequest: (input: {
361 readonly cwd: string;
362 readonly context?: SourceControlProvider.SourceControlProviderContext;
363 readonly reference: string;
364 }) => Effect.Effect<NormalizedBitbucketPullRequestRecord, BitbucketApiError>;
365 readonly getRepositoryCloneUrls: (input: {
366 readonly cwd: string;
367 readonly context?: SourceControlProvider.SourceControlProviderContext;
368 readonly repository: string;
369 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
370 readonly createRepository: (input: {
371 readonly cwd: string;
372 readonly repository: string;
373 readonly visibility: SourceControlRepositoryVisibility;
374 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
375 readonly createPullRequest: (input: {
376 readonly cwd: string;
377 readonly context?: SourceControlProvider.SourceControlProviderContext;
378 readonly baseBranch: string;
379 readonly headSelector: string;
380 readonly source?: SourceControlProvider.SourceControlRefSelector;
381 readonly target?: SourceControlProvider.SourceControlRefSelector;
382 readonly title: string;
383 readonly bodyFile: string;
384 }) => Effect.Effect<void, BitbucketApiError>;
385 readonly getDefaultBranch: (input: {
386 readonly cwd: string;
387 readonly context?: SourceControlProvider.SourceControlProviderContext;
388 }) => Effect.Effect<string | null, BitbucketApiError>;
389 readonly checkoutPullRequest: (input: {
390 readonly cwd: string;
391 readonly context?: SourceControlProvider.SourceControlProviderContext;
392 readonly reference: string;
393 readonly force?: boolean;
394 }) => Effect.Effect<void, BitbucketApiError>;
395 }
396>()("@t3tools/source-control-bitbucket/server/BitbucketApi") {}
397​
398function nonEmpty(value: string | undefined): Option.Option<string> {
399 const trimmed = value?.trim();
400 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
402​
403function normalizeChangeRequestId(reference: string): string {
404 const trimmed = reference.trim().replace(/^#/, "");
405 const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec(
406 trimmed,
407 );
408 return urlMatch?.[1] ?? trimmed;
410​
411function sourceWorkspace(input: {
412 readonly headSelector: string;
413 readonly source?: SourceControlProvider.SourceControlRefSelector;
414}): string | undefined {
415 if (input.source?.owner) return input.source.owner;
416 return SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner;
418​
419function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> {
420 switch (state) {
421 case "open":
422 return ["OPEN"];
423 case "closed":
424 return ["DECLINED", "SUPERSEDED"];
425 case "merged":
426 return ["MERGED"];
427 case "all":
428 return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"];
429 }
431​
432function bitbucketQueryString(filters: ReadonlyArray<string>): string {
433 return filters.join(" AND ");
435​
436function bitbucketStateFilter(states: ReadonlyArray<string>): string {
437 return states.length === 1
438 ? `state = "${states[0]}"`
439 : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`;
441​
442function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null {
443 const normalized = value.trim().replace(/\.git$/u, "");
444 const parts = normalized.split("/").filter((part) => part.length > 0);
445 if (parts.length < 2) return null;
446 const workspace = parts.at(-2);
447 const repoSlug = parts.at(-1);
448 return workspace && repoSlug ? { workspace, repoSlug } : null;
450​
451function requireRepositoryLocator(
452 repository: string,
453): Effect.Effect<BitbucketRepositoryLocator, BitbucketApiError> {
454 const locator = parseBitbucketRepositorySlug(repository);
455 return locator
456 ? Effect.succeed(locator)
457 : Effect.fail(
458 new BitbucketRepositoryLocatorError({
459 repository,
460 }),
461 );
463​
464function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null {
465 const trimmed = remoteUrl.trim();
466 const scpMatch = /^[a-zA-Z0-9._-]+@[^:/]+:(.+)$/.exec(trimmed);
467 if (scpMatch?.[1]) {
468 return parseBitbucketRepositorySlug(scpMatch[1]);
469 }
470​
471 try {
472 return parseBitbucketRepositorySlug(new URL(trimmed).pathname);
473 } catch {
474 return null;
475 }
477​
478function normalizeRepositoryCloneUrls(
479 raw: typeof RawBitbucketRepositorySchema.Type,
480): SourceControlRepositoryCloneUrls {
481 const httpClone =
482 raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ??
483 raw.links.html?.href;
484 const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href;
485​
486 return {
487 nameWithOwner: raw.full_name,
488 url: httpClone ?? raw.links.html?.href ?? raw.full_name,
489 sshUrl: sshClone ?? httpClone ?? raw.full_name,
490 };
492​
493function defaultChangeRequestTargetBranch(input: {
494 readonly repository: typeof RawBitbucketRepositorySchema.Type;
495 readonly branchingModel: typeof RawBitbucketBranchingModelSchema.Type | null;
496}): string | null {
497 const repositoryMainBranch = input.repository.mainbranch?.name ?? null;
498 const development = input.branchingModel?.development;
499 if (!development || development.use_mainbranch === true || development.is_valid === false) {
500 return repositoryMainBranch;
501 }
502​
503 const developmentBranch = development.branch?.name?.trim() ?? development.name?.trim() ?? "";
504 if (developmentBranch.length === 0 || developmentBranch === "null") {
505 return repositoryMainBranch;
506 }
507​
508 return developmentBranch;
510​
511function shouldPreferSshRemote(originRemoteUrl: string | null): boolean {
512 if (!originRemoteUrl) return false;
513 return isSshRemoteUrl(originRemoteUrl);
515​
516function selectCloneUrl(input: {
517 readonly cloneUrls: SourceControlRepositoryCloneUrls;
518 readonly originRemoteUrl: string | null;
519}): string {
520 return shouldPreferSshRemote(input.originRemoteUrl)
521 ? input.cloneUrls.sshUrl
522 : input.cloneUrls.url;
524​
525function checkoutBranchName(input: {
526 readonly pullRequestId: number;
527 readonly headBranch: string;
528 readonly isCrossRepository: boolean;
529}): string {
530 if (!input.isCrossRepository) {
531 return input.headBranch;
532 }
533​
534 return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`;
536​
537function repositoryNameWithOwner(
538 repository: Schema.Schema.Type<typeof BitbucketPullRequestSchema>["source"]["repository"],
539): string | null {
540 const fullName = repository?.full_name?.trim() ?? "";
541 return fullName.length > 0 ? fullName : null;
543​
544function repositoryOwnerName(repositoryName: string): string {
545 return repositoryName.split("/")[0]?.trim() || "bitbucket";
547​
548type BitbucketCredential =
549 | { readonly kind: "access-token"; readonly accessToken: string }
550 | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string };
551​
552/** Invalid header values can throw errors containing credentials; treat them as unset. */
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;
568​
569const resolveCredential = Effect.fn("BitbucketApi.resolveCredential")(function* (
570 settings: BitbucketClient.BitbucketSettings,
571 secretProviders: ServerSettings["secretProviders"],
572 env: Config.Success<typeof BitbucketApiEnvConfig>,
573) {
574 const accessToken = yield* requireSettingsSecretLiteral(settings.accessToken, secretProviders);
575 const apiToken = HEADER_SAFE.test(accessToken)
576 ? ""
577 : yield* requireSettingsSecretLiteral(settings.apiToken, secretProviders);
578 return (
579 credentialFrom({ accessToken, email: settings.email, apiToken }) ??
580 credentialFrom({
581 accessToken: Option.getOrElse(env.accessToken, () => ""),
582 email: Option.getOrElse(env.email, () => ""),
583 apiToken: Option.getOrElse(env.apiToken, () => ""),
584 })
585 );
586});
587​
588function authFromCredential(credential: BitbucketCredential | null): SourceControlProviderAuth {
589 if (credential?.kind === "access-token") {
590 return {
591 status: "unknown",
592 account: Option.none(),
593 host: Option.some("bitbucket.org"),
594 detail: Option.some("An access token is configured."),
595 };
596 }
597​
598 if (credential?.kind === "api-token") {
599 return {
600 status: "unknown",
601 account: Option.some(credential.email),
602 host: Option.some("bitbucket.org"),
603 detail: Option.some("An API token is configured."),
604 };
605 }
606​
607 return {
608 status: "unauthenticated",
609 account: Option.none(),
610 host: Option.some("bitbucket.org"),
611 detail: Option.some(
612 "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.",
613 ),
614 };
616​
617function originOf(value: string): string | null {
618 try {
619 return new URL(value).origin;
620 } catch {
621 return null;
622 }
624​
625const responseError = Effect.fn("BitbucketApi.responseError")(function* (
626 operation: BitbucketApiOperation,
627 response: HttpClientResponse.HttpClientResponse,
628): Effect.fn.Return<never, BitbucketApiError> {
629 const now = yield* Clock.currentTimeMillis;
630 const retryAt = SourceControlRateLimit.retryAtFromHeader(response.headers["retry-after"], now);
631 const collected = yield* collectUint8StreamText({
632 stream: response.stream,
633 maxBytes: DEFAULT_MAX_RESPONSE_BYTES,
634 }).pipe(
635 Effect.mapError(
636 (cause) =>
637 new BitbucketResponseBodyReadError({
638 operation,
639 status: response.status,
640 retryAt,
641 cause,
642 }),
643 ),
644 );
645 return yield* new BitbucketResponseError({
646 operation,
647 status: response.status,
648 responseBodyLength: collected.text.length,
649 retryAt,
650 });
651});
652​
653/** @public */
654export const make = Effect.gen(function* () {
655 const config = yield* BitbucketApiEnvConfig;
656 const host = yield* SourceControlHost.SourceControlHost;
657 const git = host.git;
658 const httpClient = yield* HttpClient.HttpClient;
659 const fileSystem = yield* FileSystem.FileSystem;
660 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
661 const slots = yield* Semaphore.make(CONCURRENCY);
662 // The repository host PullRequestService keys its own backoff under, so both share one pause.
663 const rateLimitKey = {
664 provider: SourceControlProviderKind.make("bitbucket"),
665 host: "bitbucket.org",
666 };
667​
668 const servesHost = yield* BitbucketServesHost;
669​
670 const servesProvider = (provider: SourceControlProviderInfo | null) =>
671 provider?.kind === "bitbucket" && servesHost(URL.parse(provider.baseUrl)?.hostname ?? "");
672​
673 const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`;
674​
675 const currentCredential = host.settings.getWithSecrets(BitbucketClient.definition.kind).pipe(
676 Effect.map((settings) => ({
677 bitbucket: readSourceControlHostSettings(
678 BitbucketClient.settings,
679 settings.sourceControlHosts[BitbucketClient.definition.kind],
680 ),
681 secretProviders: settings.secretProviders,
682 })),
683 Effect.catch((error) =>
684 // No cause: a settings decode error can quote a hand-edited token.
685 Effect.logWarning("failed to read Bitbucket credentials from settings", {
686 operation: error.operation,
687 }).pipe(
688 Effect.as({
689 bitbucket: readSourceControlHostSettings(BitbucketClient.settings, {}),
690 secretProviders: {},
691 }),
692 ),
693 ),
694 Effect.flatMap(({ bitbucket, secretProviders }) =>
695 resolveCredential(bitbucket, secretProviders, config),
696 ),
697 );
698​
699 const withAuth = (request: HttpClientRequest.HttpClientRequest) =>
700 currentCredential.pipe(
701 Effect.map((credential) =>
702 credential === null
703 ? request
704 : credential.kind === "access-token"
705 ? request.pipe(HttpClientRequest.bearerToken(credential.accessToken))
706 : request.pipe(HttpClientRequest.basicAuth(credential.email, credential.apiToken)),
707 ),
708 );
709​
710 /**
711 * Every request waits for one of the shared slots, then honors the pause a 429 starts for the
712 * whole host. Each queued lookup would otherwise spend its own 429 and keep the quota exhausted.
713 * Background reads fail at once while paused; a write or an interactive read has a user waiting
714 * on it, so it still goes out.
715 */
716 const throttled = Effect.fn("BitbucketApi.throttled")(
717 function* <A, R>(method: string, effect: Effect.Effect<A, BitbucketApiError, R>) {
718 const interactive = yield* SourceControlRateLimit.Interactive;
719 const lease = yield* limits.check(
720 rateLimitKey,
721 interactive || method !== "GET" ? { allowPaused: true } : undefined,
722 );
723 return yield* effect.pipe(
724 Effect.tap(() => limits.recordSuccess({ ...rateLimitKey, lease })),
725 Effect.tapError((error) =>
726 (error._tag === "BitbucketResponseError" ||
727 error._tag === "BitbucketResponseBodyReadError") &&
728 error.status === 429
729 ? limits.recordRateLimit({ ...rateLimitKey, lease, retryAt: error.retryAt })
730 : Effect.void,
731 ),
732 );
733 },
734 (effect) => slots.withPermit(effect),
735 );
736​
737 const decodeResponse = <S extends Schema.Top>(
738 operation: BitbucketApiOperation,
739 schema: S,
740 response: HttpClientResponse.HttpClientResponse,
741 ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> =>
742 HttpClientResponse.matchStatus({
743 "2xx": (success) =>
744 HttpClientResponse.schemaBodyJson(schema)(success).pipe(
745 Effect.mapError(
746 (cause) =>
747 new BitbucketResponseDecodeError({
748 operation,
749 status: success.status,
750 cause,
751 }),
752 ),
753 ),
754 orElse: (failed) => responseError(operation, failed),
755 })(response);
756​
757 const executeJson = <S extends Schema.Top>(
758 operation: BitbucketApiOperation,
759 request: HttpClientRequest.HttpClientRequest,
760 schema: S,
761 ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> =>
762 throttled(
763 request.method,
764 withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe(
765 Effect.flatMap((authed) =>
766 httpClient
767 .execute(authed)
768 .pipe(Effect.mapError((cause) => new BitbucketRequestError({ operation, cause }))),
769 ),
770 Effect.flatMap((response) => decodeResponse(operation, schema, response)),
771 Effect.timeout(BITBUCKET_API_REQUEST_TIMEOUT),
772 Effect.catchTags({
773 TimeoutError: (cause) => Effect.fail(new BitbucketRequestError({ operation, cause })),
774 }),
775 ),
776 );
777​
778 const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: {
779 readonly cwd: string;
780 readonly reference?: string;
781 readonly context?: SourceControlProvider.SourceControlProviderContext;
782 readonly repository?: string;
783 }) {
784 if (input.context?.provider.kind === "bitbucket" && !servesProvider(input.context.provider)) {
785 // Falling back to another remote could act on a different repository.
786 return yield* new BitbucketRepositoryRemoteNotFoundError({ cwd: input.cwd });
787 }
788 if (input.repository !== undefined) {
789 return yield* requireRepositoryLocator(input.repository);
790 }
791​
792 const url = input.reference ? URL.parse(input.reference) : null;
793 const link = url ? definition.parseChangeRequestUrl(url) : null;
794 if (link) return yield* requireRepositoryLocator(link.repository);
795​
796 const fromContext =
797 input.context?.provider.kind === "bitbucket"
798 ? parseBitbucketRemoteUrl(input.context.remoteUrl)
799 : null;
800 if (fromContext) return fromContext;
801​
802 const listRemotes = yield* git.remotes(input.cwd).pipe(
803 Effect.mapError(
804 (cause) =>
805 new BitbucketRepositoryVcsResolveError({
806 cwd: input.cwd,
807 cause,
808 }),
809 ),
810 );
811 const remotes = yield* listRemotes.pipe(
812 Effect.mapError(
813 (cause) =>
814 new BitbucketRepositoryRemotesListError({
815 cwd: input.cwd,
816 cause,
817 }),
818 ),
819 );
820​
821 for (const remote of remotes.remotes) {
822 if (!servesProvider(recognizeRemoteWith(definition, remote.url))) continue;
823 const parsed = parseBitbucketRemoteUrl(remote.url);
824 if (parsed) return parsed;
825 }
826​
827 return yield* new BitbucketRepositoryRemoteNotFoundError({
828 cwd: input.cwd,
829 });
830 });
831​
832 const getRepositoryFromLocator = (repository: BitbucketRepositoryLocator) =>
833 executeJson(
834 "getRepository",
835 HttpClientRequest.get(
836 apiUrl(
837 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`,
838 ),
839 ),
840 RawBitbucketRepositorySchema,
841 );
842​
843 const getRepository = (input: {
844 readonly cwd: string;
845 readonly context?: SourceControlProvider.SourceControlProviderContext;
846 readonly repository?: string;
847 }) => resolveRepository(input).pipe(Effect.flatMap(getRepositoryFromLocator));
848​
849 const getBranchingModelFromLocator = (repository: BitbucketRepositoryLocator) =>
850 executeJson(
851 "getBranchingModel",
852 HttpClientRequest.get(
853 apiUrl(
854 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/branching-model`,
855 ),
856 ),
857 RawBitbucketBranchingModelSchema,
858 );
859​
860 const getRawPullRequestFromRepository = (
861 repository: BitbucketRepositoryLocator,
862 reference: string,
863 ) =>
864 executeJson(
865 "getPullRequest",
866 HttpClientRequest.get(
867 apiUrl(
868 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`,
869 ),
870 ),
871 BitbucketPullRequestSchema,
872 );
873​
874 const getRawPullRequest = (input: {
875 readonly cwd: string;
876 readonly context?: SourceControlProvider.SourceControlProviderContext;
877 readonly reference: string;
878 }) =>
879 resolveRepository(input).pipe(
880 Effect.flatMap((repository) => getRawPullRequestFromRepository(repository, input.reference)),
881 );
882​
883 const readConfigValueNullable = (cwd: string, key: string) =>
884 git.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null));
885​
886 const resolveCheckoutRemote = Effect.fn("BitbucketApi.resolveCheckoutRemote")(function* (input: {
887 readonly cwd: string;
888 readonly context?: SourceControlProvider.SourceControlProviderContext;
889 readonly destinationRepository: BitbucketRepositoryLocator;
890 readonly sourceRepositoryName: string;
891 readonly isCrossRepository: boolean;
892 }) {
893 if (!input.isCrossRepository) {
894 const context = input.context?.provider.kind === "bitbucket" ? input.context : undefined;
895 const remoteName =
896 context?.remoteName ??
897 (yield* git.resolvePrimaryRemoteName(input.cwd).pipe(Effect.orElseSucceed(() => null)));
898 if (remoteName) {
899 const remoteUrl =
900 context?.remoteUrl ??
901 (yield* readConfigValueNullable(input.cwd, `remote.${remoteName}.url`));
902 const repository =
903 remoteUrl && (context || recognizeRemoteWith(definition, remoteUrl))
904 ? parseBitbucketRemoteUrl(remoteUrl)
905 : null;
906 if (
907 repository &&
908 `${repository.workspace}/${repository.repoSlug}`.toLowerCase() ===
909 input.sourceRepositoryName.toLowerCase()
910 ) {
911 return remoteName;
912 }
913 }
914 }
915​
916 const cloneUrls = yield* getRepository({
917 cwd: input.cwd,
918 repository: input.sourceRepositoryName,
919 ...(input.context ? { context: input.context } : {}),
920 }).pipe(Effect.map(normalizeRepositoryCloneUrls));
921 const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url");
922 return yield* git.ensureRemote({
923 cwd: input.cwd,
924 preferredName: input.isCrossRepository
925 ? repositoryOwnerName(input.sourceRepositoryName)
926 : input.destinationRepository.workspace,
927 url: selectCloneUrl({ cloneUrls, originRemoteUrl }),
928 });
929 });
930​
931 const apiOrigin = originOf(config.baseUrl);
932​
933 const trustedUrl = (value: string): string | null => {
934 if (!/^https?:\/\//u.test(value)) return apiUrl(value);
935 const origin = originOf(value);
936 return origin !== null && origin === apiOrigin ? value : null;
937 };
938​
939 /** Redirects forward auth headers; restrict them to the configured Bitbucket. */
940 const send = (input: {
941 readonly method: "GET" | "POST" | "PUT" | "DELETE";
942 readonly url: string;
943 readonly body?: string;
944 readonly redirects: number;
945 }): Effect.Effect<HttpClientResponse.HttpClientResponse, BitbucketApiError> => {
946 const url = trustedUrl(input.url);
947 if (url === null) {
948 return Effect.fail(
949 new BitbucketUntrustedUrlError({ host: originOf(input.url) ?? "an unreadable url" }),
950 );
951 }
952 const base =
953 input.method === "GET"
954 ? HttpClientRequest.get(url)
955 : input.method === "POST"
956 ? HttpClientRequest.post(url)
957 : input.method === "DELETE"
958 ? HttpClientRequest.make("DELETE")(url)
959 : HttpClientRequest.put(url);
960 // Diff endpoints return patches, not JSON.
961 const withBody =
962 input.body === undefined
963 ? base
964 : base.pipe(HttpClientRequest.bodyText(input.body, "application/json"));
965 return withAuth(withBody).pipe(
966 Effect.flatMap((request) =>
967 httpClient
968 .execute(request)
969 .pipe(
970 Effect.mapError(
971 (cause): BitbucketApiError =>
972 new BitbucketRequestError({ operation: "request", cause }),
973 ),
974 ),
975 ),
976 Effect.flatMap((response) => {
977 const location = response.headers.location;
978 if (
979 response.status >= 300 &&
980 response.status < 400 &&
981 location !== undefined &&
982 input.redirects < MAX_REDIRECTS
983 ) {
984 return send({
985 ...input,
986 url: new URL(location, url).toString(),
987 redirects: input.redirects + 1,
988 });
989 }
990 return Effect.succeed(response);
991 }),
992 );
993 };
994​
995 const request: BitbucketApi["Service"]["request"] = (input) =>
996 throttled(
997 input.method,
998 send({ ...input, redirects: 0 }).pipe(
999 Effect.flatMap((response) =>
1000 HttpClientResponse.matchStatus({
1001 // Read through the body stream rather than `text`, so an oversized diff is stopped as
1002 // it arrives instead of being materialized whole and then cut.
1003 "2xx": (success) =>
1004 collectUint8StreamText({
1005 stream: success.stream,
1006 maxBytes: input.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
1007 }).pipe(
1008 Effect.mapError(
1009 (cause) =>
1010 new BitbucketResponseBodyReadError({
1011 operation: "request",
1012 status: success.status,
1013 cause,
1014 }),
1015 ),
1016 Effect.map((collected) => ({
1017 body: collected.text,
1018 truncated: collected.truncated,
1019 })),
1020 ),
1021 orElse: (failed) => responseError("request", failed),
1022 })(response),
1023 ),
1024 // This also caps redirect hops: no request can finish headers then stall its body forever.
1025 Effect.timeout(BITBUCKET_API_REQUEST_TIMEOUT),
1026 Effect.catchTags({
1027 TimeoutError: (cause) =>
1028 Effect.fail(new BitbucketRequestError({ operation: "request", cause })),
1029 }),
1030 ),
1031 );
1032​
1033 return BitbucketApi.of({
1034 request,
1035 // Bitbucket answers anonymous /user with 401, so without a credential there is nothing to ask.
1036 probeAuth: currentCredential.pipe(
1037 Effect.flatMap((credential) =>
1038 credential === null
1039 ? Effect.succeed(authFromCredential(null))
1040 : executeJson(
1041 "probeAuth",
1042 HttpClientRequest.get(apiUrl("/user")),
1043 BitbucketUserSchema,
1044 ).pipe(
1045 Effect.map((user) => ({
1046 status: "authenticated" as const,
1047 account: nonEmpty(user.username ?? user.display_name ?? user.account_id),
1048 host: Option.some("bitbucket.org"),
1049 detail: Option.none<string>(),
1050 })),
1051 Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))),
1052 ),
1053 ),
1054 Effect.catchTags({
1055 SettingsSecretUnresolvedError: (error) =>
1056 Effect.succeed<SourceControlProviderAuth>({
1057 status: "unauthenticated",
1058 account: Option.none(),
1059 host: Option.some("bitbucket.org"),
1060 detail: Option.some(error.message),
1061 }),
1062 }),
1063 ),
1064 listPullRequests: Effect.fn("BitbucketApi.listPullRequests")(function (input) {
1065 return resolveRepository(input).pipe(
1066 Effect.flatMap((repository) => {
1067 const states = toBitbucketStates(input.state);
1068 const query: Record<string, string | ReadonlyArray<string>> = {
1069 pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))),
1070 sort: "-updated_on",
1071 q: bitbucketQueryString([
1072 `source.branch.name = "${SourceControlProvider.sourceBranch(input).replaceAll('"', '\\"')}"`,
1073 bitbucketStateFilter(states),
1074 ]),
1075 state: states,
1076 };
1077​
1078 return executeJson(
1079 "listPullRequests",
1080 HttpClientRequest.get(
1081 apiUrl(
1082 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
1083 ),
1084 { urlParams: query },
1085 ),
1086 BitbucketPullRequestListSchema,
1087 );
1088 }),
1089 Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)),
1090 );
1091 }),
1092 getPullRequest: Effect.fn("BitbucketApi.getPullRequest")(function (input) {
1093 return getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord));
1094 }),
1095 getRepositoryCloneUrls: Effect.fn("BitbucketApi.getRepositoryCloneUrls")(function (input) {
1096 return getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls));
1097 }),
1098 createRepository: Effect.fn("BitbucketApi.createRepository")(function (input) {
1099 return requireRepositoryLocator(input.repository).pipe(
1100 Effect.flatMap((repository) =>
1101 executeJson(
1102 "createRepository",
1103 HttpClientRequest.post(
1104 apiUrl(
1105 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`,
1106 ),
1107 ).pipe(
1108 HttpClientRequest.bodyJsonUnsafe({
1109 scm: "git",
1110 is_private: input.visibility === "private",
1111 }),
1112 ),
1113 RawBitbucketRepositorySchema,
1114 ),
1115 ),
1116 Effect.map(normalizeRepositoryCloneUrls),
1117 );
1118 }),
1119 createPullRequest: Effect.fn("BitbucketApi.createPullRequest")(function* (input) {
1120 const repository = yield* resolveRepository(input);
1121 const description = yield* fileSystem.readFileString(input.bodyFile).pipe(
1122 Effect.mapError(
1123 (cause) =>
1124 new BitbucketPullRequestBodyReadError({
1125 cwd: input.cwd,
1126 bodyFile: input.bodyFile,
1127 cause,
1128 }),
1129 ),
1130 );
1131 const sourceOwner = sourceWorkspace(input);
1132 const body = {
1133 title: input.title,
1134 description,
1135 source: {
1136 branch: {
1137 name: SourceControlProvider.sourceBranch(input),
1138 },
1139 ...(sourceOwner
1140 ? {
1141 repository: {
1142 full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`,
1143 },
1145 : {}),
1146 },
1147 destination: {
1148 branch: {
1149 name: input.target?.refName ?? input.baseBranch,
1150 },
1151 },
1152 };
1153​
1154 yield* executeJson(
1155 "createPullRequest",
1156 HttpClientRequest.post(
1157 apiUrl(
1158 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
1159 ),
1160 ).pipe(HttpClientRequest.bodyJsonUnsafe(body)),
1161 BitbucketPullRequestSchema,
1162 );
1163 }),
1164 getDefaultBranch: Effect.fn("BitbucketApi.getDefaultBranch")(function (input) {
1165 return resolveRepository(input).pipe(
1166 Effect.flatMap((locator) =>
1167 Effect.all(
1169 repository: getRepositoryFromLocator(locator),
1170 branchingModel: getBranchingModelFromLocator(locator).pipe(
1171 Effect.orElseSucceed(
1172 (): typeof RawBitbucketBranchingModelSchema.Type | null => null,
1173 ),
1174 ),
1175 },
1176 { concurrency: "unbounded" },
1177 ),
1178 ),
1179 Effect.map(defaultChangeRequestTargetBranch),
1180 );
1181 }),
1182 checkoutPullRequest: Effect.fn("BitbucketApi.checkoutPullRequest")(
1183 function* (input) {
1184 const destinationRepository = yield* resolveRepository(input);
1185 const pullRequest = yield* getRawPullRequestFromRepository(
1186 destinationRepository,
1187 input.reference,
1188 );
1189 const destinationRepositoryName =
1190 repositoryNameWithOwner(pullRequest.destination.repository) ??
1191 `${destinationRepository.workspace}/${destinationRepository.repoSlug}`;
1192 const sourceRepositoryName =
1193 repositoryNameWithOwner(pullRequest.source.repository) ?? destinationRepositoryName;
1194 const isCrossRepository = sourceRepositoryName !== destinationRepositoryName;
1195 const remoteName = yield* resolveCheckoutRemote({
1196 cwd: input.cwd,
1197 destinationRepository,
1198 sourceRepositoryName,
1199 isCrossRepository,
1200 ...(input.context ? { context: input.context } : {}),
1201 });
1202 const remoteBranch = pullRequest.source.branch.name;
1203 const localBranch = checkoutBranchName({
1204 pullRequestId: pullRequest.id,
1205 headBranch: remoteBranch,
1206 isCrossRepository,
1207 });
1208 const localBranchNames = yield* git.listLocalBranchNames(input.cwd);
1209 const localBranchExists = localBranchNames.includes(localBranch);
1210​
1211 if (input.force === true || !localBranchExists) {
1212 yield* git.fetchRemoteBranch({
1213 cwd: input.cwd,
1214 remoteName,
1215 remoteBranch,
1216 localBranch,
1217 });
1218 } else {
1219 yield* git.fetchRemoteTrackingBranch({
1220 cwd: input.cwd,
1221 remoteName,
1222 remoteBranch,
1223 });
1225​
1226 yield* git.setBranchUpstream({
1227 cwd: input.cwd,
1228 branch: localBranch,
1229 remoteName,
1230 remoteBranch,
1231 });
1232 yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch }));
1233 },
1234 (effect, input) =>
1235 effect.pipe(
1236 Effect.mapError((cause) =>
1237 isBitbucketApiError(cause)
1238 ? cause
1239 : new BitbucketCheckoutError({
1240 cwd: input.cwd,
1241 reference: input.reference,
1242 cause,
1243 }),
1244 ),
1245 ),
1246 ),
1247 });
1248});
1249​
1250export const layer = Layer.effect(BitbucketApi, make);