From 826965978b2902dee4a6f96804fd5048528dc99b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 4 May 2026 10:02:25 -0700 Subject: [PATCH] bitubicket base brnach (#2500) Co-authored-by: Julius Marminge --- .../src/sourceControl/BitbucketApi.test.ts | 93 ++++++++++++++++++- apps/server/src/sourceControl/BitbucketApi.ts | 85 ++++++++++++++--- 2 files changed, 164 insertions(+), 14 deletions(-) diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 5542ea40921eedd75864b8dc049f4515be499d64..efc7ec5fb9bfee699f1a6aa866ec66ef10346463 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -287,7 +287,18 @@ it.effect("expands all-state pull request listing instead of relying on Bitbucke it.effect("reads repository clone URLs and default branch", () => { const { layer } = makeLayer({ - response: () => Response.json(repositoryJson), + response: (request) => + Response.json( + request.url.endsWith("/branching-model") + ? { + development: { + branch: { name: "main" }, + name: "main", + use_mainbranch: true, + }, + } + : repositoryJson, + ), }); return Effect.gen(function* () { @@ -307,6 +318,86 @@ it.effect("reads repository clone URLs and default branch", () => { }).pipe(Effect.provide(layer)); }); +it.effect( + "prefers the Bitbucket branching model development branch as the default PR target", + () => { + const { execute, layer } = makeLayer({ + response: (request) => + Response.json( + request.url.endsWith("/branching-model") + ? { + development: { + branch: { name: "develop" }, + name: "develop", + use_mainbranch: false, + }, + } + : repositoryJson, + ), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(defaultBranch, "develop"); + assert.deepStrictEqual( + execute.mock.calls.map((call) => call[0].url).toSorted(), + [ + "https://api.test.local/2.0/repositories/pingdotgg/t3code", + "https://api.test.local/2.0/repositories/pingdotgg/t3code/branching-model", + ].toSorted(), + ); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect( + "falls back to the repository main branch when the Bitbucket development branch is invalid", + () => { + const { layer } = makeLayer({ + response: (request) => + Response.json( + request.url.endsWith("/branching-model") + ? { + development: { + name: "develop", + use_mainbranch: false, + is_valid: false, + }, + } + : repositoryJson, + ), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(defaultBranch, "main"); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect( + "falls back to the repository main branch when the Bitbucket branching model is unavailable", + () => { + const { layer } = makeLayer({ + response: (request) => + request.url.endsWith("/branching-model") + ? Response.json({ error: { message: "Not found" } }, { status: 404 }) + : Response.json(repositoryJson), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(defaultBranch, "main"); + }).pipe(Effect.provide(layer)); + }, +); + it.effect("creates repositories through the Bitbucket REST API", () => { const { execute, layer } = makeLayer({ response: () => Response.json(repositoryJson), diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 4f795e864a1a06c2599cf29585f967344bf2302d..b95d5190039d7291825a333cc4388499cd2531c6 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -65,6 +65,23 @@ const RawBitbucketRepositorySchema = Schema.Struct({ ), }); +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), @@ -216,7 +233,7 @@ function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator } function normalizeRepositoryCloneUrls( - raw: Schema.Schema.Type, + raw: typeof RawBitbucketRepositorySchema.Type, ): SourceControlRepositoryCloneUrls { const httpClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ?? @@ -230,6 +247,24 @@ function normalizeRepositoryCloneUrls( }; } +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://"); @@ -430,23 +465,32 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { }); }); + const getRepositoryFromLocator = (repository: BitbucketRepositoryLocator) => + executeJson( + "getRepository", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, + ), + ), + RawBitbucketRepositorySchema, + ); + const getRepository = (input: { readonly cwd: string; readonly context?: SourceControlProvider.SourceControlProviderContext; readonly repository?: string; - }) => - resolveRepository(input).pipe( - Effect.flatMap((repository) => - executeJson( - "getRepository", - HttpClientRequest.get( - apiUrl( - `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, - ), - ), - RawBitbucketRepositorySchema, + }) => resolveRepository(input).pipe(Effect.flatMap(getRepositoryFromLocator)); + + const getBranchingModelFromLocator = (repository: BitbucketRepositoryLocator) => + executeJson( + "getBranchingModel", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/branching-model`, ), ), + RawBitbucketBranchingModelSchema, ); const getRawPullRequestFromRepository = ( @@ -628,7 +672,22 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { ); }), getDefaultBranch: (input) => - getRepository(input).pipe(Effect.map((repository) => repository.mainbranch?.name ?? null)), + resolveRepository(input).pipe( + Effect.flatMap((locator) => + Effect.all( + { + repository: getRepositoryFromLocator(locator), + branchingModel: getBranchingModelFromLocator(locator).pipe( + Effect.catch(() => + Effect.succeed(null), + ), + ), + }, + { concurrency: "unbounded" }, + ), + ), + Effect.map(defaultChangeRequestTargetBranch), + ), // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide // an official checkout CLI. This provider-local path uses GitVcsDriver as a // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this -- 2.54.0