| 1 | import * as Clock from "effect/Clock"; |
| 2 | import * as Config from "effect/Config"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import * as Schema from "effect/Schema"; |
| 9 | import { |
| 10 | NonNegativeInt, |
| 11 | TrimmedNonEmptyString, |
| 12 | type SourceControlProviderAuth, |
| 13 | type SourceControlRepositoryCloneUrls, |
| 14 | type SourceControlRepositoryVisibility, |
| 15 | } from "@t3tools/contracts"; |
| 16 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; |
| 17 | import { sanitizeBranchFragment } from "@t3tools/shared/git"; |
| 18 | import { |
| 19 | detectSourceControlProviderFromRemoteUrl, |
| 20 | isSshRemoteUrl, |
| 21 | } from "@t3tools/shared/sourceControl"; |
| 22 | |
| 23 | import { |
| 24 | BitbucketPullRequestListSchema, |
| 25 | BitbucketPullRequestSchema, |
| 26 | normalizeBitbucketPullRequestRecord, |
| 27 | type NormalizedBitbucketPullRequestRecord, |
| 28 | } from "./bitbucketPullRequests.ts"; |
| 29 | import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; |
| 30 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; |
| 31 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; |
| 32 | import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition"; |
| 33 | |
| 34 | import * as BitbucketClient from "../client/definition.ts"; |
| 35 | import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit"; |
| 36 | |
| 37 | const 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. */ |
| 39 | const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; |
| 40 | /** Bitbucket redirects a diff once; this leaves room without following a chain forever. */ |
| 41 | const MAX_REDIRECTS = 3; |
| 42 | /** A single API operation must bound both header delivery and body consumption. */ |
| 43 | const BITBUCKET_API_REQUEST_TIMEOUT = "30 seconds"; |
| 44 | |
| 45 | const 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 | |
| 54 | const 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 | ]); |
| 68 | type BitbucketApiOperation = typeof BitbucketApiOperation.Type; |
| 69 | |
| 70 | export 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 | |
| 87 | export 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 | |
| 103 | export 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 | |
| 121 | export 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 | |
| 139 | export 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 | |
| 156 | export 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 | |
| 172 | export 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 | |
| 188 | export 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 | |
| 203 | export 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 | |
| 220 | export 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 | */ |
| 242 | export 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 | |
| 258 | export 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 | ]); |
| 271 | export type BitbucketApiError = typeof BitbucketApiError.Type; |
| 272 | const isBitbucketApiError = Schema.is(BitbucketApiError); |
| 273 | |
| 274 | const 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 | |
| 300 | const 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 | |
| 317 | const BitbucketUserSchema = Schema.Struct({ |
| 318 | username: Schema.optional(TrimmedNonEmptyString), |
| 319 | display_name: Schema.optional(TrimmedNonEmptyString), |
| 320 | account_id: Schema.optional(TrimmedNonEmptyString), |
| 321 | }); |
| 322 | |
| 323 | export interface BitbucketRepositoryLocator { |
| 324 | readonly workspace: string; |
| 325 | readonly repoSlug: string; |
| 326 | } |
| 327 | |
| 328 | export 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 | |
| 396 | function 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 | |
| 401 | function 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 | |
| 409 | function 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 | |
| 417 | function 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 | |
| 430 | function bitbucketQueryString(filters: ReadonlyArray<string>): string { |
| 431 | return filters.join(" AND "); |
| 432 | } |
| 433 | |
| 434 | function bitbucketStateFilter(states: ReadonlyArray<string>): string { |
| 435 | return states.length === 1 |
| 436 | ? `state = "${states[0]}"` |
| 437 | : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`; |
| 438 | } |
| 439 | |
| 440 | function 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 | |
| 449 | function 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 | |
| 462 | function 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 | |
| 476 | function 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 | |
| 491 | function 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 | |
| 509 | function shouldPreferSshRemote(originRemoteUrl: string | null): boolean { |
| 510 | if (!originRemoteUrl) return false; |
| 511 | return isSshRemoteUrl(originRemoteUrl); |
| 512 | } |
| 513 | |
| 514 | function 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 | |
| 523 | function 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 | |
| 535 | function 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 | |
| 542 | function repositoryOwnerName(repositoryName: string): string { |
| 543 | return repositoryName.split("/")[0]?.trim() || "bitbucket"; |
| 544 | } |
| 545 | |
| 546 | type 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 | */ |
| 554 | const HEADER_SAFE = /^[\x21-\x7e]+$/u; |
| 555 | |
| 556 | function 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 | */ |
| 574 | function 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 | |
| 588 | function 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. */ |
| 618 | function originOf(value: string): string | null { |
| 619 | try { |
| 620 | return new URL(value).origin; |
| 621 | } catch { |
| 622 | return null; |
| 623 | } |
| 624 | } |
| 625 | |
| 626 | function 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. */ |
| 659 | export 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 | |
| 1185 | export const layer = Layer.effect(BitbucketApi, make); |