891 lines · 29.5 KB · TypeScript
RawDownload
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 } from "@t3tools/shared/sourceControl"; import {  BitbucketPullRequestListSchema,  BitbucketPullRequestSchema,  normalizeBitbucketPullRequestRecord,  type NormalizedBitbucketPullRequestRecord,} from "./bitbucketPullRequests.ts";import * as SourceControlProvider from "./SourceControlProvider.ts";import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; 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",]);type BitbucketApiOperation = typeof BitbucketApiOperation.Type; export class BitbucketRepositoryLocatorError extends Schema.TaggedErrorClass<BitbucketRepositoryLocatorError>()(  "BitbucketRepositoryLocatorError",  {    repository: Schema.String,  },) {  override get message(): string {    return "Bitbucket API failed in createRepository: Bitbucket repositories must be specified as workspace/repository.";  }} export class BitbucketRequestError extends Schema.TaggedErrorClass<BitbucketRequestError>()(  "BitbucketRequestError",  {    operation: BitbucketApiOperation,    cause: Schema.Defect(),  },) {  override get message(): string {    return `Bitbucket API failed in ${this.operation}: Failed to send the Bitbucket request.`;  }} export class BitbucketResponseError extends Schema.TaggedErrorClass<BitbucketResponseError>()(  "BitbucketResponseError",  {    operation: BitbucketApiOperation,    status: Schema.Int,    responseBodyLength: NonNegativeInt,  },) {  override get message(): string {    return `Bitbucket API failed in ${this.operation}: Bitbucket returned HTTP ${this.status}.`;  }} export class BitbucketResponseBodyReadError extends Schema.TaggedErrorClass<BitbucketResponseBodyReadError>()(  "BitbucketResponseBodyReadError",  {    operation: BitbucketApiOperation,    status: Schema.Int,    cause: Schema.Defect(),  },) {  override get message(): string {    return `Bitbucket API failed in ${this.operation}: Bitbucket returned HTTP ${this.status}.`;  }} export class BitbucketResponseDecodeError extends Schema.TaggedErrorClass<BitbucketResponseDecodeError>()(  "BitbucketResponseDecodeError",  {    operation: BitbucketApiOperation,    status: Schema.Int,    cause: Schema.Defect(),  },) {  override get message(): string {    return `Bitbucket API failed in ${this.operation}: Bitbucket returned invalid JSON for the requested resource.`;  }} export class BitbucketRepositoryVcsResolveError extends Schema.TaggedErrorClass<BitbucketRepositoryVcsResolveError>()(  "BitbucketRepositoryVcsResolveError",  {    cwd: Schema.String,    cause: Schema.Defect(),  },) {  override get message(): string {    return `Bitbucket API failed in resolveRepository: Failed to resolve VCS repository for ${this.cwd}.`;  }} export class BitbucketRepositoryRemotesListError extends Schema.TaggedErrorClass<BitbucketRepositoryRemotesListError>()(  "BitbucketRepositoryRemotesListError",  {    cwd: Schema.String,    cause: Schema.Defect(),  },) {  override get message(): string {    return `Bitbucket API failed in resolveRepository: Failed to list remotes for ${this.cwd}.`;  }} export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedErrorClass<BitbucketRepositoryRemoteNotFoundError>()(  "BitbucketRepositoryRemoteNotFoundError",  {    cwd: Schema.String,  },) {  override get message(): string {    return `Bitbucket API failed in resolveRepository: No Bitbucket repository remote was detected for ${this.cwd}.`;  }} export class BitbucketPullRequestBodyReadError extends Schema.TaggedErrorClass<BitbucketPullRequestBodyReadError>()(  "BitbucketPullRequestBodyReadError",  {    cwd: Schema.String,    bodyFile: Schema.String,    cause: Schema.Defect(),  },) {  override get message(): string {    return `Bitbucket API failed in createPullRequest: Failed to read pull request body file ${this.bodyFile}.`;  }} export class BitbucketCheckoutError extends Schema.TaggedErrorClass<BitbucketCheckoutError>()(  "BitbucketCheckoutError",  {    cwd: Schema.String,    reference: Schema.String,    cause: Schema.Defect(),  },) {  override get message(): string {    return "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request.";  }} export const BitbucketApiError = Schema.Union([  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>;    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();  if (trimmed.startsWith("git@")) {    const pathStart = trimmed.indexOf(":");    return pathStart < 0 ? null : parseBitbucketRepositorySlug(trimmed.slice(pathStart + 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;} function shouldPreferSshRemote(originRemoteUrl: string | null): boolean {  const trimmed = originRemoteUrl?.trim() ?? "";  return trimmed.startsWith("git@") || trimmed.startsWith("ssh://");} function selectCloneUrl(input: {  readonly cloneUrls: SourceControlRepositoryCloneUrls;  readonly originRemoteUrl: string | null;}): string {  return shouldPreferSshRemote(input.originRemoteUrl)    ? input.cloneUrls.sshUrl    : input.cloneUrls.url;} function checkoutBranchName(input: {  readonly pullRequestId: number;  readonly headBranch: string;  readonly isCrossRepository: boolean;}): string {  if (!input.isCrossRepository) {    return input.headBranch;  }   return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`;} function repositoryNameWithOwner(  repository: Schema.Schema.Type<typeof BitbucketPullRequestSchema>["source"]["repository"],): string | null {  const fullName = repository?.full_name?.trim() ?? "";  return fullName.length > 0 ? fullName : null;} function repositoryOwnerName(repositoryName: string): string {  return repositoryName.split("/")[0]?.trim() || "bitbucket";} function authFromConfig(  config: Config.Success<typeof BitbucketApiEnvConfig>,): SourceControlProviderAuth {  if (Option.isSome(config.accessToken)) {    return {      status: "unknown",      account: Option.none(),      host: Option.some("bitbucket.org"),      detail: Option.some("Bitbucket access token is configured."),    };  }   if (Option.isSome(config.email) && Option.isSome(config.apiToken)) {    return {      status: "unknown",      account: config.email,      host: Option.some("bitbucket.org"),      detail: Option.some("Bitbucket API token is configured."),    };  }   return {    status: "unauthenticated",    account: Option.none(),    host: Option.some("bitbucket.org"),    detail: Option.some(      "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.",    ),  };} function responseError(  operation: BitbucketApiOperation,  response: HttpClientResponse.HttpClientResponse,): Effect.Effect<never, BitbucketApiError> {  return response.text.pipe(    Effect.mapError(      (cause) =>        new BitbucketResponseBodyReadError({          operation,          status: response.status,          cause,        }),    ),    Effect.flatMap((body) =>      Effect.fail(        new BitbucketResponseError({          operation,          status: response.status,          responseBodyLength: body.length,        }),      ),    ),  );} 

Lines 1–500 of 891.