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