authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-04 10:02:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-04 10:02:25-07:00
log826965978b2902dee4a6f96804fd5048528dc99b
treec2b30df02b6b65ceca0cc2e429e707a7e79dbe10
parent623e471a0230e9aba289c74a2120bbe91250949a
signature Signed by PGP key B5690EEEBB952194

bitubicket base brnach (#2500)

Co-authored-by: Julius Marminge <julius@macmini.local>

2 files changed, 164 insertions(+), 14 deletions(-)

apps/server/src/sourceControl/BitbucketApi.test.ts+92-1
...@@ -287,7 +287,18 @@ it.effect("expands all-state pull request listing instead of relying on Bitbucke...@@ -287,7 +287,18 @@ it.effect("expands all-state pull request listing instead of relying on Bitbucke
287287
288it.effect("reads repository clone URLs and default branch", () => {288it.effect("reads repository clone URLs and default branch", () => {
289 const { layer } = makeLayer({289 const { layer } = makeLayer({
290 response: () => Response.json(repositoryJson),290 response: (request) =>
291 Response.json(
292 request.url.endsWith("/branching-model")
293 ? {
294 development: {
295 branch: { name: "main" },
296 name: "main",
297 use_mainbranch: true,
298 },
299 }
300 : repositoryJson,
301 ),
291 });302 });
292303
293 return Effect.gen(function* () {304 return Effect.gen(function* () {
...@@ -307,6 +318,86 @@ it.effect("reads repository clone URLs and default branch", () => {...@@ -307,6 +318,86 @@ it.effect("reads repository clone URLs and default branch", () => {
307 }).pipe(Effect.provide(layer));318 }).pipe(Effect.provide(layer));
308});319});
309320
321it.effect(
322 "prefers the Bitbucket branching model development branch as the default PR target",
323 () => {
324 const { execute, layer } = makeLayer({
325 response: (request) =>
326 Response.json(
327 request.url.endsWith("/branching-model")
328 ? {
329 development: {
330 branch: { name: "develop" },
331 name: "develop",
332 use_mainbranch: false,
333 },
334 }
335 : repositoryJson,
336 ),
337 });
338
339 return Effect.gen(function* () {
340 const bitbucket = yield* BitbucketApi.BitbucketApi;
341 const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" });
342
343 assert.strictEqual(defaultBranch, "develop");
344 assert.deepStrictEqual(
345 execute.mock.calls.map((call) => call[0].url).toSorted(),
346 [
347 "https://api.test.local/2.0/repositories/pingdotgg/t3code",
348 "https://api.test.local/2.0/repositories/pingdotgg/t3code/branching-model",
349 ].toSorted(),
350 );
351 }).pipe(Effect.provide(layer));
352 },
353);
354
355it.effect(
356 "falls back to the repository main branch when the Bitbucket development branch is invalid",
357 () => {
358 const { layer } = makeLayer({
359 response: (request) =>
360 Response.json(
361 request.url.endsWith("/branching-model")
362 ? {
363 development: {
364 name: "develop",
365 use_mainbranch: false,
366 is_valid: false,
367 },
368 }
369 : repositoryJson,
370 ),
371 });
372
373 return Effect.gen(function* () {
374 const bitbucket = yield* BitbucketApi.BitbucketApi;
375 const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" });
376
377 assert.strictEqual(defaultBranch, "main");
378 }).pipe(Effect.provide(layer));
379 },
380);
381
382it.effect(
383 "falls back to the repository main branch when the Bitbucket branching model is unavailable",
384 () => {
385 const { layer } = makeLayer({
386 response: (request) =>
387 request.url.endsWith("/branching-model")
388 ? Response.json({ error: { message: "Not found" } }, { status: 404 })
389 : Response.json(repositoryJson),
390 });
391
392 return Effect.gen(function* () {
393 const bitbucket = yield* BitbucketApi.BitbucketApi;
394 const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" });
395
396 assert.strictEqual(defaultBranch, "main");
397 }).pipe(Effect.provide(layer));
398 },
399);
400
310it.effect("creates repositories through the Bitbucket REST API", () => {401it.effect("creates repositories through the Bitbucket REST API", () => {
311 const { execute, layer } = makeLayer({402 const { execute, layer } = makeLayer({
312 response: () => Response.json(repositoryJson),403 response: () => Response.json(repositoryJson),
apps/server/src/sourceControl/BitbucketApi.ts+72-13
...@@ -65,6 +65,23 @@ const RawBitbucketRepositorySchema = Schema.Struct({...@@ -65,6 +65,23 @@ const RawBitbucketRepositorySchema = Schema.Struct({
65 ),65 ),
66});66});
6767
68const RawBitbucketBranchingModelSchema = Schema.Struct({
69 development: Schema.optional(
70 Schema.Struct({
71 branch: Schema.optional(
72 Schema.NullOr(
73 Schema.Struct({
74 name: Schema.optional(TrimmedNonEmptyString),
75 }),
76 ),
77 ),
78 is_valid: Schema.optional(Schema.Boolean),
79 name: Schema.optional(Schema.NullOr(Schema.String)),
80 use_mainbranch: Schema.optional(Schema.Boolean),
81 }),
82 ),
83});
84
68const BitbucketUserSchema = Schema.Struct({85const BitbucketUserSchema = Schema.Struct({
69 username: Schema.optional(TrimmedNonEmptyString),86 username: Schema.optional(TrimmedNonEmptyString),
70 display_name: Schema.optional(TrimmedNonEmptyString),87 display_name: Schema.optional(TrimmedNonEmptyString),
...@@ -216,7 +233,7 @@ function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator...@@ -216,7 +233,7 @@ function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator
216}233}
217234
218function normalizeRepositoryCloneUrls(235function normalizeRepositoryCloneUrls(
219 raw: Schema.Schema.Type<typeof RawBitbucketRepositorySchema>,236 raw: typeof RawBitbucketRepositorySchema.Type,
220): SourceControlRepositoryCloneUrls {237): SourceControlRepositoryCloneUrls {
221 const httpClone =238 const httpClone =
222 raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ??239 raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ??
...@@ -230,6 +247,24 @@ function normalizeRepositoryCloneUrls(...@@ -230,6 +247,24 @@ function normalizeRepositoryCloneUrls(
230 };247 };
231}248}
232249
250function defaultChangeRequestTargetBranch(input: {
251 readonly repository: typeof RawBitbucketRepositorySchema.Type;
252 readonly branchingModel: typeof RawBitbucketBranchingModelSchema.Type | null;
253}): string | null {
254 const repositoryMainBranch = input.repository.mainbranch?.name ?? null;
255 const development = input.branchingModel?.development;
256 if (!development || development.use_mainbranch === true || development.is_valid === false) {
257 return repositoryMainBranch;
258 }
259
260 const developmentBranch = development.branch?.name?.trim() ?? development.name?.trim() ?? "";
261 if (developmentBranch.length === 0 || developmentBranch === "null") {
262 return repositoryMainBranch;
263 }
264
265 return developmentBranch;
266}
267
233function shouldPreferSshRemote(originRemoteUrl: string | null): boolean {268function shouldPreferSshRemote(originRemoteUrl: string | null): boolean {
234 const trimmed = originRemoteUrl?.trim() ?? "";269 const trimmed = originRemoteUrl?.trim() ?? "";
235 return trimmed.startsWith("git@") || trimmed.startsWith("ssh://");270 return trimmed.startsWith("git@") || trimmed.startsWith("ssh://");
...@@ -430,23 +465,32 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {...@@ -430,23 +465,32 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {
430 });465 });
431 });466 });
432467
468 const getRepositoryFromLocator = (repository: BitbucketRepositoryLocator) =>
469 executeJson(
470 "getRepository",
471 HttpClientRequest.get(
472 apiUrl(
473 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`,
474 ),
475 ),
476 RawBitbucketRepositorySchema,
477 );
478
433 const getRepository = (input: {479 const getRepository = (input: {
434 readonly cwd: string;480 readonly cwd: string;
435 readonly context?: SourceControlProvider.SourceControlProviderContext;481 readonly context?: SourceControlProvider.SourceControlProviderContext;
436 readonly repository?: string;482 readonly repository?: string;
437 }) =>483 }) => resolveRepository(input).pipe(Effect.flatMap(getRepositoryFromLocator));
438 resolveRepository(input).pipe(484
439 Effect.flatMap((repository) =>485 const getBranchingModelFromLocator = (repository: BitbucketRepositoryLocator) =>
440 executeJson(486 executeJson(
441 "getRepository",487 "getBranchingModel",
442 HttpClientRequest.get(488 HttpClientRequest.get(
443 apiUrl(489 apiUrl(
444 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`,490 `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/branching-model`,
445 ),
446 ),
447 RawBitbucketRepositorySchema,
448 ),491 ),
449 ),492 ),
493 RawBitbucketBranchingModelSchema,
450 );494 );
451495
452 const getRawPullRequestFromRepository = (496 const getRawPullRequestFromRepository = (
...@@ -628,7 +672,22 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {...@@ -628,7 +672,22 @@ export const make = Effect.fn("makeBitbucketApi")(function* () {
628 );672 );
629 }),673 }),
630 getDefaultBranch: (input) =>674 getDefaultBranch: (input) =>
631 getRepository(input).pipe(Effect.map((repository) => repository.mainbranch?.name ?? null)),675 resolveRepository(input).pipe(
676 Effect.flatMap((locator) =>
677 Effect.all(
678 {
679 repository: getRepositoryFromLocator(locator),
680 branchingModel: getBranchingModelFromLocator(locator).pipe(
681 Effect.catch(() =>
682 Effect.succeed<typeof RawBitbucketBranchingModelSchema.Type | null>(null),
683 ),
684 ),
685 },
686 { concurrency: "unbounded" },
687 ),
688 ),
689 Effect.map(defaultChangeRequestTargetBranch),
690 ),
632 // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide691 // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide
633 // an official checkout CLI. This provider-local path uses GitVcsDriver as a692 // an official checkout CLI. This provider-local path uses GitVcsDriver as a
634 // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this693 // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this