| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as Context from "effect/Context"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as PlatformError from "effect/PlatformError"; |
| 4 | 5 | import * as Result from "effect/Result"; |
| 5 | 6 | import * as Schema from "effect/Schema"; |
| 6 | 7 | |
| ... | ... | @@ -18,69 +19,165 @@ import { |
| 18 | 19 | |
| 19 | 20 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 20 | 21 | |
| 21 | | export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", { |
| 22 | | operation: Schema.String, |
| 23 | | command: Schema.String, |
| 22 | const gitHubCliFailureFields = { |
| 23 | command: Schema.Literal("gh"), |
| 24 | 24 | cwd: Schema.String, |
| 25 | | detail: Schema.String, |
| 26 | | cause: Schema.optional(Schema.Defect()), |
| 27 | | }) { |
| 25 | cause: Schema.Defect(), |
| 26 | } as const; |
| 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 | 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( |
| 33 | | context: { |
| 34 | | readonly operation: "execute"; |
| 35 | | readonly command: "gh"; |
| 36 | | readonly cwd: string; |
| 37 | | }, |
| 38 | | error: VcsError | unknown, |
| 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 | | } |
| 41 | export class GitHubCliAuthenticationError extends Schema.TaggedErrorClass<GitHubCliAuthenticationError>()( |
| 42 | "GitHubCliAuthenticationError", |
| 43 | gitHubCliFailureFields, |
| 44 | ) { |
| 45 | get detail(): string { |
| 46 | return "GitHub CLI is not authenticated. Run `gh auth login` and retry."; |
| 47 | } |
| 49 | 48 | |
| 50 | | if ( |
| 51 | | lower.includes("authentication failed") || |
| 52 | | lower.includes("not logged in") || |
| 53 | | lower.includes("gh auth login") || |
| 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 | | } |
| 49 | override get message(): string { |
| 50 | return `GitHub CLI failed in execute: ${this.detail}`; |
| 51 | } |
| 52 | } |
| 62 | 53 | |
| 63 | | if ( |
| 64 | | lower.includes("could not resolve to a pullrequest") || |
| 65 | | lower.includes("repository.pullrequest") || |
| 66 | | lower.includes("no pull requests found for branch") || |
| 67 | | lower.includes("pull request not found") |
| 68 | | ) { |
| 69 | | return new GitHubCliError({ |
| 70 | | ...context, |
| 71 | | detail: "Pull request not found. Check the PR number or URL and try again.", |
| 72 | | cause: error, |
| 73 | | }); |
| 74 | | } |
| 54 | export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass<GitHubPullRequestNotFoundError>()( |
| 55 | "GitHubPullRequestNotFoundError", |
| 56 | gitHubCliFailureFields, |
| 57 | ) { |
| 58 | get detail(): string { |
| 59 | return "Pull request not found. Check the PR number or URL and try again."; |
| 60 | } |
| 61 | |
| 62 | override get message(): string { |
| 63 | return `GitHub CLI failed in execute: ${this.detail}`; |
| 64 | } |
| 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({ |
| 77 | | ...context, |
| 78 | | detail: "GitHub CLI command failed.", |
| 79 | | cause: error, |
| 80 | | }); |
| 107 | override get message(): string { |
| 108 | return `GitHub CLI failed in listChangeRequests: ${this.detail}`; |
| 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 | 181 | export interface GitHubPullRequestSummary { |
| 85 | 182 | readonly number: number; |
| 86 | 183 | readonly title: string; |
| ... | ... | @@ -150,22 +247,14 @@ export class GitHubCli extends Context.Service< |
| 150 | 247 | } |
| 151 | 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 | 250 | const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ |
| 165 | 251 | nameWithOwner: TrimmedNonEmptyString, |
| 166 | 252 | url: TrimmedNonEmptyString, |
| 167 | 253 | sshUrl: TrimmedNonEmptyString, |
| 168 | 254 | }); |
| 255 | const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( |
| 256 | Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), |
| 257 | ); |
| 169 | 258 | |
| 170 | 259 | function normalizeRepositoryCloneUrls( |
| 171 | 260 | raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>, |
| ... | ... | @@ -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 | 306 | export const make = Effect.gen(function* () { |
| 239 | 307 | const process = yield* VcsProcess.VcsProcess; |
| 240 | 308 | |
| ... | ... | @@ -247,14 +315,7 @@ export const make = Effect.gen(function* () { |
| 247 | 315 | cwd: input.cwd, |
| 248 | 316 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, |
| 249 | 317 | }) |
| 250 | | .pipe( |
| 251 | | Effect.mapError((error) => |
| 252 | | GitHubCliError.fromVcsError( |
| 253 | | { operation: "execute", command: "gh", cwd: input.cwd }, |
| 254 | | error, |
| 255 | | ), |
| 256 | | ), |
| 257 | | ); |
| 318 | .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); |
| 258 | 319 | |
| 259 | 320 | return GitHubCli.of({ |
| 260 | 321 | execute, |
| ... | ... | @@ -282,11 +343,9 @@ export const make = Effect.gen(function* () { |
| 282 | 343 | Effect.flatMap((decoded) => { |
| 283 | 344 | if (!Result.isSuccess(decoded)) { |
| 284 | 345 | return Effect.fail( |
| 285 | | new GitHubCliError({ |
| 286 | | operation: "listOpenPullRequests", |
| 346 | new GitHubPullRequestListDecodeError({ |
| 287 | 347 | command: "gh", |
| 288 | 348 | cwd: input.cwd, |
| 289 | | detail: "GitHub CLI returned invalid PR list JSON.", |
| 290 | 349 | cause: decoded.failure, |
| 291 | 350 | }), |
| 292 | 351 | ); |
| ... | ... | @@ -316,11 +375,9 @@ export const make = Effect.gen(function* () { |
| 316 | 375 | Effect.flatMap((decoded) => { |
| 317 | 376 | if (!Result.isSuccess(decoded)) { |
| 318 | 377 | return Effect.fail( |
| 319 | | new GitHubCliError({ |
| 320 | | operation: "getPullRequest", |
| 378 | new GitHubPullRequestDecodeError({ |
| 321 | 379 | command: "gh", |
| 322 | 380 | cwd: input.cwd, |
| 323 | | detail: "GitHub CLI returned invalid pull request JSON.", |
| 324 | 381 | cause: decoded.failure, |
| 325 | 382 | }), |
| 326 | 383 | ); |
| ... | ... | @@ -340,12 +397,15 @@ export const make = Effect.gen(function* () { |
| 340 | 397 | }).pipe( |
| 341 | 398 | Effect.map((result) => result.stdout.trim()), |
| 342 | 399 | Effect.flatMap((raw) => |
| 343 | | decodeGitHubJson( |
| 344 | | raw, |
| 345 | | RawGitHubRepositoryCloneUrlsSchema, |
| 346 | | "getRepositoryCloneUrls", |
| 347 | | "GitHub CLI returned invalid repository JSON.", |
| 348 | | input.cwd, |
| 400 | decodeRawGitHubRepositoryCloneUrls(raw).pipe( |
| 401 | Effect.mapError( |
| 402 | (cause) => |
| 403 | new GitHubRepositoryDecodeError({ |
| 404 | command: "gh", |
| 405 | cwd: input.cwd, |
| 406 | cause, |
| 407 | }), |
| 408 | ), |
| 349 | 409 | ), |
| 350 | 410 | ), |
| 351 | 411 | Effect.map(normalizeRepositoryCloneUrls), |