| ... | @@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 7 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 8 | import { | 8 | import { |
| | 9 | NonNegativeInt, |
| 9 | TrimmedNonEmptyString, | 10 | TrimmedNonEmptyString, |
| 10 | type SourceControlProviderAuth, | 11 | type SourceControlProviderAuth, |
| 11 | type SourceControlRepositoryCloneUrls, | 12 | type SourceControlRepositoryCloneUrls, |
| ... | @@ -36,20 +37,156 @@ const BitbucketApiEnvConfig = Config.all({ | ... | @@ -36,20 +37,156 @@ const BitbucketApiEnvConfig = Config.all({ |
| 36 | apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), | 37 | apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), |
| 37 | }); | 38 | }); |
| 38 | | 39 | |
| 39 | export class BitbucketApiError extends Schema.TaggedErrorClass<BitbucketApiError>()( | 40 | const BitbucketApiOperation = Schema.Literals([ |
| 40 | "BitbucketApiError", | 41 | "resolveRepository", |
| | 42 | "getRepository", |
| | 43 | "getBranchingModel", |
| | 44 | "getPullRequest", |
| | 45 | "listPullRequests", |
| | 46 | "createRepository", |
| | 47 | "createPullRequest", |
| | 48 | "probeAuth", |
| | 49 | "checkoutPullRequest", |
| | 50 | ]); |
| | 51 | type BitbucketApiOperation = typeof BitbucketApiOperation.Type; |
| | 52 | |
| | 53 | export class BitbucketRepositoryLocatorError extends Schema.TaggedErrorClass<BitbucketRepositoryLocatorError>()( |
| | 54 | "BitbucketRepositoryLocatorError", |
| 41 | { | 55 | { |
| 42 | operation: Schema.String, | 56 | repository: Schema.String, |
| 43 | detail: Schema.String, | | |
| 44 | status: Schema.optional(Schema.Number), | | |
| 45 | cause: Schema.optional(Schema.Defect()), | | |
| 46 | }, | 57 | }, |
| 47 | ) { | 58 | ) { |
| 48 | override get message(): string { | 59 | 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."; |
| 50 | } | 61 | } |
| 51 | } | 62 | } |
| 52 | const isBitbucketApiError = Schema.is(BitbucketApiError); | 63 | |
| | 64 | export 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 | |
| | 76 | export 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 | |
| | 89 | export 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 | |
| | 102 | export 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 | |
| | 115 | export 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 | |
| | 127 | export 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 | |
| | 139 | export 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 | |
| | 150 | export 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 | |
| | 163 | export 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 | |
| | 176 | export 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 | ]); |
| | 188 | export type BitbucketApiError = typeof BitbucketApiError.Type; |
| | 189 | export const isBitbucketApiError = Schema.is(BitbucketApiError); |
| 53 | | 190 | |
| 54 | const RawBitbucketRepositorySchema = Schema.Struct({ | 191 | const RawBitbucketRepositorySchema = Schema.Struct({ |
| 55 | full_name: TrimmedNonEmptyString, | 192 | full_name: TrimmedNonEmptyString, |
| ... | @@ -209,16 +346,14 @@ function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | ... | @@ -209,16 +346,14 @@ function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator |
| 209 | } | 346 | } |
| 210 | | 347 | |
| 211 | function requireRepositoryLocator( | 348 | function requireRepositoryLocator( |
| 212 | operation: string, | | |
| 213 | repository: string, | 349 | repository: string, |
| 214 | ): Effect.Effect<BitbucketRepositoryLocator, BitbucketApiError> { | 350 | ): Effect.Effect<BitbucketRepositoryLocator, BitbucketApiError> { |
| 215 | const locator = parseBitbucketRepositorySlug(repository); | 351 | const locator = parseBitbucketRepositorySlug(repository); |
| 216 | return locator | 352 | return locator |
| 217 | ? Effect.succeed(locator) | 353 | ? Effect.succeed(locator) |
| 218 | : Effect.fail( | 354 | : Effect.fail( |
| 219 | new BitbucketApiError({ | 355 | new BitbucketRepositoryLocatorError({ |
| 220 | operation, | 356 | repository, |
| 221 | detail: "Bitbucket repositories must be specified as workspace/repository.", | | |
| 222 | }), | 357 | }), |
| 223 | ); | 358 | ); |
| 224 | } | 359 | } |
| ... | @@ -339,20 +474,24 @@ function authFromConfig( | ... | @@ -339,20 +474,24 @@ function authFromConfig( |
| 339 | } | 474 | } |
| 340 | | 475 | |
| 341 | function responseError( | 476 | function responseError( |
| 342 | operation: string, | 477 | operation: BitbucketApiOperation, |
| 343 | response: HttpClientResponse.HttpClientResponse, | 478 | response: HttpClientResponse.HttpClientResponse, |
| 344 | ): Effect.Effect<never, BitbucketApiError> { | 479 | ): Effect.Effect<never, BitbucketApiError> { |
| 345 | return response.text.pipe( | 480 | 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 | ), |
| 347 | Effect.flatMap((body) => | 489 | Effect.flatMap((body) => |
| 348 | Effect.fail( | 490 | Effect.fail( |
| 349 | new BitbucketApiError({ | 491 | new BitbucketResponseError({ |
| 350 | operation, | 492 | operation, |
| 351 | status: response.status, | 493 | status: response.status, |
| 352 | detail: | 494 | responseBodyLength: body.length, |
| 353 | body.trim().length > 0 | | |
| 354 | ? `Bitbucket returned HTTP ${response.status}: ${body.trim()}` | | |
| 355 | : `Bitbucket returned HTTP ${response.status}.`, | | |
| 356 | }), | 495 | }), |
| 357 | ), | 496 | ), |
| 358 | ), | 497 | ), |
| ... | @@ -379,7 +518,7 @@ export const make = Effect.gen(function* () { | ... | @@ -379,7 +518,7 @@ export const make = Effect.gen(function* () { |
| 379 | }; | 518 | }; |
| 380 | | 519 | |
| 381 | const decodeResponse = <S extends Schema.Top>( | 520 | const decodeResponse = <S extends Schema.Top>( |
| 382 | operation: string, | 521 | operation: BitbucketApiOperation, |
| 383 | schema: S, | 522 | schema: S, |
| 384 | response: HttpClientResponse.HttpClientResponse, | 523 | response: HttpClientResponse.HttpClientResponse, |
| 385 | ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> => | 524 | ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> => |
| ... | @@ -388,9 +527,9 @@ export const make = Effect.gen(function* () { | ... | @@ -388,9 +527,9 @@ export const make = Effect.gen(function* () { |
| 388 | HttpClientResponse.schemaBodyJson(schema)(success).pipe( | 527 | HttpClientResponse.schemaBodyJson(schema)(success).pipe( |
| 389 | Effect.mapError( | 528 | Effect.mapError( |
| 390 | (cause) => | 529 | (cause) => |
| 391 | new BitbucketApiError({ | 530 | new BitbucketResponseDecodeError({ |
| 392 | operation, | 531 | operation, |
| 393 | detail: "Bitbucket returned invalid JSON for the requested resource.", | 532 | status: success.status, |
| 394 | cause, | 533 | cause, |
| 395 | }), | 534 | }), |
| 396 | ), | 535 | ), |
| ... | @@ -399,16 +538,15 @@ export const make = Effect.gen(function* () { | ... | @@ -399,16 +538,15 @@ export const make = Effect.gen(function* () { |
| 399 | })(response); | 538 | })(response); |
| 400 | | 539 | |
| 401 | const executeJson = <S extends Schema.Top>( | 540 | const executeJson = <S extends Schema.Top>( |
| 402 | operation: string, | 541 | operation: BitbucketApiOperation, |
| 403 | request: HttpClientRequest.HttpClientRequest, | 542 | request: HttpClientRequest.HttpClientRequest, |
| 404 | schema: S, | 543 | schema: S, |
| 405 | ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> => | 544 | ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> => |
| 406 | httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( | 545 | httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( |
| 407 | Effect.mapError( | 546 | Effect.mapError( |
| 408 | (cause) => | 547 | (cause) => |
| 409 | new BitbucketApiError({ | 548 | new BitbucketRequestError({ |
| 410 | operation, | 549 | operation, |
| 411 | detail: "Failed to send the Bitbucket request.", | | |
| 412 | cause, | 550 | cause, |
| 413 | }), | 551 | }), |
| 414 | ), | 552 | ), |
| ... | @@ -433,9 +571,8 @@ export const make = Effect.gen(function* () { | ... | @@ -433,9 +571,8 @@ export const make = Effect.gen(function* () { |
| 433 | const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe( | 571 | const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe( |
| 434 | Effect.mapError( | 572 | Effect.mapError( |
| 435 | (cause) => | 573 | (cause) => |
| 436 | new BitbucketApiError({ | 574 | new BitbucketRepositoryVcsResolveError({ |
| 437 | operation: "resolveRepository", | 575 | cwd: input.cwd, |
| 438 | detail: `Failed to resolve VCS repository for ${input.cwd}.`, | | |
| 439 | cause, | 576 | cause, |
| 440 | }), | 577 | }), |
| 441 | ), | 578 | ), |
| ... | @@ -443,9 +580,8 @@ export const make = Effect.gen(function* () { | ... | @@ -443,9 +580,8 @@ export const make = Effect.gen(function* () { |
| 443 | const remotes = yield* handle.driver.listRemotes(input.cwd).pipe( | 580 | const remotes = yield* handle.driver.listRemotes(input.cwd).pipe( |
| 444 | Effect.mapError( | 581 | Effect.mapError( |
| 445 | (cause) => | 582 | (cause) => |
| 446 | new BitbucketApiError({ | 583 | new BitbucketRepositoryRemotesListError({ |
| 447 | operation: "resolveRepository", | 584 | cwd: input.cwd, |
| 448 | detail: `Failed to list remotes for ${input.cwd}.`, | | |
| 449 | cause, | 585 | cause, |
| 450 | }), | 586 | }), |
| 451 | ), | 587 | ), |
| ... | @@ -457,9 +593,8 @@ export const make = Effect.gen(function* () { | ... | @@ -457,9 +593,8 @@ export const make = Effect.gen(function* () { |
| 457 | if (parsed) return parsed; | 593 | if (parsed) return parsed; |
| 458 | } | 594 | } |
| 459 | | 595 | |
| 460 | return yield* new BitbucketApiError({ | 596 | return yield* new BitbucketRepositoryRemoteNotFoundError({ |
| 461 | operation: "resolveRepository", | 597 | cwd: input.cwd, |
| 462 | detail: `No Bitbucket repository remote was detected for ${input.cwd}.`, | | |
| 463 | }); | 598 | }); |
| 464 | }); | 599 | }); |
| 465 | | 600 | |
| ... | @@ -600,7 +735,7 @@ export const make = Effect.gen(function* () { | ... | @@ -600,7 +735,7 @@ export const make = Effect.gen(function* () { |
| 600 | getRepositoryCloneUrls: (input) => | 735 | getRepositoryCloneUrls: (input) => |
| 601 | getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), | 736 | getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), |
| 602 | createRepository: (input) => | 737 | createRepository: (input) => |
| 603 | requireRepositoryLocator("createRepository", input.repository).pipe( | 738 | requireRepositoryLocator(input.repository).pipe( |
| 604 | Effect.flatMap((repository) => | 739 | Effect.flatMap((repository) => |
| 605 | executeJson( | 740 | executeJson( |
| 606 | "createRepository", | 741 | "createRepository", |
| ... | @@ -625,9 +760,9 @@ export const make = Effect.gen(function* () { | ... | @@ -625,9 +760,9 @@ export const make = Effect.gen(function* () { |
| 625 | const description = yield* fileSystem.readFileString(input.bodyFile).pipe( | 760 | const description = yield* fileSystem.readFileString(input.bodyFile).pipe( |
| 626 | Effect.mapError( | 761 | Effect.mapError( |
| 627 | (cause) => | 762 | (cause) => |
| 628 | new BitbucketApiError({ | 763 | new BitbucketPullRequestBodyReadError({ |
| 629 | operation: "createPullRequest", | 764 | cwd: input.cwd, |
| 630 | detail: `Failed to read pull request body file ${input.bodyFile}.`, | 765 | bodyFile: input.bodyFile, |
| 631 | cause, | 766 | cause, |
| 632 | }), | 767 | }), |
| 633 | ), | 768 | ), |
| ... | @@ -743,9 +878,9 @@ export const make = Effect.gen(function* () { | ... | @@ -743,9 +878,9 @@ export const make = Effect.gen(function* () { |
| 743 | Effect.mapError((cause) => | 878 | Effect.mapError((cause) => |
| 744 | isBitbucketApiError(cause) | 879 | isBitbucketApiError(cause) |
| 745 | ? cause | 880 | ? cause |
| 746 | : new BitbucketApiError({ | 881 | : new BitbucketCheckoutError({ |
| 747 | operation: "checkoutPullRequest", | 882 | cwd: input.cwd, |
| 748 | detail: "Failed to check out the Bitbucket pull request.", | 883 | reference: input.reference, |
| 749 | cause, | 884 | cause, |
| 750 | }), | 885 | }), |
| 751 | ), | 886 | ), |