authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 19:14:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 19:14:35-07:00
log61e6d89d69240120dd08fae72459e5fd7e7a2908
treef6275b6b7752c554f09abeff229b2373c64d4c67
parent1b2f39d018410a9e09c67e2629f6ca1562860e6b
signature Signed by PGP key B5690EEEBB952194

[codex] Structure GitHub CLI failures (#3456)

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 {
171171 if (result.status === 0) {
172172 return;
173173 }
174 throw new GitHubCli.GitHubCliError({
175 operation: "execute",
176 command: "gh",
177 cwd,
178 detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`,
179 });
180}
181
182function 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"
174 throw new Error(
175 `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`,
188176 );
189177}
190178
......@@ -478,16 +466,12 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
478466 return fakeGhOutput("");
479467 },
480468 catch: (error) =>
481 isGitHubCliError(error)
469 GitHubCli.isGitHubCliError(error)
482470 ? error
483 : new GitHubCli.GitHubCliError({
484 operation: "execute",
471 : new GitHubCli.GitHubCliCommandError({
485472 command: "gh",
486473 cwd: input.cwd,
487 detail:
488 error instanceof Error
489 ? `Failed to simulate gh checkout: ${error.message}`
490 : "Failed to simulate gh checkout.",
474 cause: error,
491475 }),
492476 });
493477 }
......@@ -498,11 +482,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
498482 const cloneUrls = scenario.repositoryCloneUrls?.[repository];
499483 if (!cloneUrls) {
500484 return Effect.fail(
501 new GitHubCli.GitHubCliError({
502 operation: "execute",
485 new GitHubCli.GitHubCliCommandError({
503486 command: "gh",
504487 cwd: input.cwd,
505 detail: `Unexpected repository lookup: ${repository}`,
488 cause: new Error(`Unexpected repository lookup: ${repository}`),
506489 }),
507490 );
508491 }
......@@ -520,11 +503,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
520503 }
521504
522505 return Effect.fail(
523 new GitHubCli.GitHubCliError({
524 operation: "execute",
506 new GitHubCli.GitHubCliCommandError({
525507 command: "gh",
526508 cwd: input.cwd,
527 detail: `Unexpected gh command: ${args.join(" ")}`,
509 cause: new Error(`Unexpected gh command: ${args.join(" ")}`),
528510 }),
529511 );
530512 };
......@@ -601,11 +583,10 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
601583 }).pipe(Effect.map((result) => JSON.parse(result.stdout))),
602584 createRepository: (input) =>
603585 Effect.fail(
604 new GitHubCli.GitHubCliError({
605 operation: "createRepository",
586 new GitHubCli.GitHubCliCommandError({
606587 command: "gh",
607588 cwd: input.cwd,
608 detail: `Unexpected repository create: ${input.repository}`,
589 cause: new Error(`Unexpected repository create: ${input.repository}`),
609590 }),
610591 ),
611592 checkoutPullRequest: (input) =>
......@@ -1341,11 +1322,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
13411322
13421323 const { manager } = yield* makeManager({
13431324 ghScenario: {
1344 failWith: new GitHubCli.GitHubCliError({
1345 operation: "execute",
1325 failWith: new GitHubCli.GitHubCliUnavailableError({
13461326 command: "gh",
13471327 cwd: repoDir,
1348 detail: "GitHub CLI (`gh`) is required but not available on PATH.",
1328 cause: new Error("gh is not available on PATH"),
13491329 }),
13501330 },
13511331 });
......@@ -2483,11 +2463,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
24832463
24842464 const { manager } = yield* makeManager({
24852465 ghScenario: {
2486 failWith: new GitHubCli.GitHubCliError({
2487 operation: "execute",
2466 failWith: new GitHubCli.GitHubCliUnavailableError({
24882467 command: "gh",
24892468 cwd: repoDir,
2490 detail: "GitHub CLI (`gh`) is required but not available on PATH.",
2469 cause: new Error("gh is not available on PATH"),
24912470 }),
24922471 },
24932472 });
......@@ -2514,11 +2493,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
25142493
25152494 const { manager } = yield* makeManager({
25162495 ghScenario: {
2517 failWith: new GitHubCli.GitHubCliError({
2518 operation: "execute",
2496 failWith: new GitHubCli.GitHubCliAuthenticationError({
25192497 command: "gh",
25202498 cwd: repoDir,
2521 detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",
2499 cause: new Error("gh is not authenticated"),
25222500 }),
25232501 },
25242502 });
apps/server/src/sourceControl/GitHubCli.test.ts+25-1
......@@ -1,8 +1,9 @@
11import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest";
22import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
4import * as PlatformError from "effect/PlatformError";
45import { ChildProcessSpawner } from "effect/unstable/process";
5import { VcsProcessExitError } from "@t3tools/contracts";
6import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts";
67
78import * as VcsProcess from "../vcs/VcsProcess.ts";
89import * as GitHubCli from "./GitHubCli.ts";
......@@ -30,6 +31,27 @@ afterEach(() => {
3031});
3132
3233describe("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
3355 it.effect("parses pull request view output", () =>
3456 Effect.gen(function* () {
3557 mockRun.mockReturnValueOnce(
......@@ -274,6 +296,7 @@ describe("GitHubCli.layer", () => {
274296 command: "gh pr view",
275297 cwd: "/repo",
276298 exitCode: 1,
299 failureKind: "not-found",
277300 detail:
278301 "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)",
279302 });
......@@ -288,6 +311,7 @@ describe("GitHubCli.layer", () => {
288311 .pipe(Effect.flip);
289312
290313 assert.equal(error.message.includes("Pull request not found"), true);
314 assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError");
291315 assert.strictEqual(error.command, "gh");
292316 assert.strictEqual(error.cwd, "/repo");
293317 assert.strictEqual(error.cause, cause);
apps/server/src/sourceControl/GitHubCli.ts+165-105
......@@ -1,6 +1,7 @@
11import * as Context from "effect/Context";
22import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
4import * as PlatformError from "effect/PlatformError";
45import * as Result from "effect/Result";
56import * as Schema from "effect/Schema";
67
......@@ -18,69 +19,165 @@ import {
1819
1920const DEFAULT_TIMEOUT_MS = 30_000;
2021
21export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", {
22 operation: Schema.String,
23 command: Schema.String,
22const gitHubCliFailureFields = {
23 command: Schema.Literal("gh"),
2424 cwd: Schema.String,
25 detail: Schema.String,
26 cause: Schema.optional(Schema.Defect()),
27}) {
25 cause: Schema.Defect(),
26} as const;
27
28export 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
2836 override get message(): string {
29 return `GitHub CLI failed in ${this.operation}: ${this.detail}`;
37 return `GitHub CLI failed in execute: ${this.detail}`;
3038 }
39}
3140
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 }
41export 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 }
4948
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}
6253
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 }
54export 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
67export 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
80const gitHubCliDecodeFields = {
81 command: Schema.Literal("gh"),
82 cwd: Schema.String,
83 cause: Schema.Defect(),
84} as const;
85
86export 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
99export 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 }
75106
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}`;
81109 }
82110}
83111
112export 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
125export 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
138export const GitHubCliError = Schema.Union([
139 GitHubCliUnavailableError,
140 GitHubCliAuthenticationError,
141 GitHubPullRequestNotFoundError,
142 GitHubCliCommandError,
143 GitHubPullRequestListDecodeError,
144 GitHubChangeRequestListDecodeError,
145 GitHubPullRequestDecodeError,
146 GitHubRepositoryDecodeError,
147]);
148export type GitHubCliError = typeof GitHubCliError.Type;
149
150export const isGitHubCliError = Schema.is(GitHubCliError);
151
152export 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
84181export interface GitHubPullRequestSummary {
85182 readonly number: number;
86183 readonly title: string;
......@@ -150,22 +247,14 @@ export class GitHubCli extends Context.Service<
150247 }
151248>()("t3/sourceControl/GitHubCli") {}
152249
153function 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
164250const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({
165251 nameWithOwner: TrimmedNonEmptyString,
166252 url: TrimmedNonEmptyString,
167253 sshUrl: TrimmedNonEmptyString,
168254});
255const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect(
256 Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema),
257);
169258
170259function normalizeRepositoryCloneUrls(
171260 raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>,
......@@ -214,27 +303,6 @@ function deriveRepositoryCloneUrlsFromCreateOutput(
214303 };
215304}
216305
217function 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
238306export const make = Effect.gen(function* () {
239307 const process = yield* VcsProcess.VcsProcess;
240308
......@@ -247,14 +315,7 @@ export const make = Effect.gen(function* () {
247315 cwd: input.cwd,
248316 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
249317 })
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)));
258319
259320 return GitHubCli.of({
260321 execute,
......@@ -282,11 +343,9 @@ export const make = Effect.gen(function* () {
282343 Effect.flatMap((decoded) => {
283344 if (!Result.isSuccess(decoded)) {
284345 return Effect.fail(
285 new GitHubCliError({
286 operation: "listOpenPullRequests",
346 new GitHubPullRequestListDecodeError({
287347 command: "gh",
288348 cwd: input.cwd,
289 detail: "GitHub CLI returned invalid PR list JSON.",
290349 cause: decoded.failure,
291350 }),
292351 );
......@@ -316,11 +375,9 @@ export const make = Effect.gen(function* () {
316375 Effect.flatMap((decoded) => {
317376 if (!Result.isSuccess(decoded)) {
318377 return Effect.fail(
319 new GitHubCliError({
320 operation: "getPullRequest",
378 new GitHubPullRequestDecodeError({
321379 command: "gh",
322380 cwd: input.cwd,
323 detail: "GitHub CLI returned invalid pull request JSON.",
324381 cause: decoded.failure,
325382 }),
326383 );
......@@ -340,12 +397,15 @@ export const make = Effect.gen(function* () {
340397 }).pipe(
341398 Effect.map((result) => result.stdout.trim()),
342399 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 ),
349409 ),
350410 ),
351411 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", () =
7070
7171it.effect("adds safe request context while retaining GitHub CLI causes", () =>
7272 Effect.gen(function* () {
73 const cause = new GitHubCli.GitHubCliError({
74 operation: "execute",
73 const cause = new GitHubCli.GitHubPullRequestNotFoundError({
7574 command: "gh",
7675 cwd: "/repo",
77 detail: "Pull request not found. Check the PR number or URL and try again.",
7876 cause: new Error("raw upstream detail that should remain in the cause"),
7977 });
8078 const provider = yield* makeProvider({
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+4-10
......@@ -158,23 +158,17 @@ export const make = Effect.gen(function* () {
158158 })),
159159 )
160160 : Effect.fail(
161 new SourceControlProviderError({
162 provider: "github",
163 operation: "listChangeRequests",
161 new GitHubCli.GitHubChangeRequestListDecodeError({
164162 command: "gh",
165163 cwd: input.cwd,
166 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
167 input.headSelector,
168 ),
169 detail: "GitHub CLI returned invalid change request JSON.",
170164 cause: decoded.failure,
171165 }),
172166 ),
173167 ),
174168 );
175169 }),
176 Effect.catchTags({
177 GitHubCliError: (error) =>
170 Effect.mapError(
171 (error) =>
178172 new SourceControlProviderError({
179173 provider: "github",
180174 operation: "listChangeRequests",
......@@ -186,7 +180,7 @@ export const make = Effect.gen(function* () {
186180 detail: error.detail,
187181 cause: error,
188182 }),
189 }),
183 ),
190184 );
191185 };
192186