| author | |
| committer | |
| log | 61e6d89d69240120dd08fae72459e5fd7e7a2908 |
| tree | f6275b6b7752c554f09abeff229b2373c64d4c67 |
| parent | 1b2f39d018410a9e09c67e2629f6ca1562860e6b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>5 files changed, 212 insertions(+), 158 deletions(-)
apps/server/src/git/GitManager.test.ts+17-39| ... | @@ -171,20 +171,8 @@ function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { | ... | @@ -171,20 +171,8 @@ function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { |
| 171 | if (result.status === 0) { | 171 | if (result.status === 0) { |
| 172 | return; | 172 | return; |
| 173 | } | 173 | } |
| 174 | throw new GitHubCli.GitHubCliError({ | 174 | throw new Error( |
| 175 | operation: "execute", | 175 | `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, |
| 176 | command: "gh", | ||
| 177 | cwd, | ||
| 178 | detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, | ||
| 179 | }); | ||
| 180 | } | ||
| 181 | |||
| 182 | function isGitHubCliError(error: unknown): error is GitHubCli.GitHubCliError { | ||
| 183 | return ( | ||
| 184 | typeof error === "object" && | ||
| 185 | error !== null && | ||
| 186 | "_tag" in error && | ||
| 187 | (error as { _tag?: unknown })._tag === "GitHubCliError" | ||
| 188 | ); | 176 | ); |
| 189 | } | 177 | } |
| 190 | 178 | ||
| ... | @@ -478,16 +466,12 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ... | @@ -478,16 +466,12 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 478 | return fakeGhOutput(""); | 466 | return fakeGhOutput(""); |
| 479 | }, | 467 | }, |
| 480 | catch: (error) => | 468 | catch: (error) => |
| 481 | isGitHubCliError(error) | 469 | GitHubCli.isGitHubCliError(error) |
| 482 | ? error | 470 | ? error |
| 483 | : new GitHubCli.GitHubCliError({ | 471 | : new GitHubCli.GitHubCliCommandError({ |
| 484 | operation: "execute", | ||
| 485 | command: "gh", | 472 | command: "gh", |
| 486 | cwd: input.cwd, | 473 | cwd: input.cwd, |
| 487 | detail: | 474 | cause: error, |
| 488 | error instanceof Error | ||
| 489 | ? `Failed to simulate gh checkout: ${error.message}` | ||
| 490 | : "Failed to simulate gh checkout.", | ||
| 491 | }), | 475 | }), |
| 492 | }); | 476 | }); |
| 493 | } | 477 | } |
| ... | @@ -498,11 +482,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ... | @@ -498,11 +482,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 498 | const cloneUrls = scenario.repositoryCloneUrls?.[repository]; | 482 | const cloneUrls = scenario.repositoryCloneUrls?.[repository]; |
| 499 | if (!cloneUrls) { | 483 | if (!cloneUrls) { |
| 500 | return Effect.fail( | 484 | return Effect.fail( |
| 501 | new GitHubCli.GitHubCliError({ | 485 | new GitHubCli.GitHubCliCommandError({ |
| 502 | operation: "execute", | ||
| 503 | command: "gh", | 486 | command: "gh", |
| 504 | cwd: input.cwd, | 487 | cwd: input.cwd, |
| 505 | detail: `Unexpected repository lookup: ${repository}`, | 488 | cause: new Error(`Unexpected repository lookup: ${repository}`), |
| 506 | }), | 489 | }), |
| 507 | ); | 490 | ); |
| 508 | } | 491 | } |
| ... | @@ -520,11 +503,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ... | @@ -520,11 +503,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 520 | } | 503 | } |
| 521 | 504 | ||
| 522 | return Effect.fail( | 505 | return Effect.fail( |
| 523 | new GitHubCli.GitHubCliError({ | 506 | new GitHubCli.GitHubCliCommandError({ |
| 524 | operation: "execute", | ||
| 525 | command: "gh", | 507 | command: "gh", |
| 526 | cwd: input.cwd, | 508 | cwd: input.cwd, |
| 527 | detail: `Unexpected gh command: ${args.join(" ")}`, | 509 | cause: new Error(`Unexpected gh command: ${args.join(" ")}`), |
| 528 | }), | 510 | }), |
| 529 | ); | 511 | ); |
| 530 | }; | 512 | }; |
| ... | @@ -601,11 +583,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ... | @@ -601,11 +583,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 601 | }).pipe(Effect.map((result) => JSON.parse(result.stdout))), | 583 | }).pipe(Effect.map((result) => JSON.parse(result.stdout))), |
| 602 | createRepository: (input) => | 584 | createRepository: (input) => |
| 603 | Effect.fail( | 585 | Effect.fail( |
| 604 | new GitHubCli.GitHubCliError({ | 586 | new GitHubCli.GitHubCliCommandError({ |
| 605 | operation: "createRepository", | ||
| 606 | command: "gh", | 587 | command: "gh", |
| 607 | cwd: input.cwd, | 588 | cwd: input.cwd, |
| 608 | detail: `Unexpected repository create: ${input.repository}`, | 589 | cause: new Error(`Unexpected repository create: ${input.repository}`), |
| 609 | }), | 590 | }), |
| 610 | ), | 591 | ), |
| 611 | checkoutPullRequest: (input) => | 592 | checkoutPullRequest: (input) => |
| ... | @@ -1341,11 +1322,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -1341,11 +1322,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1341 | 1322 | ||
| 1342 | const { manager } = yield* makeManager({ | 1323 | const { manager } = yield* makeManager({ |
| 1343 | ghScenario: { | 1324 | ghScenario: { |
| 1344 | failWith: new GitHubCli.GitHubCliError({ | 1325 | failWith: new GitHubCli.GitHubCliUnavailableError({ |
| 1345 | operation: "execute", | ||
| 1346 | command: "gh", | 1326 | command: "gh", |
| 1347 | cwd: repoDir, | 1327 | cwd: repoDir, |
| 1348 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | 1328 | cause: new Error("gh is not available on PATH"), |
| 1349 | }), | 1329 | }), |
| 1350 | }, | 1330 | }, |
| 1351 | }); | 1331 | }); |
| ... | @@ -2483,11 +2463,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -2483,11 +2463,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 2483 | 2463 | ||
| 2484 | const { manager } = yield* makeManager({ | 2464 | const { manager } = yield* makeManager({ |
| 2485 | ghScenario: { | 2465 | ghScenario: { |
| 2486 | failWith: new GitHubCli.GitHubCliError({ | 2466 | failWith: new GitHubCli.GitHubCliUnavailableError({ |
| 2487 | operation: "execute", | ||
| 2488 | command: "gh", | 2467 | command: "gh", |
| 2489 | cwd: repoDir, | 2468 | cwd: repoDir, |
| 2490 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | 2469 | cause: new Error("gh is not available on PATH"), |
| 2491 | }), | 2470 | }), |
| 2492 | }, | 2471 | }, |
| 2493 | }); | 2472 | }); |
| ... | @@ -2514,11 +2493,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -2514,11 +2493,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 2514 | 2493 | ||
| 2515 | const { manager } = yield* makeManager({ | 2494 | const { manager } = yield* makeManager({ |
| 2516 | ghScenario: { | 2495 | ghScenario: { |
| 2517 | failWith: new GitHubCli.GitHubCliError({ | 2496 | failWith: new GitHubCli.GitHubCliAuthenticationError({ |
| 2518 | operation: "execute", | ||
| 2519 | command: "gh", | 2497 | command: "gh", |
| 2520 | cwd: repoDir, | 2498 | cwd: repoDir, |
| 2521 | detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", | 2499 | cause: new Error("gh is not authenticated"), |
| 2522 | }), | 2500 | }), |
| 2523 | }, | 2501 | }, |
| 2524 | }); | 2502 | }); |
apps/server/src/sourceControl/GitHubCli.test.ts+25-1| ... | @@ -1,8 +1,9 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; | 1 | import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as PlatformError from "effect/PlatformError"; | ||
| 4 | import { ChildProcessSpawner } from "effect/unstable/process"; | 5 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 5 | import { VcsProcessExitError } from "@t3tools/contracts"; | 6 | import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; |
| 6 | 7 | ||
| 7 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 8 | import * as GitHubCli from "./GitHubCli.ts"; | 9 | import * as GitHubCli from "./GitHubCli.ts"; |
| ... | @@ -30,6 +31,27 @@ afterEach(() => { | ... | @@ -30,6 +31,27 @@ afterEach(() => { |
| 30 | }); | 31 | }); |
| 31 | 32 | ||
| 32 | describe("GitHubCli.layer", () => { | 33 | describe("GitHubCli.layer", () => { |
| 34 | it("does not classify a missing cwd as an unavailable gh executable", () => { | ||
| 35 | const context = { command: "gh", cwd: "/repo" } as const; | ||
| 36 | const missingCwd = new VcsProcessSpawnError({ | ||
| 37 | operation: "GitHubCli.execute", | ||
| 38 | command: "gh", | ||
| 39 | cwd: context.cwd, | ||
| 40 | cause: PlatformError.systemError({ | ||
| 41 | _tag: "NotFound", | ||
| 42 | module: "FileSystem", | ||
| 43 | method: "access", | ||
| 44 | pathOrDescriptor: context.cwd, | ||
| 45 | }), | ||
| 46 | }); | ||
| 47 | |||
| 48 | const commandFailure = GitHubCli.fromVcsError(context, missingCwd); | ||
| 49 | |||
| 50 | assert.equal(commandFailure._tag, "GitHubCliCommandError"); | ||
| 51 | assert.strictEqual(commandFailure.cause, missingCwd); | ||
| 52 | assert.notProperty(commandFailure, "operation"); | ||
| 53 | }); | ||
| 54 | |||
| 33 | it.effect("parses pull request view output", () => | 55 | it.effect("parses pull request view output", () => |
| 34 | Effect.gen(function* () { | 56 | Effect.gen(function* () { |
| 35 | mockRun.mockReturnValueOnce( | 57 | mockRun.mockReturnValueOnce( |
| ... | @@ -274,6 +296,7 @@ describe("GitHubCli.layer", () => { | ... | @@ -274,6 +296,7 @@ describe("GitHubCli.layer", () => { |
| 274 | command: "gh pr view", | 296 | command: "gh pr view", |
| 275 | cwd: "/repo", | 297 | cwd: "/repo", |
| 276 | exitCode: 1, | 298 | exitCode: 1, |
| 299 | failureKind: "not-found", | ||
| 277 | detail: | 300 | detail: |
| 278 | "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", | 301 | "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", |
| 279 | }); | 302 | }); |
| ... | @@ -288,6 +311,7 @@ describe("GitHubCli.layer", () => { | ... | @@ -288,6 +311,7 @@ describe("GitHubCli.layer", () => { |
| 288 | .pipe(Effect.flip); | 311 | .pipe(Effect.flip); |
| 289 | 312 | ||
| 290 | assert.equal(error.message.includes("Pull request not found"), true); | 313 | assert.equal(error.message.includes("Pull request not found"), true); |
| 314 | assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); | ||
| 291 | assert.strictEqual(error.command, "gh"); | 315 | assert.strictEqual(error.command, "gh"); |
| 292 | assert.strictEqual(error.cwd, "/repo"); | 316 | assert.strictEqual(error.cwd, "/repo"); |
| 293 | assert.strictEqual(error.cause, cause); | 317 | assert.strictEqual(error.cause, cause); |
apps/server/src/sourceControl/GitHubCli.ts+165-105| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import * as Context from "effect/Context"; | 1 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as PlatformError from "effect/PlatformError"; | ||
| 4 | import * as Result from "effect/Result"; | 5 | import * as Result from "effect/Result"; |
| 5 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 6 | 7 | ||
| ... | @@ -18,69 +19,165 @@ import { | ... | @@ -18,69 +19,165 @@ import { |
| 18 | 19 | ||
| 19 | const DEFAULT_TIMEOUT_MS = 30_000; | 20 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 20 | 21 | ||
| 21 | export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", { | 22 | const gitHubCliFailureFields = { |
| 22 | operation: Schema.String, | 23 | command: Schema.Literal("gh"), |
| 23 | command: Schema.String, | ||
| 24 | cwd: Schema.String, | 24 | cwd: Schema.String, |
| 25 | detail: Schema.String, | 25 | cause: Schema.Defect(), |
| 26 | cause: Schema.optional(Schema.Defect()), | 26 | } as const; |
| 27 | }) { | 27 | |
| 28 | export class GitHubCliUnavailableError extends Schema.TaggedErrorClass<GitHubCliUnavailableError>()( | ||
| 29 | "GitHubCliUnavailableError", | ||
| 30 | gitHubCliFailureFields, | ||
| 31 | ) { | ||
| 32 | get detail(): string { | ||
| 33 | return "GitHub CLI (`gh`) is required but not available on PATH."; | ||
| 34 | } | ||
| 35 | |||
| 28 | override get message(): string { | 36 | override get message(): string { |
| 29 | return `GitHub CLI failed in ${this.operation}: ${this.detail}`; | 37 | return `GitHub CLI failed in execute: ${this.detail}`; |
| 30 | } | 38 | } |
| 39 | } | ||
| 31 | 40 | ||
| 32 | static fromVcsError( | 41 | export class GitHubCliAuthenticationError extends Schema.TaggedErrorClass<GitHubCliAuthenticationError>()( |
| 33 | context: { | 42 | "GitHubCliAuthenticationError", |
| 34 | readonly operation: "execute"; | 43 | gitHubCliFailureFields, |
| 35 | readonly command: "gh"; | 44 | ) { |
| 36 | readonly cwd: string; | 45 | get detail(): string { |
| 37 | }, | 46 | return "GitHub CLI is not authenticated. Run `gh auth login` and retry."; |
| 38 | error: VcsError | unknown, | 47 | } |
| 39 | ): GitHubCliError { | ||
| 40 | const lower = errorText(error).toLowerCase(); | ||
| 41 | |||
| 42 | if (lower.includes("command not found: gh") || lower.includes("enoent")) { | ||
| 43 | return new GitHubCliError({ | ||
| 44 | ...context, | ||
| 45 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | ||
| 46 | cause: error, | ||
| 47 | }); | ||
| 48 | } | ||
| 49 | 48 | ||
| 50 | if ( | 49 | override get message(): string { |
| 51 | lower.includes("authentication failed") || | 50 | return `GitHub CLI failed in execute: ${this.detail}`; |
| 52 | lower.includes("not logged in") || | 51 | } |
| 53 | lower.includes("gh auth login") || | 52 | } |
| 54 | lower.includes("no oauth token") | ||
| 55 | ) { | ||
| 56 | return new GitHubCliError({ | ||
| 57 | ...context, | ||
| 58 | detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", | ||
| 59 | cause: error, | ||
| 60 | }); | ||
| 61 | } | ||
| 62 | 53 | ||
| 63 | if ( | 54 | export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass<GitHubPullRequestNotFoundError>()( |
| 64 | lower.includes("could not resolve to a pullrequest") || | 55 | "GitHubPullRequestNotFoundError", |
| 65 | lower.includes("repository.pullrequest") || | 56 | gitHubCliFailureFields, |
| 66 | lower.includes("no pull requests found for branch") || | 57 | ) { |
| 67 | lower.includes("pull request not found") | 58 | get detail(): string { |
| 68 | ) { | 59 | return "Pull request not found. Check the PR number or URL and try again."; |
| 69 | return new GitHubCliError({ | 60 | } |
| 70 | ...context, | 61 | |
| 71 | detail: "Pull request not found. Check the PR number or URL and try again.", | 62 | override get message(): string { |
| 72 | cause: error, | 63 | return `GitHub CLI failed in execute: ${this.detail}`; |
| 73 | }); | 64 | } |
| 74 | } | 65 | } |
| 66 | |||
| 67 | export class GitHubCliCommandError extends Schema.TaggedErrorClass<GitHubCliCommandError>()( | ||
| 68 | "GitHubCliCommandError", | ||
| 69 | gitHubCliFailureFields, | ||
| 70 | ) { | ||
| 71 | get detail(): string { | ||
| 72 | return "GitHub CLI command failed."; | ||
| 73 | } | ||
| 74 | |||
| 75 | override get message(): string { | ||
| 76 | return `GitHub CLI failed in execute: ${this.detail}`; | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | const gitHubCliDecodeFields = { | ||
| 81 | command: Schema.Literal("gh"), | ||
| 82 | cwd: Schema.String, | ||
| 83 | cause: Schema.Defect(), | ||
| 84 | } as const; | ||
| 85 | |||
| 86 | export class GitHubPullRequestListDecodeError extends Schema.TaggedErrorClass<GitHubPullRequestListDecodeError>()( | ||
| 87 | "GitHubPullRequestListDecodeError", | ||
| 88 | gitHubCliDecodeFields, | ||
| 89 | ) { | ||
| 90 | get detail(): string { | ||
| 91 | return "GitHub CLI returned invalid PR list JSON."; | ||
| 92 | } | ||
| 93 | |||
| 94 | override get message(): string { | ||
| 95 | return `GitHub CLI failed in listOpenPullRequests: ${this.detail}`; | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | export class GitHubChangeRequestListDecodeError extends Schema.TaggedErrorClass<GitHubChangeRequestListDecodeError>()( | ||
| 100 | "GitHubChangeRequestListDecodeError", | ||
| 101 | gitHubCliDecodeFields, | ||
| 102 | ) { | ||
| 103 | get detail(): string { | ||
| 104 | return "GitHub CLI returned invalid change request JSON."; | ||
| 105 | } | ||
| 75 | 106 | ||
| 76 | return new GitHubCliError({ | 107 | override get message(): string { |
| 77 | ...context, | 108 | return `GitHub CLI failed in listChangeRequests: ${this.detail}`; |
| 78 | detail: "GitHub CLI command failed.", | ||
| 79 | cause: error, | ||
| 80 | }); | ||
| 81 | } | 109 | } |
| 82 | } | 110 | } |
| 83 | 111 | ||
| 112 | export class GitHubPullRequestDecodeError extends Schema.TaggedErrorClass<GitHubPullRequestDecodeError>()( | ||
| 113 | "GitHubPullRequestDecodeError", | ||
| 114 | gitHubCliDecodeFields, | ||
| 115 | ) { | ||
| 116 | get detail(): string { | ||
| 117 | return "GitHub CLI returned invalid pull request JSON."; | ||
| 118 | } | ||
| 119 | |||
| 120 | override get message(): string { | ||
| 121 | return `GitHub CLI failed in getPullRequest: ${this.detail}`; | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass<GitHubRepositoryDecodeError>()( | ||
| 126 | "GitHubRepositoryDecodeError", | ||
| 127 | gitHubCliDecodeFields, | ||
| 128 | ) { | ||
| 129 | get detail(): string { | ||
| 130 | return "GitHub CLI returned invalid repository JSON."; | ||
| 131 | } | ||
| 132 | |||
| 133 | override get message(): string { | ||
| 134 | return `GitHub CLI failed in getRepositoryCloneUrls: ${this.detail}`; | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | export const GitHubCliError = Schema.Union([ | ||
| 139 | GitHubCliUnavailableError, | ||
| 140 | GitHubCliAuthenticationError, | ||
| 141 | GitHubPullRequestNotFoundError, | ||
| 142 | GitHubCliCommandError, | ||
| 143 | GitHubPullRequestListDecodeError, | ||
| 144 | GitHubChangeRequestListDecodeError, | ||
| 145 | GitHubPullRequestDecodeError, | ||
| 146 | GitHubRepositoryDecodeError, | ||
| 147 | ]); | ||
| 148 | export type GitHubCliError = typeof GitHubCliError.Type; | ||
| 149 | |||
| 150 | export const isGitHubCliError = Schema.is(GitHubCliError); | ||
| 151 | |||
| 152 | export function fromVcsError( | ||
| 153 | context: { | ||
| 154 | readonly command: "gh"; | ||
| 155 | readonly cwd: string; | ||
| 156 | }, | ||
| 157 | error: VcsError, | ||
| 158 | ): GitHubCliError { | ||
| 159 | if ( | ||
| 160 | error._tag === "VcsProcessSpawnError" && | ||
| 161 | error.cause instanceof PlatformError.PlatformError && | ||
| 162 | error.cause.reason._tag === "NotFound" && | ||
| 163 | error.cause.reason.module === "ChildProcess" && | ||
| 164 | error.cause.reason.method === "spawn" | ||
| 165 | ) { | ||
| 166 | return new GitHubCliUnavailableError({ ...context, cause: error }); | ||
| 167 | } | ||
| 168 | |||
| 169 | if (error._tag === "VcsProcessExitError") { | ||
| 170 | if (error.failureKind === "authentication") { | ||
| 171 | return new GitHubCliAuthenticationError({ ...context, cause: error }); | ||
| 172 | } | ||
| 173 | if (error.failureKind === "not-found") { | ||
| 174 | return new GitHubPullRequestNotFoundError({ ...context, cause: error }); | ||
| 175 | } | ||
| 176 | } | ||
| 177 | |||
| 178 | return new GitHubCliCommandError({ ...context, cause: error }); | ||
| 179 | } | ||
| 180 | |||
| 84 | export interface GitHubPullRequestSummary { | 181 | export interface GitHubPullRequestSummary { |
| 85 | readonly number: number; | 182 | readonly number: number; |
| 86 | readonly title: string; | 183 | readonly title: string; |
| ... | @@ -150,22 +247,14 @@ export class GitHubCli extends Context.Service< | ... | @@ -150,22 +247,14 @@ export class GitHubCli extends Context.Service< |
| 150 | } | 247 | } |
| 151 | >()("t3/sourceControl/GitHubCli") {} | 248 | >()("t3/sourceControl/GitHubCli") {} |
| 152 | 249 | ||
| 153 | function errorText(error: VcsError | unknown): string { | ||
| 154 | if (typeof error === "object" && error !== null) { | ||
| 155 | const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; | ||
| 156 | const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; | ||
| 157 | const message = "message" in error && typeof error.message === "string" ? error.message : ""; | ||
| 158 | return [tag, detail, message].filter(Boolean).join("\n"); | ||
| 159 | } | ||
| 160 | |||
| 161 | return String(error); | ||
| 162 | } | ||
| 163 | |||
| 164 | const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ | 250 | const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ |
| 165 | nameWithOwner: TrimmedNonEmptyString, | 251 | nameWithOwner: TrimmedNonEmptyString, |
| 166 | url: TrimmedNonEmptyString, | 252 | url: TrimmedNonEmptyString, |
| 167 | sshUrl: TrimmedNonEmptyString, | 253 | sshUrl: TrimmedNonEmptyString, |
| 168 | }); | 254 | }); |
| 255 | const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( | ||
| 256 | Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), | ||
| 257 | ); | ||
| 169 | 258 | ||
| 170 | function normalizeRepositoryCloneUrls( | 259 | function normalizeRepositoryCloneUrls( |
| 171 | raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>, | 260 | raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>, |
| ... | @@ -214,27 +303,6 @@ function deriveRepositoryCloneUrlsFromCreateOutput( | ... | @@ -214,27 +303,6 @@ function deriveRepositoryCloneUrlsFromCreateOutput( |
| 214 | }; | 303 | }; |
| 215 | } | 304 | } |
| 216 | 305 | ||
| 217 | function decodeGitHubJson<S extends Schema.Top>( | ||
| 218 | raw: string, | ||
| 219 | schema: S, | ||
| 220 | operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", | ||
| 221 | invalidDetail: string, | ||
| 222 | cwd: string, | ||
| 223 | ): Effect.Effect<S["Type"], GitHubCliError, S["DecodingServices"]> { | ||
| 224 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( | ||
| 225 | Effect.mapError( | ||
| 226 | (error) => | ||
| 227 | new GitHubCliError({ | ||
| 228 | operation, | ||
| 229 | command: "gh", | ||
| 230 | cwd, | ||
| 231 | detail: invalidDetail, | ||
| 232 | cause: error, | ||
| 233 | }), | ||
| 234 | ), | ||
| 235 | ); | ||
| 236 | } | ||
| 237 | |||
| 238 | export const make = Effect.gen(function* () { | 306 | export const make = Effect.gen(function* () { |
| 239 | const process = yield* VcsProcess.VcsProcess; | 307 | const process = yield* VcsProcess.VcsProcess; |
| 240 | 308 | ||
| ... | @@ -247,14 +315,7 @@ export const make = Effect.gen(function* () { | ... | @@ -247,14 +315,7 @@ export const make = Effect.gen(function* () { |
| 247 | cwd: input.cwd, | 315 | cwd: input.cwd, |
| 248 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | 316 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, |
| 249 | }) | 317 | }) |
| 250 | .pipe( | 318 | .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); |
| 251 | Effect.mapError((error) => | ||
| 252 | GitHubCliError.fromVcsError( | ||
| 253 | { operation: "execute", command: "gh", cwd: input.cwd }, | ||
| 254 | error, | ||
| 255 | ), | ||
| 256 | ), | ||
| 257 | ); | ||
| 258 | 319 | ||
| 259 | return GitHubCli.of({ | 320 | return GitHubCli.of({ |
| 260 | execute, | 321 | execute, |
| ... | @@ -282,11 +343,9 @@ export const make = Effect.gen(function* () { | ... | @@ -282,11 +343,9 @@ export const make = Effect.gen(function* () { |
| 282 | Effect.flatMap((decoded) => { | 343 | Effect.flatMap((decoded) => { |
| 283 | if (!Result.isSuccess(decoded)) { | 344 | if (!Result.isSuccess(decoded)) { |
| 284 | return Effect.fail( | 345 | return Effect.fail( |
| 285 | new GitHubCliError({ | 346 | new GitHubPullRequestListDecodeError({ |
| 286 | operation: "listOpenPullRequests", | ||
| 287 | command: "gh", | 347 | command: "gh", |
| 288 | cwd: input.cwd, | 348 | cwd: input.cwd, |
| 289 | detail: "GitHub CLI returned invalid PR list JSON.", | ||
| 290 | cause: decoded.failure, | 349 | cause: decoded.failure, |
| 291 | }), | 350 | }), |
| 292 | ); | 351 | ); |
| ... | @@ -316,11 +375,9 @@ export const make = Effect.gen(function* () { | ... | @@ -316,11 +375,9 @@ export const make = Effect.gen(function* () { |
| 316 | Effect.flatMap((decoded) => { | 375 | Effect.flatMap((decoded) => { |
| 317 | if (!Result.isSuccess(decoded)) { | 376 | if (!Result.isSuccess(decoded)) { |
| 318 | return Effect.fail( | 377 | return Effect.fail( |
| 319 | new GitHubCliError({ | 378 | new GitHubPullRequestDecodeError({ |
| 320 | operation: "getPullRequest", | ||
| 321 | command: "gh", | 379 | command: "gh", |
| 322 | cwd: input.cwd, | 380 | cwd: input.cwd, |
| 323 | detail: "GitHub CLI returned invalid pull request JSON.", | ||
| 324 | cause: decoded.failure, | 381 | cause: decoded.failure, |
| 325 | }), | 382 | }), |
| 326 | ); | 383 | ); |
| ... | @@ -340,12 +397,15 @@ export const make = Effect.gen(function* () { | ... | @@ -340,12 +397,15 @@ export const make = Effect.gen(function* () { |
| 340 | }).pipe( | 397 | }).pipe( |
| 341 | Effect.map((result) => result.stdout.trim()), | 398 | Effect.map((result) => result.stdout.trim()), |
| 342 | Effect.flatMap((raw) => | 399 | Effect.flatMap((raw) => |
| 343 | decodeGitHubJson( | 400 | decodeRawGitHubRepositoryCloneUrls(raw).pipe( |
| 344 | raw, | 401 | Effect.mapError( |
| 345 | RawGitHubRepositoryCloneUrlsSchema, | 402 | (cause) => |
| 346 | "getRepositoryCloneUrls", | 403 | new GitHubRepositoryDecodeError({ |
| 347 | "GitHub CLI returned invalid repository JSON.", | 404 | command: "gh", |
| 348 | input.cwd, | 405 | cwd: input.cwd, |
| 406 | cause, | ||
| 407 | }), | ||
| 408 | ), | ||
| 349 | ), | 409 | ), |
| 350 | ), | 410 | ), |
| 351 | Effect.map(normalizeRepositoryCloneUrls), | 411 | Effect.map(normalizeRepositoryCloneUrls), |
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+1-3| ... | @@ -70,11 +70,9 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = | ... | @@ -70,11 +70,9 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = |
| 70 | 70 | ||
| 71 | it.effect("adds safe request context while retaining GitHub CLI causes", () => | 71 | it.effect("adds safe request context while retaining GitHub CLI causes", () => |
| 72 | Effect.gen(function* () { | 72 | Effect.gen(function* () { |
| 73 | const cause = new GitHubCli.GitHubCliError({ | 73 | const cause = new GitHubCli.GitHubPullRequestNotFoundError({ |
| 74 | operation: "execute", | ||
| 75 | command: "gh", | 74 | command: "gh", |
| 76 | cwd: "/repo", | 75 | cwd: "/repo", |
| 77 | detail: "Pull request not found. Check the PR number or URL and try again.", | ||
| 78 | cause: new Error("raw upstream detail that should remain in the cause"), | 76 | cause: new Error("raw upstream detail that should remain in the cause"), |
| 79 | }); | 77 | }); |
| 80 | const provider = yield* makeProvider({ | 78 | const provider = yield* makeProvider({ |
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+4-10| ... | @@ -158,23 +158,17 @@ export const make = Effect.gen(function* () { | ... | @@ -158,23 +158,17 @@ export const make = Effect.gen(function* () { |
| 158 | })), | 158 | })), |
| 159 | ) | 159 | ) |
| 160 | : Effect.fail( | 160 | : Effect.fail( |
| 161 | new SourceControlProviderError({ | 161 | new GitHubCli.GitHubChangeRequestListDecodeError({ |
| 162 | provider: "github", | ||
| 163 | operation: "listChangeRequests", | ||
| 164 | command: "gh", | 162 | command: "gh", |
| 165 | cwd: input.cwd, | 163 | cwd: input.cwd, |
| 166 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 167 | input.headSelector, | ||
| 168 | ), | ||
| 169 | detail: "GitHub CLI returned invalid change request JSON.", | ||
| 170 | cause: decoded.failure, | 164 | cause: decoded.failure, |
| 171 | }), | 165 | }), |
| 172 | ), | 166 | ), |
| 173 | ), | 167 | ), |
| 174 | ); | 168 | ); |
| 175 | }), | 169 | }), |
| 176 | Effect.catchTags({ | 170 | Effect.mapError( |
| 177 | GitHubCliError: (error) => | 171 | (error) => |
| 178 | new SourceControlProviderError({ | 172 | new SourceControlProviderError({ |
| 179 | provider: "github", | 173 | provider: "github", |
| 180 | operation: "listChangeRequests", | 174 | operation: "listChangeRequests", |
| ... | @@ -186,7 +180,7 @@ export const make = Effect.gen(function* () { | ... | @@ -186,7 +180,7 @@ export const make = Effect.gen(function* () { |
| 186 | detail: error.detail, | 180 | detail: error.detail, |
| 187 | cause: error, | 181 | cause: error, |
| 188 | }), | 182 | }), |
| 189 | }), | 183 | ), |
| 190 | ); | 184 | ); |
| 191 | }; | 185 | }; |
| 192 | 186 |