1099 lines · 36.7 KB · TypeScript
RawDownload
import * as Clock from "effect/Clock";import * as Config from "effect/Config";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Schema from "effect/Schema";import {  NonNegativeInt,  TrimmedNonEmptyString,  type SourceControlProviderAuth,  type SourceControlRepositoryCloneUrls,  type SourceControlRepositoryVisibility,} from "@t3tools/contracts";import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";import { sanitizeBranchFragment } from "@t3tools/shared/git";import {  detectSourceControlProviderFromRemoteUrl,  isSshRemoteUrl,} from "@t3tools/shared/sourceControl"; import {  BitbucketPullRequestListSchema,  BitbucketPullRequestSchema,  normalizeBitbucketPullRequestRecord,  type NormalizedBitbucketPullRequestRecord,} from "./bitbucketPullRequests.ts";import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";import * as SourceControlProvider from "./SourceControlProvider.ts";import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";import { retryAtFromHeader } from "./SourceControlRateLimit.ts"; const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0";/** A response body past this is cut short, so one huge diff cannot exhaust the server. */const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;/** Bitbucket redirects a diff once; this leaves room without following a chain forever. */const MAX_REDIRECTS = 3; const BitbucketApiEnvConfig = Config.all({  baseUrl: Config.string("T3CODE_BITBUCKET_API_BASE_URL").pipe(    Config.withDefault(DEFAULT_API_BASE_URL),  ),  accessToken: Config.string("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option),  email: Config.string("T3CODE_BITBUCKET_EMAIL").pipe(Config.option),  apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option),}); const BitbucketApiOperation = Schema.Literals([  "resolveRepository",  "getRepository",  "getBranchingModel",  "getPullRequest",  "listPullRequests",  "createRepository",  "createPullRequest",  "probeAuth",  "checkoutPullRequest",  // The raw escape hatch. Callers name their own operation in their own error, the way the  // pull request wrappers do on top of `gh` and `glab`.  "request",]);type BitbucketApiOperation = typeof BitbucketApiOperation.Type; export class BitbucketRepositoryLocatorError extends Schema.TaggedErrorClass<BitbucketRepositoryLocatorError>()(  "BitbucketRepositoryLocatorError",  {    repository: Schema.String,  },) {  get detail(): string {    return "Bitbucket repositories must be specified as workspace/repository.";  }   override get message(): string {    return `Bitbucket API failed in createRepository: ${this.detail}`;  }} export class BitbucketRequestError extends Schema.TaggedErrorClass<BitbucketRequestError>()(  "BitbucketRequestError",  {    operation: BitbucketApiOperation,    cause: Schema.Defect(),  },) {  get detail(): string {    return "Failed to send the Bitbucket request.";  }   override get message(): string {    return `Bitbucket API failed in ${this.operation}: ${this.detail}`;  }} export class BitbucketResponseError extends Schema.TaggedErrorClass<BitbucketResponseError>()(  "BitbucketResponseError",  {    operation: BitbucketApiOperation,    status: Schema.Int,    responseBodyLength: NonNegativeInt,    retryAt: Schema.optional(Schema.Number),  },) {  get detail(): string {    return `Bitbucket returned HTTP ${this.status}.`;  }   override get message(): string {    return `Bitbucket API failed in ${this.operation}: ${this.detail}`;  }} export class BitbucketResponseBodyReadError extends Schema.TaggedErrorClass<BitbucketResponseBodyReadError>()(  "BitbucketResponseBodyReadError",  {    operation: BitbucketApiOperation,    status: Schema.Int,    cause: Schema.Defect(),  },) {  get detail(): string {    return `Bitbucket returned HTTP ${this.status}.`;  }   override get message(): string {    return `Bitbucket API failed in ${this.operation}: ${this.detail}`;  }} export class BitbucketResponseDecodeError extends Schema.TaggedErrorClass<BitbucketResponseDecodeError>()(  "BitbucketResponseDecodeError",  {    operation: BitbucketApiOperation,    status: Schema.Int,    cause: Schema.Defect(),  },) {  get detail(): string {    return "Bitbucket returned invalid JSON for the requested resource.";  }   override get message(): string {    return `Bitbucket API failed in ${this.operation}: ${this.detail}`;  }} export class BitbucketRepositoryVcsResolveError extends Schema.TaggedErrorClass<BitbucketRepositoryVcsResolveError>()(  "BitbucketRepositoryVcsResolveError",  {    cwd: Schema.String,    cause: Schema.Defect(),  },) {  get detail(): string {    return `Failed to resolve VCS repository for ${this.cwd}.`;  }   override get message(): string {    return `Bitbucket API failed in resolveRepository: ${this.detail}`;  }} export class BitbucketRepositoryRemotesListError extends Schema.TaggedErrorClass<BitbucketRepositoryRemotesListError>()(  "BitbucketRepositoryRemotesListError",  {    cwd: Schema.String,    cause: Schema.Defect(),  },) {  get detail(): string {    return `Failed to list remotes for ${this.cwd}.`;  }   override get message(): string {    return `Bitbucket API failed in resolveRepository: ${this.detail}`;  }} export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedErrorClass<BitbucketRepositoryRemoteNotFoundError>()(  "BitbucketRepositoryRemoteNotFoundError",  {    cwd: Schema.String,  },) {  get detail(): string {    return `No Bitbucket repository remote was detected for ${this.cwd}.`;  }   override get message(): string {    return `Bitbucket API failed in resolveRepository: ${this.detail}`;  }} export class BitbucketPullRequestBodyReadError extends Schema.TaggedErrorClass<BitbucketPullRequestBodyReadError>()(  "BitbucketPullRequestBodyReadError",  {    cwd: Schema.String,    bodyFile: Schema.String,    cause: Schema.Defect(),  },) {  get detail(): string {    return `Failed to read pull request body file ${this.bodyFile}.`;  }   override get message(): string {    return `Bitbucket API failed in createPullRequest: ${this.detail}`;  }} export class BitbucketCheckoutError extends Schema.TaggedErrorClass<BitbucketCheckoutError>()(  "BitbucketCheckoutError",  {    cwd: Schema.String,    reference: Schema.String,    cause: Schema.Defect(),  },) {  get detail(): string {    return "Failed to check out the Bitbucket pull request.";  }   override get message(): string {    return `Bitbucket API failed in checkoutPullRequest: ${this.detail}`;  }} /** * A url that does not belong to the configured Bitbucket. Refused rather than followed, because * the request carries the account's credentials and a url that came back in a response — a * pagination cursor, or the target of a redirect — is not this server's to trust. */export class BitbucketUntrustedUrlError extends Schema.TaggedErrorClass<BitbucketUntrustedUrlError>()(  "BitbucketUntrustedUrlError",  {    /** The host only. A rejected hop is often a signed url, whose query carries a credential. */    host: Schema.String,  },) {  get detail(): string {    return `The response pointed at ${this.host}, outside the configured Bitbucket.`;  }   override get message(): string {    return `Bitbucket API failed in request: ${this.detail}`;  }} export const BitbucketApiError = Schema.Union([  BitbucketUntrustedUrlError,  BitbucketRepositoryLocatorError,  BitbucketRequestError,  BitbucketResponseError,  BitbucketResponseBodyReadError,  BitbucketResponseDecodeError,  BitbucketRepositoryVcsResolveError,  BitbucketRepositoryRemotesListError,  BitbucketRepositoryRemoteNotFoundError,  BitbucketPullRequestBodyReadError,  BitbucketCheckoutError,]);export type BitbucketApiError = typeof BitbucketApiError.Type;export const isBitbucketApiError = Schema.is(BitbucketApiError); const RawBitbucketRepositorySchema = Schema.Struct({  full_name: TrimmedNonEmptyString,  links: Schema.Struct({    html: Schema.optional(      Schema.Struct({        href: TrimmedNonEmptyString,      }),    ),    clone: Schema.optional(      Schema.Array(        Schema.Struct({          name: TrimmedNonEmptyString,          href: TrimmedNonEmptyString,        }),      ),    ),  }),  mainbranch: Schema.optional(    Schema.NullOr(      Schema.Struct({        name: TrimmedNonEmptyString,      }),    ),  ),}); const RawBitbucketBranchingModelSchema = Schema.Struct({  development: Schema.optional(    Schema.Struct({      branch: Schema.optional(        Schema.NullOr(          Schema.Struct({            name: Schema.optional(TrimmedNonEmptyString),          }),        ),      ),      is_valid: Schema.optional(Schema.Boolean),      name: Schema.optional(Schema.NullOr(Schema.String)),      use_mainbranch: Schema.optional(Schema.Boolean),    }),  ),}); const BitbucketUserSchema = Schema.Struct({  username: Schema.optional(TrimmedNonEmptyString),  display_name: Schema.optional(TrimmedNonEmptyString),  account_id: Schema.optional(TrimmedNonEmptyString),}); export interface BitbucketRepositoryLocator {  readonly workspace: string;  readonly repoSlug: string;} export class BitbucketApi extends Context.Service<  BitbucketApi,  {    readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;     /**     * One authenticated request, returning the body verbatim. Bitbucket answers most endpoints     * with JSON and a few — a pull request diff, for one — with plain text, so the body is     * handed back undecoded for the caller to read as it sees fit.     */    readonly request: (input: {      readonly method: "GET" | "POST" | "PUT" | "DELETE";      /**       * A path below the API base, or a whole URL as a paged response reports its next page.       * A whole URL is refused unless it belongs to the configured Bitbucket.       */      readonly url: string;      /** A JSON document, for the endpoints that take one. */      readonly body?: string;      /** Response bytes to keep; past this the body comes back cut short and marked. */      readonly maxBytes?: number;    }) => Effect.Effect<{ readonly body: string; readonly truncated: boolean }, BitbucketApiError>;    readonly listPullRequests: (input: {      readonly cwd: string;      readonly context?: SourceControlProvider.SourceControlProviderContext;      readonly headSelector: string;      readonly source?: SourceControlProvider.SourceControlRefSelector;      readonly state: "open" | "closed" | "merged" | "all";      readonly limit?: number;    }) => Effect.Effect<ReadonlyArray<NormalizedBitbucketPullRequestRecord>, BitbucketApiError>;    readonly getPullRequest: (input: {      readonly cwd: string;      readonly context?: SourceControlProvider.SourceControlProviderContext;      readonly reference: string;    }) => Effect.Effect<NormalizedBitbucketPullRequestRecord, BitbucketApiError>;    readonly getRepositoryCloneUrls: (input: {      readonly cwd: string;      readonly context?: SourceControlProvider.SourceControlProviderContext;      readonly repository: string;    }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;    readonly createRepository: (input: {      readonly cwd: string;      readonly repository: string;      readonly visibility: SourceControlRepositoryVisibility;    }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>;    readonly createPullRequest: (input: {      readonly cwd: string;      readonly context?: SourceControlProvider.SourceControlProviderContext;      readonly baseBranch: string;      readonly headSelector: string;      readonly source?: SourceControlProvider.SourceControlRefSelector;      readonly target?: SourceControlProvider.SourceControlRefSelector;      readonly title: string;      readonly bodyFile: string;    }) => Effect.Effect<void, BitbucketApiError>;    readonly getDefaultBranch: (input: {      readonly cwd: string;      readonly context?: SourceControlProvider.SourceControlProviderContext;    }) => Effect.Effect<string | null, BitbucketApiError>;    readonly checkoutPullRequest: (input: {      readonly cwd: string;      readonly context?: SourceControlProvider.SourceControlProviderContext;      readonly reference: string;      readonly force?: boolean;    }) => Effect.Effect<void, BitbucketApiError>;  }>()("t3/sourceControl/BitbucketApi") {} function nonEmpty(value: string | undefined): Option.Option<string> {  const trimmed = value?.trim();  return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);} function normalizeChangeRequestId(reference: string): string {  const trimmed = reference.trim().replace(/^#/, "");  const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec(    trimmed,  );  return urlMatch?.[1] ?? trimmed;} function sourceWorkspace(input: {  readonly headSelector: string;  readonly source?: SourceControlProvider.SourceControlRefSelector;}): string | undefined {  if (input.source?.owner) return input.source.owner;  return SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner;} function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> {  switch (state) {    case "open":      return ["OPEN"];    case "closed":      return ["DECLINED", "SUPERSEDED"];    case "merged":      return ["MERGED"];    case "all":      return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"];  }} function bitbucketQueryString(filters: ReadonlyArray<string>): string {  return filters.join(" AND ");} function bitbucketStateFilter(states: ReadonlyArray<string>): string {  return states.length === 1    ? `state = "${states[0]}"`    : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`;} function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null {  const normalized = value.trim().replace(/\.git$/u, "");  const parts = normalized.split("/").filter((part) => part.length > 0);  if (parts.length < 2) return null;  const workspace = parts.at(-2);  const repoSlug = parts.at(-1);  return workspace && repoSlug ? { workspace, repoSlug } : null;} function requireRepositoryLocator(  repository: string,): Effect.Effect<BitbucketRepositoryLocator, BitbucketApiError> {  const locator = parseBitbucketRepositorySlug(repository);  return locator    ? Effect.succeed(locator)    : Effect.fail(        new BitbucketRepositoryLocatorError({          repository,        }),      );} function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null {  const trimmed = remoteUrl.trim();  const scpMatch = /^[a-zA-Z0-9._-]+@[^:/]+:(.+)$/.exec(trimmed);  if (scpMatch?.[1]) {    return parseBitbucketRepositorySlug(scpMatch[1]);  }   try {    return parseBitbucketRepositorySlug(new URL(trimmed).pathname);  } catch {    return null;  }} function normalizeRepositoryCloneUrls(  raw: typeof RawBitbucketRepositorySchema.Type,): SourceControlRepositoryCloneUrls {  const httpClone =    raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ??    raw.links.html?.href;  const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href;   return {    nameWithOwner: raw.full_name,    url: httpClone ?? raw.links.html?.href ?? raw.full_name,    sshUrl: sshClone ?? httpClone ?? raw.full_name,  };} function defaultChangeRequestTargetBranch(input: {  readonly repository: typeof RawBitbucketRepositorySchema.Type;  readonly branchingModel: typeof RawBitbucketBranchingModelSchema.Type | null;}): string | null {  const repositoryMainBranch = input.repository.mainbranch?.name ?? null;  const development = input.branchingModel?.development;  if (!development || development.use_mainbranch === true || development.is_valid === false) {    return repositoryMainBranch;  }   const developmentBranch = development.branch?.name?.trim() ?? development.name?.trim() ?? "";  if (developmentBranch.length === 0 || developmentBranch === "null") {    return repositoryMainBranch;  }   return developmentBranch;}

Lines 1–500 of 1099.