diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index e4a7649e74a..5a9759ace0b 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -533,8 +533,8 @@ it.effect("preserves the HTTP client failure without deriving the domain message }), ); + assert.instanceOf(error, BitbucketApi.BitbucketRequestError); assert.strictEqual(error.operation, "getPullRequest"); - assert.strictEqual(error.detail, "Failed to send the Bitbucket request."); assert.strictEqual( error.message, "Bitbucket API failed in getPullRequest: Failed to send the Bitbucket request.", @@ -544,6 +544,61 @@ it.effect("preserves the HTTP client failure without deriving the domain message }).pipe(Effect.provide(layer)); }); +it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => { + const responseBody = '{"error":{"message":"credential=secret-value"}}'; + const { layer } = makeLayer({ + response: () => new Response(responseBody, { status: 403 }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* bitbucket + .checkoutPullRequest({ cwd: "/repo", reference: "42" }) + .pipe(Effect.flip); + + assert.instanceOf(error, BitbucketApi.BitbucketResponseError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.status, 403); + assert.strictEqual(error.responseBodyLength, responseBody.length); + assert.notProperty(error, "responseBody"); + assert.strictEqual( + error.message, + "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 403.", + ); + assert.notInclude(error.message, "secret-value"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("preserves Bitbucket response body read failures as their immediate cause", () => { + const cause = new Error("response stream failed"); + const { layer } = makeLayer({ + response: () => + new Response( + new ReadableStream({ + start: (controller) => controller.error(cause), + }), + { status: 502 }, + ), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* bitbucket + .getPullRequest({ cwd: "/repo", reference: "42" }) + .pipe(Effect.flip); + + assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.status, 502); + assert.instanceOf(error.cause, HttpClientError.HttpClientError); + assert.strictEqual(error.cause.cause, cause); + assert.strictEqual( + error.message, + "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 502.", + ); + }).pipe(Effect.provide(layer)); +}); + it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { const { git, layer } = makeLayer({ response: () => @@ -630,8 +685,9 @@ it.effect("preserves Git checkout failures without deriving the domain message f }), ); - assert.strictEqual(error.operation, "checkoutPullRequest"); - assert.strictEqual(error.detail, "Failed to check out the Bitbucket pull request."); + assert.instanceOf(error, BitbucketApi.BitbucketCheckoutError); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.reference, "42"); assert.strictEqual( error.message, "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request.", diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 9a678ab44dc..f7d7f6671a4 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -6,6 +6,7 @@ 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, @@ -36,20 +37,156 @@ const BitbucketApiEnvConfig = Config.all({ apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), }); -export class BitbucketApiError extends Schema.TaggedErrorClass()( - "BitbucketApiError", +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", { - operation: Schema.String, - detail: Schema.String, - status: Schema.optional(Schema.Number), - cause: Schema.optional(Schema.Defect()), + repository: Schema.String, }, ) { override get message(): string { - return `Bitbucket API failed in ${this.operation}: ${this.detail}`; + return "Bitbucket API failed in createRepository: Bitbucket repositories must be specified as workspace/repository."; } } -const isBitbucketApiError = Schema.is(BitbucketApiError); + +export class BitbucketRequestError extends Schema.TaggedErrorClass()( + "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", + { + 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", + { + 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", + { + 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", + { + 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", + { + 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", + { + 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", + { + 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", + { + 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, @@ -209,16 +346,14 @@ function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator } function requireRepositoryLocator( - operation: string, repository: string, ): Effect.Effect { const locator = parseBitbucketRepositorySlug(repository); return locator ? Effect.succeed(locator) : Effect.fail( - new BitbucketApiError({ - operation, - detail: "Bitbucket repositories must be specified as workspace/repository.", + new BitbucketRepositoryLocatorError({ + repository, }), ); } @@ -339,20 +474,24 @@ function authFromConfig( } function responseError( - operation: string, + operation: BitbucketApiOperation, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect { return response.text.pipe( - Effect.orElseSucceed(() => ""), + Effect.mapError( + (cause) => + new BitbucketResponseBodyReadError({ + operation, + status: response.status, + cause, + }), + ), Effect.flatMap((body) => Effect.fail( - new BitbucketApiError({ + new BitbucketResponseError({ operation, status: response.status, - detail: - body.trim().length > 0 - ? `Bitbucket returned HTTP ${response.status}: ${body.trim()}` - : `Bitbucket returned HTTP ${response.status}.`, + responseBodyLength: body.length, }), ), ), @@ -379,7 +518,7 @@ export const make = Effect.gen(function* () { }; const decodeResponse = ( - operation: string, + operation: BitbucketApiOperation, schema: S, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect => @@ -388,9 +527,9 @@ export const make = Effect.gen(function* () { HttpClientResponse.schemaBodyJson(schema)(success).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ + new BitbucketResponseDecodeError({ operation, - detail: "Bitbucket returned invalid JSON for the requested resource.", + status: success.status, cause, }), ), @@ -399,16 +538,15 @@ export const make = Effect.gen(function* () { })(response); const executeJson = ( - operation: string, + operation: BitbucketApiOperation, request: HttpClientRequest.HttpClientRequest, schema: S, ): Effect.Effect => httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ + new BitbucketRequestError({ operation, - detail: "Failed to send the Bitbucket request.", cause, }), ), @@ -433,9 +571,8 @@ export const make = Effect.gen(function* () { const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ - operation: "resolveRepository", - detail: `Failed to resolve VCS repository for ${input.cwd}.`, + new BitbucketRepositoryVcsResolveError({ + cwd: input.cwd, cause, }), ), @@ -443,9 +580,8 @@ export const make = Effect.gen(function* () { const remotes = yield* handle.driver.listRemotes(input.cwd).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ - operation: "resolveRepository", - detail: `Failed to list remotes for ${input.cwd}.`, + new BitbucketRepositoryRemotesListError({ + cwd: input.cwd, cause, }), ), @@ -457,9 +593,8 @@ export const make = Effect.gen(function* () { if (parsed) return parsed; } - return yield* new BitbucketApiError({ - operation: "resolveRepository", - detail: `No Bitbucket repository remote was detected for ${input.cwd}.`, + return yield* new BitbucketRepositoryRemoteNotFoundError({ + cwd: input.cwd, }); }); @@ -600,7 +735,7 @@ export const make = Effect.gen(function* () { getRepositoryCloneUrls: (input) => getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), createRepository: (input) => - requireRepositoryLocator("createRepository", input.repository).pipe( + requireRepositoryLocator(input.repository).pipe( Effect.flatMap((repository) => executeJson( "createRepository", @@ -625,9 +760,9 @@ export const make = Effect.gen(function* () { const description = yield* fileSystem.readFileString(input.bodyFile).pipe( Effect.mapError( (cause) => - new BitbucketApiError({ - operation: "createPullRequest", - detail: `Failed to read pull request body file ${input.bodyFile}.`, + new BitbucketPullRequestBodyReadError({ + cwd: input.cwd, + bodyFile: input.bodyFile, cause, }), ), @@ -743,9 +878,9 @@ export const make = Effect.gen(function* () { Effect.mapError((cause) => isBitbucketApiError(cause) ? cause - : new BitbucketApiError({ - operation: "checkoutPullRequest", - detail: "Failed to check out the Bitbucket pull request.", + : new BitbucketCheckoutError({ + cwd: input.cwd, + reference: input.reference, cause, }), ), diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index 75ac877cd43..eeb4c8fbdd2 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -53,11 +53,10 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( it.effect("adds repository context while retaining Bitbucket API causes", () => Effect.gen(function* () { - const cause = new BitbucketApi.BitbucketApiError({ + const upstreamCause = new Error("raw upstream failure"); + const cause = new BitbucketApi.BitbucketRequestError({ operation: "getRepository", - detail: "upstream detail that should remain in the cause", - status: 503, - cause: new Error("raw upstream failure"), + cause: upstreamCause, }); const provider = yield* makeProvider({ getRepositoryCloneUrls: () => Effect.fail(cause), @@ -86,7 +85,7 @@ it.effect("adds repository context while retaining Bitbucket API causes", () => }, ); assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes(cause.detail), false); + assert.equal(error.message.includes(upstreamCause.message), false); }), );