diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 8e2bc56c007b..2ea48c3fdc8c 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -6,6 +6,7 @@ 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 * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; @@ -16,6 +17,8 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; +const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError); + const bitbucketPullRequest = { id: 42, title: "Add Bitbucket provider", @@ -99,7 +102,7 @@ function makeLayer(input: { } satisfies Partial; const driver = { - listRemotes: () => + listRemotes: vi.fn(() => Effect.succeed({ remotes: [ { @@ -115,8 +118,26 @@ function makeLayer(input: { expiresAt: Option.none(), }, }), + ), } satisfies Partial; + const resolve = vi.fn(() => + Effect.succeed({ + kind: "git" as const, + repository: { + kind: "git" as const, + rootPath: "/repo", + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), + expiresAt: Option.none(), + }, + }, + driver: driver as unknown as VcsDriver.VcsDriver["Service"], + }), + ); + const layer = BitbucketApi.layer.pipe( Layer.provide( Layer.succeed( @@ -126,21 +147,7 @@ function makeLayer(input: { ), Layer.provide( Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ - resolve: () => - Effect.succeed({ - kind: "git", - repository: { - kind: "git", - rootPath: "/repo", - metadataPath: null, - freshness: { - source: "live-local" as const, - observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), - expiresAt: Option.none(), - }, - }, - driver: driver as unknown as VcsDriver.VcsDriver["Service"], - }), + resolve, }), ), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(git)), @@ -159,7 +166,7 @@ function makeLayer(input: { Layer.provideMerge(NodeServices.layer), ); - return { execute, git: gitMock, layer }; + return { execute, git: gitMock, resolve, listRemotes: driver.listRemotes, layer }; } it.effect("parses pull request responses from the Bitbucket REST API", () => { @@ -333,6 +340,102 @@ it.effect("reads repository clone URLs and default branch", () => { }).pipe(Effect.provide(layer)); }); +it.effect.each([false, true])( + "rejects invalid explicit repositories before fallback, context=%s", + (withContext) => { + const { execute, resolve, listRemotes, layer } = makeLayer({ + response: () => Response.json(repositoryJson), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + for (const repository of ["t3code", "", " \t "]) { + const error = yield* bitbucket + .getRepositoryCloneUrls({ + cwd: "/repo", + repository, + ...(withContext + ? { + context: { + provider: { + kind: "bitbucket" as const, + name: "Bitbucket", + baseUrl: "https://bitbucket.org", + }, + remoteName: "origin", + remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git", + }, + } + : {}), + }) + .pipe(Effect.match({ onFailure: (error) => error, onSuccess: () => null })); + + assert.instanceOf(error, BitbucketApi.BitbucketRepositoryLocatorError); + assert.strictEqual( + isBitbucketRepositoryLocatorError(error) ? error.repository : null, + repository, + ); + assert.strictEqual(resolve.mock.calls.length, 0); + assert.strictEqual(listRemotes.mock.calls.length, 0); + assert.strictEqual(execute.mock.calls.length, 0); + } + }).pipe(Effect.provide(layer)); + }, +); + +it.effect("prefers an explicit repository and uses context when the repository is omitted", () => { + const { execute, resolve, listRemotes, layer } = makeLayer({ + response: (request) => + Response.json(request.url.endsWith("/branching-model") ? {} : repositoryJson), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const context = { + provider: { kind: "bitbucket" as const, name: "Bitbucket", baseUrl: "https://bitbucket.org" }, + remoteName: "origin", + remoteUrl: "git@bitbucket.org:another/context.git", + }; + for (const repository of ["pingdotgg/t3code", "https://bitbucket.org/pingdotgg/t3code.git"]) { + yield* bitbucket.getRepositoryCloneUrls({ cwd: "/repo", context, repository }); + } + yield* bitbucket.getDefaultBranch({ cwd: "/repo", context }); + + assert.deepStrictEqual( + execute.mock.calls.map(([request]) => request.url).toSorted(), + [ + "https://api.test.local/2.0/repositories/pingdotgg/t3code", + "https://api.test.local/2.0/repositories/pingdotgg/t3code", + "https://api.test.local/2.0/repositories/another/context", + "https://api.test.local/2.0/repositories/another/context/branching-model", + ].toSorted(), + ); + assert.strictEqual(resolve.mock.calls.length, 0); + assert.strictEqual(listRemotes.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); +}); + +it.effect("discovers the repository from cwd remotes when the repository is omitted", () => { + const { execute, resolve, listRemotes, layer } = makeLayer({ + response: (request) => + Response.json(request.url.endsWith("/branching-model") ? {} : repositoryJson), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const branch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(branch, "main"); + assert.deepStrictEqual(resolve.mock.calls, [[{ cwd: "/repo" }]]); + assert.deepStrictEqual(listRemotes.mock.calls, [["/repo"]]); + assert.isTrue( + execute.mock.calls.every(([request]) => + request.url.startsWith("https://api.test.local/2.0/repositories/pingdotgg/t3code"), + ), + ); + }).pipe(Effect.provide(layer)); +}); + it.effect( "prefers the Bitbucket branching model development branch as the default PR target", () => { diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 3f34385a2d3c..18ff81af1c19 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -72,12 +72,14 @@ export class BitbucketRepositoryLocatorError extends Schema.TaggedError + Effect.gen(function* () { + const locatorCause = new BitbucketApi.BitbucketRepositoryLocatorError({ + repository: "credential-token-abc123", + }); + const provider = yield* BitbucketSourceControlProvider.make.pipe( + Effect.provide( + Layer.mock(BitbucketApi.BitbucketApi)({ + getRepositoryCloneUrls: () => Effect.fail(locatorCause), + createRepository: () => Effect.fail(locatorCause), + }), + ), + ); + + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const input = { + provider: "bitbucket" as const, + repository: locatorCause.repository, + cwd: "/workspace", + }; + for (const operation of [ + service.lookupRepository(input).pipe(Effect.asVoid), + service.publishRepository({ ...input, visibility: "private" }).pipe(Effect.asVoid), + ]) { + const error = yield* Effect.flip(operation); + + assert.strictEqual(error.provider, "bitbucket"); + assert.strictEqual( + error.detail, + "Bitbucket repositories must be specified as workspace/repository.", + ); + assert.notInclude(error.message, locatorCause.repository); + assert.instanceOf(error.cause, SourceControlProviderError); + assert.strictEqual( + isSourceControlProviderError(error.cause) ? error.cause.cause : null, + locatorCause, + ); + } + }).pipe(Effect.provide(layer({ provider }))); + }), +); + +it.effect("keeps a plain Bitbucket locator lookalike out of the repository message", () => { + const provider = makeProvider({ + kind: "bitbucket", + getRepositoryCloneUrls: () => + Effect.fail( + new SourceControlProviderError({ + provider: "bitbucket", + operation: "getRepositoryCloneUrls", + cwd: "/workspace", + detail: "credential token abc123 was rejected", + cause: { + _tag: "BitbucketRepositoryLocatorError", + repository: "t3code", + detail: "credential token abc123 was rejected", + }, + }), + ), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* Effect.flip( + service.lookupRepository({ provider: "bitbucket", repository: "t3code" }), + ); + + assert.strictEqual(error.detail, "The source control operation could not be completed."); + assert.notInclude(error.message, "abc123"); + }).pipe(Effect.provide(layer({ provider }))); +}); + +it.effect("keeps arbitrary Bitbucket request failures out of the repository message", () => + Effect.gen(function* () { + const cause = new BitbucketApi.BitbucketRequestError({ + operation: "getRepository", + cause: new Error("credential token abc123 was rejected"), + }); + const provider = yield* BitbucketSourceControlProvider.make.pipe( + Effect.provide( + Layer.mock(BitbucketApi.BitbucketApi)({ + getRepositoryCloneUrls: () => Effect.fail(cause), + }), + ), + ); + + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* Effect.flip( + service.lookupRepository({ + provider: "bitbucket", + repository: "pingdotgg/t3code", + cwd: "/workspace", + }), + ); + + assert.strictEqual(error.detail, "The source control operation could not be completed."); + assert.notInclude(error.message, "abc123"); + assert.notInclude(error.message, cause.detail); + }).pipe(Effect.provide(layer({ provider }))); + }), +); + it.effect("clones a looked-up repository into the requested destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index eaf65b5cd662..845636c01f83 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -7,6 +7,7 @@ import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import { + SourceControlProviderError, SourceControlRepositoryError, type SourceControlCloneRepositoryInput, type SourceControlCloneRepositoryResult, @@ -26,8 +27,11 @@ import { type GitCloneProgressLine, } from "../project/gitCloneProgress.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as BitbucketApi from "./BitbucketApi.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); +const isSourceControlProviderError = Schema.is(SourceControlProviderError); +const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError); export class SourceControlRepositoryService extends Context.Service< SourceControlRepositoryService, @@ -92,7 +96,12 @@ function mapRepositoryError(operation: string, provider: SourceControlProviderKi : new SourceControlRepositoryError({ operation, provider, - detail: "The source control operation could not be completed.", + detail: + isSourceControlProviderError(cause) && + cause.provider === "bitbucket" && + isBitbucketRepositoryLocatorError(cause.cause) + ? BitbucketApi.BitbucketRepositoryLocatorError.detail + : "The source control operation could not be completed.", cause, }), );