authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 18:47:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 18:47:12-07:00
loga3dadc04bf38b49bc8476a8e6c59bda7dc81d2e4
treed9ed773dbe21867e612dcebfe06af1b4dde15e58
parent1a59277dc84105e2131c2bc828deb51c0b12d7f6
signature Signed by PGP key B5690EEEBB952194

[codex] Structure Bitbucket API errors (#3457)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 239 insertions(+), 49 deletions(-)

apps/server/src/sourceControl/BitbucketApi.test.ts+59-3
......@@ -533,8 +533,8 @@ it.effect("preserves the HTTP client failure without deriving the domain message
533533 }),
534534 );
535535
536 assert.instanceOf(error, BitbucketApi.BitbucketRequestError);
536537 assert.strictEqual(error.operation, "getPullRequest");
537 assert.strictEqual(error.detail, "Failed to send the Bitbucket request.");
538538 assert.strictEqual(
539539 error.message,
540540 "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
544544 }).pipe(Effect.provide(layer));
545545});
546546
547it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => {
548 const responseBody = '{"error":{"message":"credential=secret-value"}}';
549 const { layer } = makeLayer({
550 response: () => new Response(responseBody, { status: 403 }),
551 });
552
553 return Effect.gen(function* () {
554 const bitbucket = yield* BitbucketApi.BitbucketApi;
555 const error = yield* bitbucket
556 .checkoutPullRequest({ cwd: "/repo", reference: "42" })
557 .pipe(Effect.flip);
558
559 assert.instanceOf(error, BitbucketApi.BitbucketResponseError);
560 assert.strictEqual(error.operation, "getPullRequest");
561 assert.strictEqual(error.status, 403);
562 assert.strictEqual(error.responseBodyLength, responseBody.length);
563 assert.notProperty(error, "responseBody");
564 assert.strictEqual(
565 error.message,
566 "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 403.",
567 );
568 assert.notInclude(error.message, "secret-value");
569 }).pipe(Effect.provide(layer));
570});
571
572it.effect("preserves Bitbucket response body read failures as their immediate cause", () => {
573 const cause = new Error("response stream failed");
574 const { layer } = makeLayer({
575 response: () =>
576 new Response(
577 new ReadableStream<Uint8Array>({
578 start: (controller) => controller.error(cause),
579 }),
580 { status: 502 },
581 ),
582 });
583
584 return Effect.gen(function* () {
585 const bitbucket = yield* BitbucketApi.BitbucketApi;
586 const error = yield* bitbucket
587 .getPullRequest({ cwd: "/repo", reference: "42" })
588 .pipe(Effect.flip);
589
590 assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError);
591 assert.strictEqual(error.operation, "getPullRequest");
592 assert.strictEqual(error.status, 502);
593 assert.instanceOf(error.cause, HttpClientError.HttpClientError);
594 assert.strictEqual(error.cause.cause, cause);
595 assert.strictEqual(
596 error.message,
597 "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 502.",
598 );
599 }).pipe(Effect.provide(layer));
600});
601
547602it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => {
548603 const { git, layer } = makeLayer({
549604 response: () =>
......@@ -630,8 +685,9 @@ it.effect("preserves Git checkout failures without deriving the domain message f
630685 }),
631686 );
632687
633 assert.strictEqual(error.operation, "checkoutPullRequest");
634 assert.strictEqual(error.detail, "Failed to check out the Bitbucket pull request.");
688 assert.instanceOf(error, BitbucketApi.BitbucketCheckoutError);
689 assert.strictEqual(error.cwd, "/repo");
690 assert.strictEqual(error.reference, "42");
635691 assert.strictEqual(
636692 error.message,
637693 "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request.",
apps/server/src/sourceControl/BitbucketApi.ts+176-41
......@@ -6,6 +6,7 @@ import * as Layer from "effect/Layer";
66import * as Option from "effect/Option";
77import * as Schema from "effect/Schema";
88import {
9 NonNegativeInt,
910 TrimmedNonEmptyString,
1011 type SourceControlProviderAuth,
1112 type SourceControlRepositoryCloneUrls,
......@@ -36,20 +37,156 @@ const BitbucketApiEnvConfig = Config.all({
3637 apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option),
3738});
3839
39export class BitbucketApiError extends Schema.TaggedErrorClass<BitbucketApiError>()(
40 "BitbucketApiError",
40const BitbucketApiOperation = Schema.Literals([
41 "resolveRepository",
42 "getRepository",
43 "getBranchingModel",
44 "getPullRequest",
45 "listPullRequests",
46 "createRepository",
47 "createPullRequest",
48 "probeAuth",
49 "checkoutPullRequest",
50]);
51type BitbucketApiOperation = typeof BitbucketApiOperation.Type;
52
53export class BitbucketRepositoryLocatorError extends Schema.TaggedErrorClass<BitbucketRepositoryLocatorError>()(
54 "BitbucketRepositoryLocatorError",
4155 {
42 operation: Schema.String,
43 detail: Schema.String,
44 status: Schema.optional(Schema.Number),
45 cause: Schema.optional(Schema.Defect()),
56 repository: Schema.String,
4657 },
4758) {
4859 override get message(): string {
49 return `Bitbucket API failed in ${this.operation}: ${this.detail}`;
60 return "Bitbucket API failed in createRepository: Bitbucket repositories must be specified as workspace/repository.";
5061 }
5162}
52const isBitbucketApiError = Schema.is(BitbucketApiError);
63
64export class BitbucketRequestError extends Schema.TaggedErrorClass<BitbucketRequestError>()(
65 "BitbucketRequestError",
66 {
67 operation: BitbucketApiOperation,
68 cause: Schema.Defect(),
69 },
70) {
71 override get message(): string {
72 return `Bitbucket API failed in ${this.operation}: Failed to send the Bitbucket request.`;
73 }
74}
75
76export class BitbucketResponseError extends Schema.TaggedErrorClass<BitbucketResponseError>()(
77 "BitbucketResponseError",
78 {
79 operation: BitbucketApiOperation,
80 status: Schema.Int,
81 responseBodyLength: NonNegativeInt,
82 },
83) {
84 override get message(): string {
85 return `Bitbucket API failed in ${this.operation}: Bitbucket returned HTTP ${this.status}.`;
86 }
87}
88
89export class BitbucketResponseBodyReadError extends Schema.TaggedErrorClass<BitbucketResponseBodyReadError>()(
90 "BitbucketResponseBodyReadError",
91 {
92 operation: BitbucketApiOperation,
93 status: Schema.Int,
94 cause: Schema.Defect(),
95 },
96) {
97 override get message(): string {
98 return `Bitbucket API failed in ${this.operation}: Bitbucket returned HTTP ${this.status}.`;
99 }
100}
101
102export class BitbucketResponseDecodeError extends Schema.TaggedErrorClass<BitbucketResponseDecodeError>()(
103 "BitbucketResponseDecodeError",
104 {
105 operation: BitbucketApiOperation,
106 status: Schema.Int,
107 cause: Schema.Defect(),
108 },
109) {
110 override get message(): string {
111 return `Bitbucket API failed in ${this.operation}: Bitbucket returned invalid JSON for the requested resource.`;
112 }
113}
114
115export class BitbucketRepositoryVcsResolveError extends Schema.TaggedErrorClass<BitbucketRepositoryVcsResolveError>()(
116 "BitbucketRepositoryVcsResolveError",
117 {
118 cwd: Schema.String,
119 cause: Schema.Defect(),
120 },
121) {
122 override get message(): string {
123 return `Bitbucket API failed in resolveRepository: Failed to resolve VCS repository for ${this.cwd}.`;
124 }
125}
126
127export class BitbucketRepositoryRemotesListError extends Schema.TaggedErrorClass<BitbucketRepositoryRemotesListError>()(
128 "BitbucketRepositoryRemotesListError",
129 {
130 cwd: Schema.String,
131 cause: Schema.Defect(),
132 },
133) {
134 override get message(): string {
135 return `Bitbucket API failed in resolveRepository: Failed to list remotes for ${this.cwd}.`;
136 }
137}
138
139export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedErrorClass<BitbucketRepositoryRemoteNotFoundError>()(
140 "BitbucketRepositoryRemoteNotFoundError",
141 {
142 cwd: Schema.String,
143 },
144) {
145 override get message(): string {
146 return `Bitbucket API failed in resolveRepository: No Bitbucket repository remote was detected for ${this.cwd}.`;
147 }
148}
149
150export class BitbucketPullRequestBodyReadError extends Schema.TaggedErrorClass<BitbucketPullRequestBodyReadError>()(
151 "BitbucketPullRequestBodyReadError",
152 {
153 cwd: Schema.String,
154 bodyFile: Schema.String,
155 cause: Schema.Defect(),
156 },
157) {
158 override get message(): string {
159 return `Bitbucket API failed in createPullRequest: Failed to read pull request body file ${this.bodyFile}.`;
160 }
161}
162
163export class BitbucketCheckoutError extends Schema.TaggedErrorClass<BitbucketCheckoutError>()(
164 "BitbucketCheckoutError",
165 {
166 cwd: Schema.String,
167 reference: Schema.String,
168 cause: Schema.Defect(),
169 },
170) {
171 override get message(): string {
172 return "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request.";
173 }
174}
175
176export const BitbucketApiError = Schema.Union([
177 BitbucketRepositoryLocatorError,
178 BitbucketRequestError,
179 BitbucketResponseError,
180 BitbucketResponseBodyReadError,
181 BitbucketResponseDecodeError,
182 BitbucketRepositoryVcsResolveError,
183 BitbucketRepositoryRemotesListError,
184 BitbucketRepositoryRemoteNotFoundError,
185 BitbucketPullRequestBodyReadError,
186 BitbucketCheckoutError,
187]);
188export type BitbucketApiError = typeof BitbucketApiError.Type;
189export const isBitbucketApiError = Schema.is(BitbucketApiError);
53190
54191const RawBitbucketRepositorySchema = Schema.Struct({
55192 full_name: TrimmedNonEmptyString,
......@@ -209,16 +346,14 @@ function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator
209346}
210347
211348function requireRepositoryLocator(
212 operation: string,
213349 repository: string,
214350): Effect.Effect<BitbucketRepositoryLocator, BitbucketApiError> {
215351 const locator = parseBitbucketRepositorySlug(repository);
216352 return locator
217353 ? Effect.succeed(locator)
218354 : Effect.fail(
219 new BitbucketApiError({
220 operation,
221 detail: "Bitbucket repositories must be specified as workspace/repository.",
355 new BitbucketRepositoryLocatorError({
356 repository,
222357 }),
223358 );
224359}
......@@ -339,20 +474,24 @@ function authFromConfig(
339474}
340475
341476function responseError(
342 operation: string,
477 operation: BitbucketApiOperation,
343478 response: HttpClientResponse.HttpClientResponse,
344479): Effect.Effect<never, BitbucketApiError> {
345480 return response.text.pipe(
346 Effect.orElseSucceed(() => ""),
481 Effect.mapError(
482 (cause) =>
483 new BitbucketResponseBodyReadError({
484 operation,
485 status: response.status,
486 cause,
487 }),
488 ),
347489 Effect.flatMap((body) =>
348490 Effect.fail(
349 new BitbucketApiError({
491 new BitbucketResponseError({
350492 operation,
351493 status: response.status,
352 detail:
353 body.trim().length > 0
354 ? `Bitbucket returned HTTP ${response.status}: ${body.trim()}`
355 : `Bitbucket returned HTTP ${response.status}.`,
494 responseBodyLength: body.length,
356495 }),
357496 ),
358497 ),
......@@ -379,7 +518,7 @@ export const make = Effect.gen(function* () {
379518 };
380519
381520 const decodeResponse = <S extends Schema.Top>(
382 operation: string,
521 operation: BitbucketApiOperation,
383522 schema: S,
384523 response: HttpClientResponse.HttpClientResponse,
385524 ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> =>
......@@ -388,9 +527,9 @@ export const make = Effect.gen(function* () {
388527 HttpClientResponse.schemaBodyJson(schema)(success).pipe(
389528 Effect.mapError(
390529 (cause) =>
391 new BitbucketApiError({
530 new BitbucketResponseDecodeError({
392531 operation,
393 detail: "Bitbucket returned invalid JSON for the requested resource.",
532 status: success.status,
394533 cause,
395534 }),
396535 ),
......@@ -399,16 +538,15 @@ export const make = Effect.gen(function* () {
399538 })(response);
400539
401540 const executeJson = <S extends Schema.Top>(
402 operation: string,
541 operation: BitbucketApiOperation,
403542 request: HttpClientRequest.HttpClientRequest,
404543 schema: S,
405544 ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> =>
406545 httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe(
407546 Effect.mapError(
408547 (cause) =>
409 new BitbucketApiError({
548 new BitbucketRequestError({
410549 operation,
411 detail: "Failed to send the Bitbucket request.",
412550 cause,
413551 }),
414552 ),
......@@ -433,9 +571,8 @@ export const make = Effect.gen(function* () {
433571 const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe(
434572 Effect.mapError(
435573 (cause) =>
436 new BitbucketApiError({
437 operation: "resolveRepository",
438 detail: `Failed to resolve VCS repository for ${input.cwd}.`,
574 new BitbucketRepositoryVcsResolveError({
575 cwd: input.cwd,
439576 cause,
440577 }),
441578 ),
......@@ -443,9 +580,8 @@ export const make = Effect.gen(function* () {
443580 const remotes = yield* handle.driver.listRemotes(input.cwd).pipe(
444581 Effect.mapError(
445582 (cause) =>
446 new BitbucketApiError({
447 operation: "resolveRepository",
448 detail: `Failed to list remotes for ${input.cwd}.`,
583 new BitbucketRepositoryRemotesListError({
584 cwd: input.cwd,
449585 cause,
450586 }),
451587 ),
......@@ -457,9 +593,8 @@ export const make = Effect.gen(function* () {
457593 if (parsed) return parsed;
458594 }
459595
460 return yield* new BitbucketApiError({
461 operation: "resolveRepository",
462 detail: `No Bitbucket repository remote was detected for ${input.cwd}.`,
596 return yield* new BitbucketRepositoryRemoteNotFoundError({
597 cwd: input.cwd,
463598 });
464599 });
465600
......@@ -600,7 +735,7 @@ export const make = Effect.gen(function* () {
600735 getRepositoryCloneUrls: (input) =>
601736 getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)),
602737 createRepository: (input) =>
603 requireRepositoryLocator("createRepository", input.repository).pipe(
738 requireRepositoryLocator(input.repository).pipe(
604739 Effect.flatMap((repository) =>
605740 executeJson(
606741 "createRepository",
......@@ -625,9 +760,9 @@ export const make = Effect.gen(function* () {
625760 const description = yield* fileSystem.readFileString(input.bodyFile).pipe(
626761 Effect.mapError(
627762 (cause) =>
628 new BitbucketApiError({
629 operation: "createPullRequest",
630 detail: `Failed to read pull request body file ${input.bodyFile}.`,
763 new BitbucketPullRequestBodyReadError({
764 cwd: input.cwd,
765 bodyFile: input.bodyFile,
631766 cause,
632767 }),
633768 ),
......@@ -743,9 +878,9 @@ export const make = Effect.gen(function* () {
743878 Effect.mapError((cause) =>
744879 isBitbucketApiError(cause)
745880 ? cause
746 : new BitbucketApiError({
747 operation: "checkoutPullRequest",
748 detail: "Failed to check out the Bitbucket pull request.",
881 : new BitbucketCheckoutError({
882 cwd: input.cwd,
883 reference: input.reference,
749884 cause,
750885 }),
751886 ),
apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts+4-5
......@@ -53,11 +53,10 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", (
5353
5454it.effect("adds repository context while retaining Bitbucket API causes", () =>
5555 Effect.gen(function* () {
56 const cause = new BitbucketApi.BitbucketApiError({
56 const upstreamCause = new Error("raw upstream failure");
57 const cause = new BitbucketApi.BitbucketRequestError({
5758 operation: "getRepository",
58 detail: "upstream detail that should remain in the cause",
59 status: 503,
60 cause: new Error("raw upstream failure"),
59 cause: upstreamCause,
6160 });
6261 const provider = yield* makeProvider({
6362 getRepositoryCloneUrls: () => Effect.fail(cause),
......@@ -86,7 +85,7 @@ it.effect("adds repository context while retaining Bitbucket API causes", () =>
8685 },
8786 );
8887 assert.strictEqual(error.cause, cause);
89 assert.equal(error.message.includes(cause.detail), false);
88 assert.equal(error.message.includes(upstreamCause.message), false);
9089 }),
9190);
9291