1import * as Clock from "effect/Clock";
2import * as Config from "effect/Config";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import {
10 NonNegativeInt,
11 TrimmedNonEmptyString,
12 type SourceControlProviderAuth,
13 type SourceControlRepositoryCloneUrls,
14 type SourceControlRepositoryVisibility,
15} from "@t3tools/contracts";
16import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
17import { sanitizeBranchFragment } from "@t3tools/shared/git";
18import {
19 detectSourceControlProviderFromRemoteUrl,
20 isSshRemoteUrl,
21} from "@t3tools/shared/sourceControl";
22
23import {
24 BitbucketPullRequestListSchema,
25 BitbucketPullRequestSchema,
26 normalizeBitbucketPullRequestRecord,
27 type NormalizedBitbucketPullRequestRecord,
28} from "./bitbucketPullRequests.ts";
29import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
30import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
31import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
32import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition";
33
34import * as BitbucketClient from "../client/definition.ts";
35import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit";
36
37const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0";
38/** A response body past this is cut short, so one huge diff cannot exhaust the server. */
39const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
40/** Bitbucket redirects a diff once; this leaves room without following a chain forever. */
41const MAX_REDIRECTS = 3;
42/** A single API operation must bound both header delivery and body consumption. */
43const BITBUCKET_API_REQUEST_TIMEOUT = "30 seconds";
44
45const BitbucketApiEnvConfig = Config.all({
46 baseUrl: Config.String("T3CODE_BITBUCKET_API_BASE_URL").pipe(
47 Config.withDefault(DEFAULT_API_BASE_URL),
48 ),
49 accessToken: Config.String("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option),
50 email: Config.String("T3CODE_BITBUCKET_EMAIL").pipe(Config.option),
51 apiToken: Config.String("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option),
52});
53
54const BitbucketApiOperation = Schema.Literals([
55 "resolveRepository",
56 "getRepository",
57 "getBranchingModel",
58 "getPullRequest",
59 "listPullRequests",
60 "createRepository",
61 "createPullRequest",
62 "probeAuth",
63 "checkoutPullRequest",
64 // The raw escape hatch. Callers name their own operation in their own error, the way the
65 // pull request wrappers do on top of `gh` and `glab`.
66 "request",
67]);
68type BitbucketApiOperation = typeof BitbucketApiOperation.Type;
69
70export class BitbucketRepositoryLocatorError extends Schema.TaggedError<BitbucketRepositoryLocatorError>()(
71 "BitbucketRepositoryLocatorError",
72 {
73 repository: Schema.String,
74 },
75) {
76 static readonly detail = "Bitbucket repositories must be specified as workspace/repository.";
77
78 get detail(): string {
79 return BitbucketRepositoryLocatorError.detail;
80 }
81
82 override get message(): string {
83 return `Bitbucket API failed: ${this.detail}`;
84 }
85}
86
87export class BitbucketRequestError extends Schema.TaggedError<BitbucketRequestError>()(
88 "BitbucketRequestError",
89 {
90 operation: BitbucketApiOperation,
91 cause: Schema.Defect(),
92 },
93) {
94 get detail(): string {
95 return "Failed to send the Bitbucket request.";
96 }
97
98 override get message(): string {
99 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
100 }
101}
102
103export class BitbucketResponseError extends Schema.TaggedError<BitbucketResponseError>()(
104 "BitbucketResponseError",
105 {
106 operation: BitbucketApiOperation,
107 status: Schema.Int,
108 responseBodyLength: NonNegativeInt,
109 retryAt: Schema.optional(Schema.Number),
110 },
111) {
112 get detail(): string {
113 return `Bitbucket returned HTTP ${this.status}.`;
114 }
115
116 override get message(): string {
117 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
118 }
119}
120
121export class BitbucketResponseBodyReadError extends Schema.TaggedError<BitbucketResponseBodyReadError>()(
122 "BitbucketResponseBodyReadError",
123 {
124 operation: BitbucketApiOperation,
125 status: Schema.Int,
126 retryAt: Schema.optional(Schema.Number),
127 cause: Schema.Defect(),
128 },
129) {
130 get detail(): string {
131 return `Bitbucket returned HTTP ${this.status}.`;
132 }
133
134 override get message(): string {
135 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
136 }
137}
138
139export class BitbucketResponseDecodeError extends Schema.TaggedError<BitbucketResponseDecodeError>()(
140 "BitbucketResponseDecodeError",
141 {
142 operation: BitbucketApiOperation,
143 status: Schema.Int,
144 cause: Schema.Defect(),
145 },
146) {
147 get detail(): string {
148 return "Bitbucket returned invalid JSON for the requested resource.";
149 }
150
151 override get message(): string {
152 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
153 }
154}
155
156export class BitbucketRepositoryVcsResolveError extends Schema.TaggedError<BitbucketRepositoryVcsResolveError>()(
157 "BitbucketRepositoryVcsResolveError",
158 {
159 cwd: Schema.String,
160 cause: Schema.Defect(),
161 },
162) {
163 get detail(): string {
164 return `Failed to resolve VCS repository for ${this.cwd}.`;
165 }
166
167 override get message(): string {
168 return `Bitbucket API failed in resolveRepository: ${this.detail}`;
169 }
170}
171
172export class BitbucketRepositoryRemotesListError extends Schema.TaggedError<BitbucketRepositoryRemotesListError>()(
173 "BitbucketRepositoryRemotesListError",
174 {
175 cwd: Schema.String,
176 cause: Schema.Defect(),
177 },
178) {
179 get detail(): string {
180 return `Failed to list remotes for ${this.cwd}.`;
181 }
182
183 override get message(): string {
184 return `Bitbucket API failed in resolveRepository: ${this.detail}`;
185 }
186}
187
188export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedError<BitbucketRepositoryRemoteNotFoundError>()(
189 "BitbucketRepositoryRemoteNotFoundError",
190 {
191 cwd: Schema.String,
192 },
193) {
194 get detail(): string {
195 return `No Bitbucket repository remote was detected for ${this.cwd}.`;
196 }
197
198 override get message(): string {
199 return `Bitbucket API failed in resolveRepository: ${this.detail}`;
200 }
201}
202
203export class BitbucketPullRequestBodyReadError extends Schema.TaggedError<BitbucketPullRequestBodyReadError>()(
204 "BitbucketPullRequestBodyReadError",
205 {
206 cwd: Schema.String,
207 bodyFile: Schema.String,
208 cause: Schema.Defect(),
209 },
210) {
211 get detail(): string {
212 return `Failed to read pull request body file ${this.bodyFile}.`;
213 }
214
215 override get message(): string {
216 return `Bitbucket API failed in createPullRequest: ${this.detail}`;
217 }
218}
219
220export class BitbucketCheckoutError extends Schema.TaggedError<BitbucketCheckoutError>()(
221 "BitbucketCheckoutError",
222 {
223 cwd: Schema.String,
224 reference: Schema.String,
225 cause: Schema.Defect(),
226 },
227) {
228 get detail(): string {
229 return "Failed to check out the Bitbucket pull request.";
230 }
231
232 override get message(): string {
233 return `Bitbucket API failed in checkoutPullRequest: ${this.detail}`;
234 }
235}
236
237/**
238 * A url that does not belong to the configured Bitbucket. Refused rather than followed, because
239 * the request carries the account's credentials and a url that came back in a response — a
240 * pagination cursor, or the target of a redirect — is not this server's to trust.
241 */
242export class BitbucketUntrustedUrlError extends Schema.TaggedError<BitbucketUntrustedUrlError>()(
243 "BitbucketUntrustedUrlError",
244 {
245 /** The host only. A rejected hop is often a signed url, whose query carries a credential. */
246 host: Schema.String,
247 },
248) {
249 get detail(): string {
250 return `The response pointed at ${this.host}, outside the configured Bitbucket.`;
251 }
252
253 override get message(): string {
254 return `Bitbucket API failed in request: ${this.detail}`;
255 }
256}
257
258export const BitbucketApiError = Schema.Union([
259 BitbucketUntrustedUrlError,
260 BitbucketRepositoryLocatorError,
261 BitbucketRequestError,
262 BitbucketResponseError,
263 BitbucketResponseBodyReadError,
264 BitbucketResponseDecodeError,
265 BitbucketRepositoryVcsResolveError,
266 BitbucketRepositoryRemotesListError,
267 BitbucketRepositoryRemoteNotFoundError,
268 BitbucketPullRequestBodyReadError,
269 BitbucketCheckoutError,
270]);
271export type BitbucketApiError = typeof BitbucketApiError.Type;
272const isBitbucketApiError = Schema.is(BitbucketApiError);
273
274const RawBitbucketRepositorySchema = Schema.Struct({
275 full_name: TrimmedNonEmptyString,
276 links: Schema.Struct({
277 html: Schema.optional(
278 Schema.Struct({
279 href: TrimmedNonEmptyString,
280 }),
281 ),
282 clone: Schema.optional(
283 Schema.Array(
284 Schema.Struct({
285 name: TrimmedNonEmptyString,
286 href: TrimmedNonEmptyString,
287 }),
288 ),
289 ),
290 }),
291 mainbranch: Schema.optional(
292 Schema.NullOr(
293 Schema.Struct({
294 name: TrimmedNonEmptyString,
295 }),
296 ),
297 ),
298});
299
300const RawBitbucketBranchingModelSchema = Schema.Struct({
301 development: Schema.optional(
302 Schema.Struct({
303 branch: Schema.optional(
304 Schema.NullOr(
305 Schema.Struct({
306 name: Schema.optional(TrimmedNonEmptyString),
307 }),
308 ),
309 ),
310 is_valid: Schema.optional(Schema.Boolean),
311 name: Schema.optional(Schema.NullOr(Schema.String)),
312 use_mainbranch: Schema.optional(Schema.Boolean),
313 }),
314 ),
315});
316
317const BitbucketUserSchema = Schema.Struct({
318 username: Schema.optional(TrimmedNonEmptyString),
319 display_name: Schema.optional(TrimmedNonEmptyString),
320 account_id: Schema.optional(TrimmedNonEmptyString),
321});
322
323export interface BitbucketRepositoryLocator {
324 readonly workspace: string;
325 readonly repoSlug: string;
326}
327
328export class BitbucketApi extends Context.Service<
329 BitbucketApi,
330 {
331 readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
332
333 /**
334 * One authenticated request, returning the body verbatim. Bitbucket answers most endpoints
335 * with JSON and a few — a pull request diff, for one — with plain text, so the body is
336 * handed back undecoded for the caller to read as it sees fit.
337 */
338 readonly request: (input: {
339 readonly method: "GET" | "POST" | "PUT" | "DELETE";
340 /**
341 * A path below the API base, or a whole URL as a paged response reports its next page.
342 * A whole URL is refused unless it belongs to the configured Bitbucket.
343 */
344 readonly url: string;
345 /** A JSON document, for the endpoints that take one. */
346 readonly body?: string;
347 /** Response bytes to keep; past this the body comes back cut short and marked. */
348 readonly maxBytes?: number;
349 }) => Effect.Effect<{ readonly body: string; readonly truncated: boolean }, BitbucketApiError>;
350 readonly listPullRequests: (input: {
351 readonly cwd: string;
352 readonly context?: SourceControlProvider.SourceControlProviderContext;
353 readonly headSelector: string;
354 readonly source?: SourceControlProvider.SourceControlRefSelector;
355 readonly state: "open" | "closed" | "merged" | "all";
356 readonly limit?: number;
357 }) => Effect.Effect<ReadonlyArray<NormalizedBitbucketPullRequestRecord>, BitbucketApiError>;
358 readonly getPullRequest: (input: {
359 readonly cwd: string;
360 readonly context?: SourceControlProvider.SourceControlProviderContext;
361 readonly reference: string;
362 }) => Effect.Effect<NormalizedBitbucketPullRequestRecord, BitbucketApiError>;
363 readonly getRepositoryCloneUrls: (input: {
364 readonly cwd: string;
365 readonly context?: SourceControlProvider.SourceControlProviderContext;
366 readonly repository: string;
367 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
368 readonly createRepository: (input: {
369 readonly cwd: string;
370 readonly repository: string;
371 readonly visibility: SourceControlRepositoryVisibility;
372 }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;
373 readonly createPullRequest: (input: {
374 readonly cwd: string;
375 readonly context?: SourceControlProvider.SourceControlProviderContext;
376 readonly baseBranch: string;
377 readonly headSelector: string;
378 readonly source?: SourceControlProvider.SourceControlRefSelector;
379 readonly target?: SourceControlProvider.SourceControlRefSelector;
380 readonly title: string;
381 readonly bodyFile: string;
382 }) => Effect.Effect<void, BitbucketApiError>;
383 readonly getDefaultBranch: (input: {
384 readonly cwd: string;
385 readonly context?: SourceControlProvider.SourceControlProviderContext;
386 }) => Effect.Effect<string | null, BitbucketApiError>;
387 readonly checkoutPullRequest: (input: {
388 readonly cwd: string;
389 readonly context?: SourceControlProvider.SourceControlProviderContext;
390 readonly reference: string;
391 readonly force?: boolean;
392 }) => Effect.Effect<void, BitbucketApiError>;
393 }
394>()("@t3tools/source-control-bitbucket/server/BitbucketApi") {}
395
396function nonEmpty(value: string | undefined): Option.Option<string> {
397 const trimmed = value?.trim();
398 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
399}
400
401function normalizeChangeRequestId(reference: string): string {
402 const trimmed = reference.trim().replace(/^#/, "");
403 const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec(
404 trimmed,
405 );
406 return urlMatch?.[1] ?? trimmed;
407}
408
409function sourceWorkspace(input: {
410 readonly headSelector: string;
411 readonly source?: SourceControlProvider.SourceControlRefSelector;
412}): string | undefined {
413 if (input.source?.owner) return input.source.owner;
414 return SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner;
415}
416
417function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> {
418 switch (state) {
419 case "open":
420 return ["OPEN"];
421 case "closed":
422 return ["DECLINED", "SUPERSEDED"];
423 case "merged":
424 return ["MERGED"];
425 case "all":
426 return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"];
427 }
428}
429
430function bitbucketQueryString(filters: ReadonlyArray<string>): string {
431 return filters.join(" AND ");
432}
433
434function bitbucketStateFilter(states: ReadonlyArray<string>): string {
435 return states.length === 1
436 ? `state = "${states[0]}"`
437 : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`;
438}
439
440function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null {
441 const normalized = value.trim().replace(/\.git$/u, "");
442 const parts = normalized.split("/").filter((part) => part.length > 0);
443 if (parts.length < 2) return null;
444 const workspace = parts.at(-2);
445 const repoSlug = parts.at(-1);
446 return workspace && repoSlug ? { workspace, repoSlug } : null;
447}
448
449function requireRepositoryLocator(
450 repository: string,
451): Effect.Effect<BitbucketRepositoryLocator, BitbucketApiError> {
452 const locator = parseBitbucketRepositorySlug(repository);
453 return locator
454 ? Effect.succeed(locator)
455 : Effect.fail(
456 new BitbucketRepositoryLocatorError({
457 repository,
458 }),
459 );
460}
461
462function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null {
463 const trimmed = remoteUrl.trim();
464 const scpMatch = /^[a-zA-Z0-9._-]+@[^:/]+:(.+)$/.exec(trimmed);
465 if (scpMatch?.[1]) {
466 return parseBitbucketRepositorySlug(scpMatch[1]);
467 }
468
469 try {
470 return parseBitbucketRepositorySlug(new URL(trimmed).pathname);
471 } catch {
472 return null;
473 }
474}
475
476function normalizeRepositoryCloneUrls(
477 raw: typeof RawBitbucketRepositorySchema.Type,
478): SourceControlRepositoryCloneUrls {
479 const httpClone =
480 raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ??
481 raw.links.html?.href;
482 const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href;
483
484 return {
485 nameWithOwner: raw.full_name,
486 url: httpClone ?? raw.links.html?.href ?? raw.full_name,
487 sshUrl: sshClone ?? httpClone ?? raw.full_name,
488 };
489}
490
491function defaultChangeRequestTargetBranch(input: {
492 readonly repository: typeof RawBitbucketRepositorySchema.Type;
493 readonly branchingModel: typeof RawBitbucketBranchingModelSchema.Type | null;
494}): string | null {
495 const repositoryMainBranch = input.repository.mainbranch?.name ?? null;
496 const development = input.branchingModel?.development;
497 if (!development || development.use_mainbranch === true || development.is_valid === false) {
498 return repositoryMainBranch;
499 }
500
501 const developmentBranch = development.branch?.name?.trim() ?? development.name?.trim() ?? "";
502 if (developmentBranch.length === 0 || developmentBranch === "null") {
503 return repositoryMainBranch;
504 }
505
506 return developmentBranch;
507}
508
509function shouldPreferSshRemote(originRemoteUrl: string | null): boolean {
510 if (!originRemoteUrl) return false;
511 return isSshRemoteUrl(originRemoteUrl);
512}
513
514function selectCloneUrl(input: {
515 readonly cloneUrls: SourceControlRepositoryCloneUrls;
516 readonly originRemoteUrl: string | null;
517}): string {
518 return shouldPreferSshRemote(input.originRemoteUrl)
519 ? input.cloneUrls.sshUrl
520 : input.cloneUrls.url;
521}
522
523function checkoutBranchName(input: {
524 readonly pullRequestId: number;
525 readonly headBranch: string;
526 readonly isCrossRepository: boolean;
527}): string {
528 if (!input.isCrossRepository) {
529 return input.headBranch;
530 }
531
532 return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`;
533}
534
535function repositoryNameWithOwner(
536 repository: Schema.Schema.Type<typeof BitbucketPullRequestSchema>["source"]["repository"],
537): string | null {
538 const fullName = repository?.full_name?.trim() ?? "";
539 return fullName.length > 0 ? fullName : null;
540}
541
542function repositoryOwnerName(repositoryName: string): string {
543 return repositoryName.split("/")[0]?.trim() || "bitbucket";
544}
545
546type BitbucketCredential =
547 | { readonly kind: "access-token"; readonly accessToken: string }
548 | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string };
549
550/**
551 * Visible ASCII only. A value the HTTP stack rejects makes it throw an error quoting the whole
552 * header, and that error travels to clients as a cause, so an unusable token is treated as unset.
553 */
554const HEADER_SAFE = /^[\x21-\x7e]+$/u;
555
556function credentialFrom(input: {
557 readonly accessToken: string;
558 readonly email: string;
559 readonly apiToken: string;
560}): BitbucketCredential | null {
561 if (HEADER_SAFE.test(input.accessToken)) {
562 return { kind: "access-token", accessToken: input.accessToken };
563 }
564 if (HEADER_SAFE.test(input.email) && HEADER_SAFE.test(input.apiToken)) {
565 return { kind: "api-token", email: input.email, apiToken: input.apiToken };
566 }
567 return null;
568}
569
570/**
571 * Credentials saved in settings win over the `T3CODE_BITBUCKET_*` environment variables, which
572 * stay as a fallback. Within each source the access token wins.
573 */
574function resolveCredential(
575 settings: BitbucketClient.BitbucketSettings,
576 env: Config.Success<typeof BitbucketApiEnvConfig>,
577): BitbucketCredential | null {
578 return (
579 credentialFrom(settings) ??
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 };
615}
616
617/** Null for anything that is not a url at all, which is never the configured Bitbucket. */
618function originOf(value: string): string | null {
619 try {
620 return new URL(value).origin;
621 } catch {
622 return null;
623 }
624}
625
626function responseError(
627 operation: BitbucketApiOperation,
628 response: HttpClientResponse.HttpClientResponse,
629): Effect.Effect<never, BitbucketApiError> {
630 // Bounded like any other body: an error response is no smaller than a successful one, and
631 // only its length is reported anyway.
632 return Effect.gen(function* () {
633 const now = yield* Clock.currentTimeMillis;
634 const retryAt = retryAtFromHeader(response.headers["retry-after"], now);
635 const collected = yield* collectUint8StreamText({
636 stream: response.stream,
637 maxBytes: DEFAULT_MAX_RESPONSE_BYTES,
638 }).pipe(
639 Effect.mapError(
640 (cause) =>
641 new BitbucketResponseBodyReadError({
642 operation,
643 status: response.status,
644 retryAt,
645 cause,
646 }),
647 ),
648 );
649 return yield* new BitbucketResponseError({
650 operation,
651 status: response.status,
652 responseBodyLength: collected.text.length,
653 retryAt,
654 });
655 });
656}
657
658/** @public Service construction is part of the canonical Effect module API. */
659export const make = Effect.gen(function* () {
660 const config = yield* BitbucketApiEnvConfig;
661 const host = yield* SourceControlHost.SourceControlHost;
662 const git = host.git;
663 const httpClient = yield* HttpClient.HttpClient;
664 const fileSystem = yield* FileSystem.FileSystem;
665
666 const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`;
667
668 // Read on every request so credentials saved in settings apply without a restart.
669 const currentCredential = host.settings.get.pipe(
670 Effect.map((settings) =>
671 resolveCredential(
672 readSourceControlHostSettings(
673 BitbucketClient.settings,
674 settings.sourceControlHosts[BitbucketClient.definition.kind],
675 ),
676 config,
677 ),
678 ),
679 Effect.catch((error) =>
680 // No cause: a settings decode error can quote a hand-edited token.
681 Effect.logWarning("failed to read Bitbucket credentials from settings", {
682 operation: error.operation,
683 }).pipe(
684 Effect.as(
685 resolveCredential(readSourceControlHostSettings(BitbucketClient.settings, {}), config),
686 ),
687 ),
688 ),
689 );
690
691 const withAuth = (request: HttpClientRequest.HttpClientRequest) =>
692 currentCredential.pipe(
693 Effect.map((credential) =>
694 credential === null
695 ? request
696 : credential.kind === "access-token"
697 ? request.pipe(HttpClientRequest.bearerToken(credential.accessToken))
698 : request.pipe(HttpClientRequest.basicAuth(credential.email, credential.apiToken)),
699 ),
700 );
701
702 const decodeResponse = <S extends Schema.Top>(
703 operation: BitbucketApiOperation,
704 schema: S,
705 response: HttpClientResponse.HttpClientResponse,
706 ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> =>
707 HttpClientResponse.matchStatus({
708 "2xx": (success) =>
709 HttpClientResponse.schemaBodyJson(schema)(success).pipe(
710 Effect.mapError(
711 (cause) =>
712 new BitbucketResponseDecodeError({
713 operation,
714 status: success.status,
715 cause,
716 }),
717 ),
718 ),
719 orElse: (failed) => responseError(operation, failed),
720 })(response);
721
722 const executeJson = <S extends Schema.Top>(
723 operation: BitbucketApiOperation,
724 request: HttpClientRequest.HttpClientRequest,
725 schema: S,
726 ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> =>
727 withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe(
728 Effect.flatMap(httpClient.execute),
729 Effect.mapError(
730 (cause) =>
731 new BitbucketRequestError({
732 operation,
733 cause,
734 }),
735 ),
736 Effect.flatMap((response) => decodeResponse(operation, schema, response)),
737 Effect.timeout(BITBUCKET_API_REQUEST_TIMEOUT),
738 Effect.catchTags({
739 TimeoutError: (cause) => Effect.fail(new BitbucketRequestError({ operation, cause })),
740 }),
741 );
742
743 const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: {
744 readonly cwd: string;
745 readonly context?: SourceControlProvider.SourceControlProviderContext;
746 readonly repository?: string;
747 }) {
748 if (input.repository !== undefined) {
749 return yield* requireRepositoryLocator(input.repository);
750 }
751
752 const fromContext =
753 input.context?.provider.kind === "bitbucket"
754 ? parseBitbucketRemoteUrl(input.context.remoteUrl)
755 : null;
756 if (fromContext) return fromContext;
757
758 const listRemotes = yield* git.remotes(input.cwd).pipe(
759 Effect.mapError(
760 (cause) =>
761 new BitbucketRepositoryVcsResolveError({
762 cwd: input.cwd,
763 cause,
764 }),
765 ),
766 );
767 const remotes = yield* listRemotes.pipe(
768 Effect.mapError(
769 (cause) =>
770 new BitbucketRepositoryRemotesListError({
771 cwd: input.cwd,
772 cause,
773 }),
774 ),
775 );
776
777 for (const remote of remotes.remotes) {
778 if (detectSourceControlProviderFromRemoteUrl(remote.url)?.kind !== "bitbucket") continue;
779 const parsed = parseBitbucketRemoteUrl(remote.url);
780 if (parsed) return parsed;
781 }
782
783 return yield* new BitbucketRepositoryRemoteNotFoundError({
784 cwd: input.cwd,
785 });
786 });
787
788 const getRepositoryFromLocator = (repository: BitbucketRepositoryLocator) =>
789 executeJson(
790 "getRepository",
791 HttpClientRequest.get(
792 apiUrl(
793 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`,
794 ),
795 ),
796 RawBitbucketRepositorySchema,
797 );
798
799 const getRepository = (input: {
800 readonly cwd: string;
801 readonly context?: SourceControlProvider.SourceControlProviderContext;
802 readonly repository?: string;
803 }) => resolveRepository(input).pipe(Effect.flatMap(getRepositoryFromLocator));
804
805 const getBranchingModelFromLocator = (repository: BitbucketRepositoryLocator) =>
806 executeJson(
807 "getBranchingModel",
808 HttpClientRequest.get(
809 apiUrl(
810 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/branching-model`,
811 ),
812 ),
813 RawBitbucketBranchingModelSchema,
814 );
815
816 const getRawPullRequestFromRepository = (
817 repository: BitbucketRepositoryLocator,
818 reference: string,
819 ) =>
820 executeJson(
821 "getPullRequest",
822 HttpClientRequest.get(
823 apiUrl(
824 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`,
825 ),
826 ),
827 BitbucketPullRequestSchema,
828 );
829
830 const getRawPullRequest = (input: {
831 readonly cwd: string;
832 readonly context?: SourceControlProvider.SourceControlProviderContext;
833 readonly reference: string;
834 }) =>
835 resolveRepository(input).pipe(
836 Effect.flatMap((repository) => getRawPullRequestFromRepository(repository, input.reference)),
837 );
838
839 const readConfigValueNullable = (cwd: string, key: string) =>
840 git.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null));
841
842 const resolveCheckoutRemote = Effect.fn("BitbucketApi.resolveCheckoutRemote")(function* (input: {
843 readonly cwd: string;
844 readonly context?: SourceControlProvider.SourceControlProviderContext;
845 readonly destinationRepository: BitbucketRepositoryLocator;
846 readonly sourceRepositoryName: string;
847 readonly isCrossRepository: boolean;
848 }) {
849 if (
850 input.context?.provider.kind === "bitbucket" &&
851 !input.isCrossRepository &&
852 parseBitbucketRemoteUrl(input.context.remoteUrl) !== null
853 ) {
854 return input.context.remoteName;
855 }
856
857 if (!input.isCrossRepository) {
858 const remoteName = yield* git
859 .resolvePrimaryRemoteName(input.cwd)
860 .pipe(Effect.orElseSucceed(() => null));
861 if (remoteName) return remoteName;
862 }
863
864 const cloneUrls = yield* getRepository({
865 cwd: input.cwd,
866 repository: input.sourceRepositoryName,
867 ...(input.context ? { context: input.context } : {}),
868 }).pipe(Effect.map(normalizeRepositoryCloneUrls));
869 const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url");
870 return yield* git.ensureRemote({
871 cwd: input.cwd,
872 preferredName: input.isCrossRepository
873 ? repositoryOwnerName(input.sourceRepositoryName)
874 : input.destinationRepository.workspace,
875 url: selectCloneUrl({ cloneUrls, originRemoteUrl }),
876 });
877 });
878
879 // A pull request's diff, diffstat and conflicts are served as redirects to a commit-range
880 // URL, and the client does not follow redirects unless asked. The hop stays on the same host,
881 // so the credentials travel with it.
882 /**
883 * The one host these credentials may be sent to. A url that came back inside a response — a
884 * pagination cursor, or the target of a redirect — is data, not instruction, so it is checked
885 * against this before the account's token travels with it.
886 */
887 const apiOrigin = originOf(config.baseUrl);
888
889 const trustedUrl = (value: string): string | null => {
890 if (!/^https?:\/\//u.test(value)) return apiUrl(value);
891 const origin = originOf(value);
892 return origin !== null && origin === apiOrigin ? value : null;
893 };
894
895 /**
896 * Redirects are followed here rather than by the client, which forwards every header to
897 * whatever host it is sent to. A pull request diff, diffstat and conflicts are all served as
898 * redirects, so they have to be followed — but only back to the same Bitbucket.
899 */
900 const send = (input: {
901 readonly method: "GET" | "POST" | "PUT" | "DELETE";
902 readonly url: string;
903 readonly body?: string;
904 readonly redirects: number;
905 }): Effect.Effect<HttpClientResponse.HttpClientResponse, BitbucketApiError> => {
906 const url = trustedUrl(input.url);
907 if (url === null) {
908 return Effect.fail(
909 new BitbucketUntrustedUrlError({ host: originOf(input.url) ?? "an unreadable url" }),
910 );
911 }
912 const base =
913 input.method === "GET"
914 ? HttpClientRequest.get(url)
915 : input.method === "POST"
916 ? HttpClientRequest.post(url)
917 : input.method === "DELETE"
918 ? HttpClientRequest.make("DELETE")(url)
919 : HttpClientRequest.put(url);
920 // No `Accept: application/json`: the diff endpoints answer with a patch, not JSON.
921 const withBody =
922 input.body === undefined
923 ? base
924 : base.pipe(HttpClientRequest.bodyText(input.body, "application/json"));
925 return withAuth(withBody).pipe(
926 Effect.flatMap(httpClient.execute),
927 Effect.mapError(
928 (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }),
929 ),
930 Effect.flatMap((response) => {
931 const location = response.headers.location;
932 if (
933 response.status >= 300 &&
934 response.status < 400 &&
935 location !== undefined &&
936 input.redirects < MAX_REDIRECTS
937 ) {
938 return send({
939 ...input,
940 url: new URL(location, url).toString(),
941 redirects: input.redirects + 1,
942 });
943 }
944 return Effect.succeed(response);
945 }),
946 );
947 };
948
949 const request: BitbucketApi["Service"]["request"] = (input) =>
950 send({ ...input, redirects: 0 }).pipe(
951 Effect.flatMap((response) =>
952 HttpClientResponse.matchStatus({
953 // Read through the body stream rather than `text`, so an oversized diff is stopped
954 // as it arrives instead of being materialized whole and then cut. The same collector
955 // the process runner bounds command output with.
956 "2xx": (success) =>
957 collectUint8StreamText({
958 stream: success.stream,
959 maxBytes: input.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
960 }).pipe(
961 Effect.mapError(
962 (cause) =>
963 new BitbucketResponseBodyReadError({
964 operation: "request",
965 status: success.status,
966 cause,
967 }),
968 ),
969 Effect.map((collected) => ({
970 body: collected.text,
971 truncated: collected.truncated,
972 })),
973 ),
974 orElse: (failed) => responseError("request", failed),
975 })(response),
976 ),
977 // This also caps redirect hops: no request can finish headers then stall its body forever.
978 Effect.timeout(BITBUCKET_API_REQUEST_TIMEOUT),
979 Effect.catchTags({
980 TimeoutError: (cause) =>
981 Effect.fail(new BitbucketRequestError({ operation: "request", cause })),
982 }),
983 );
984
985 return BitbucketApi.of({
986 request,
987 probeAuth: executeJson(
988 "probeAuth",
989 HttpClientRequest.get(apiUrl("/user")),
990 BitbucketUserSchema,
991 ).pipe(
992 Effect.map((user) => ({
993 status: "authenticated" as const,
994 account: nonEmpty(user.username ?? user.display_name ?? user.account_id),
995 host: Option.some("bitbucket.org"),
996 detail: Option.none<string>(),
997 })),
998 Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))),
999 ),
1000 listPullRequests: (input) =>
1001 resolveRepository(input).pipe(
1002 Effect.flatMap((repository) => {
1003 const states = toBitbucketStates(input.state);
1004 const query: Record<string, string | ReadonlyArray<string>> = {
1005 pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))),
1006 sort: "-updated_on",
1007 q: bitbucketQueryString([
1008 `source.branch.name = "${SourceControlProvider.sourceBranch(input).replaceAll('"', '\\"')}"`,
1009 bitbucketStateFilter(states),
1010 ]),
1011 state: states,
1012 };
1013
1014 return executeJson(
1015 "listPullRequests",
1016 HttpClientRequest.get(
1017 apiUrl(
1018 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
1019 ),
1020 { urlParams: query },
1021 ),
1022 BitbucketPullRequestListSchema,
1023 );
1024 }),
1025 Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)),
1026 ),
1027 getPullRequest: (input) =>
1028 getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)),
1029 getRepositoryCloneUrls: (input) =>
1030 getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)),
1031 createRepository: (input) =>
1032 requireRepositoryLocator(input.repository).pipe(
1033 Effect.flatMap((repository) =>
1034 executeJson(
1035 "createRepository",
1036 HttpClientRequest.post(
1037 apiUrl(
1038 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`,
1039 ),
1040 ).pipe(
1041 HttpClientRequest.bodyJsonUnsafe({
1042 scm: "git",
1043 is_private: input.visibility === "private",
1044 }),
1045 ),
1046 RawBitbucketRepositorySchema,
1047 ),
1048 ),
1049 Effect.map(normalizeRepositoryCloneUrls),
1050 ),
1051 createPullRequest: (input) =>
1052 Effect.gen(function* () {
1053 const repository = yield* resolveRepository(input);
1054 const description = yield* fileSystem.readFileString(input.bodyFile).pipe(
1055 Effect.mapError(
1056 (cause) =>
1057 new BitbucketPullRequestBodyReadError({
1058 cwd: input.cwd,
1059 bodyFile: input.bodyFile,
1060 cause,
1061 }),
1062 ),
1063 );
1064 const sourceOwner = sourceWorkspace(input);
1065 const body = {
1066 title: input.title,
1067 description,
1068 source: {
1069 branch: {
1070 name: SourceControlProvider.sourceBranch(input),
1071 },
1072 ...(sourceOwner
1073 ? {
1074 repository: {
1075 full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`,
1076 },
1077 }
1078 : {}),
1079 },
1080 destination: {
1081 branch: {
1082 name: input.target?.refName ?? input.baseBranch,
1083 },
1084 },
1085 };
1086
1087 yield* executeJson(
1088 "createPullRequest",
1089 HttpClientRequest.post(
1090 apiUrl(
1091 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
1092 ),
1093 ).pipe(HttpClientRequest.bodyJsonUnsafe(body)),
1094 BitbucketPullRequestSchema,
1095 );
1096 }),
1097 getDefaultBranch: (input) =>
1098 resolveRepository(input).pipe(
1099 Effect.flatMap((locator) =>
1100 Effect.all(
1101 {
1102 repository: getRepositoryFromLocator(locator),
1103 branchingModel: getBranchingModelFromLocator(locator).pipe(
1104 Effect.orElseSucceed(
1105 (): typeof RawBitbucketBranchingModelSchema.Type | null => null,
1106 ),
1107 ),
1108 },
1109 { concurrency: "unbounded" },
1110 ),
1111 ),
1112 Effect.map(defaultChangeRequestTargetBranch),
1113 ),
1114 // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide
1115 // an official checkout CLI. This provider-local path uses GitVcsDriver as a
1116 // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this
1117 // as the source-control provider model: if we support non-Git-compatible
1118 // hosting providers or native JJ/Sapling checkout flows, move this into a
1119 // VCS-specific change-request checkout capability.
1120 checkoutPullRequest: (input) =>
1121 Effect.gen(function* () {
1122 const destinationRepository = yield* resolveRepository(input);
1123 const pullRequest = yield* getRawPullRequestFromRepository(
1124 destinationRepository,
1125 input.reference,
1126 );
1127 const destinationRepositoryName =
1128 repositoryNameWithOwner(pullRequest.destination.repository) ??
1129 `${destinationRepository.workspace}/${destinationRepository.repoSlug}`;
1130 const sourceRepositoryName =
1131 repositoryNameWithOwner(pullRequest.source.repository) ?? destinationRepositoryName;
1132 const isCrossRepository = sourceRepositoryName !== destinationRepositoryName;
1133 const remoteName = yield* resolveCheckoutRemote({
1134 cwd: input.cwd,
1135 destinationRepository,
1136 sourceRepositoryName,
1137 isCrossRepository,
1138 ...(input.context ? { context: input.context } : {}),
1139 });
1140 const remoteBranch = pullRequest.source.branch.name;
1141 const localBranch = checkoutBranchName({
1142 pullRequestId: pullRequest.id,
1143 headBranch: remoteBranch,
1144 isCrossRepository,
1145 });
1146 const localBranchNames = yield* git.listLocalBranchNames(input.cwd);
1147 const localBranchExists = localBranchNames.includes(localBranch);
1148
1149 if (input.force === true || !localBranchExists) {
1150 yield* git.fetchRemoteBranch({
1151 cwd: input.cwd,
1152 remoteName,
1153 remoteBranch,
1154 localBranch,
1155 });
1156 } else {
1157 yield* git.fetchRemoteTrackingBranch({
1158 cwd: input.cwd,
1159 remoteName,
1160 remoteBranch,
1161 });
1162 }
1163
1164 yield* git.setBranchUpstream({
1165 cwd: input.cwd,
1166 branch: localBranch,
1167 remoteName,
1168 remoteBranch,
1169 });
1170 yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch }));
1171 }).pipe(
1172 Effect.mapError((cause) =>
1173 isBitbucketApiError(cause)
1174 ? cause
1175 : new BitbucketCheckoutError({
1176 cwd: input.cwd,
1177 reference: input.reference,
1178 cause,
1179 }),
1180 ),
1181 ),
1182 });
1183});
1184
1185export const layer = Layer.effect(BitbucketApi, make);