Skip to content

Commit 74d5e1d

Browse files
authored
Add structured Git action failures and retry UI (#55)
* Refactor Git action error handling and enhance WebSocket communication - Introduce GitActionExecutionError for structured error handling in git actions. - Update WebSocket server to serialize Git action errors with detailed failure information. - Enhance client-side handling of WebSocket errors to preserve structured error data. - Add utility functions for summarizing and resolving Git action failures in the UI. - Improve tests to cover new error handling scenarios and ensure proper decoding of structured errors. * Migrate dialog and input primitives to Base UI - Switch dialogs to Base UI primitives and update close handling - Add richer button/input composition for shared component use - Tweak destructive actions and GitHub review link rendering
1 parent 9ee5ac3 commit 74d5e1d

22 files changed

Lines changed: 1033 additions & 38 deletions

apps/server/src/git/Errors.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { GitActionFailure } from "@okcode/contracts";
12
import { Schema } from "effect";
23

34
/**
@@ -57,10 +58,26 @@ export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()(
5758
}
5859
}
5960

61+
/**
62+
* GitActionExecutionError - Structured failure information for a stacked git action.
63+
*/
64+
export class GitActionExecutionError extends Error {
65+
readonly failure: GitActionFailure;
66+
override readonly cause?: unknown;
67+
68+
constructor(input: { failure: GitActionFailure; cause?: unknown }) {
69+
super(input.failure.summary, input.cause !== undefined ? { cause: input.cause } : undefined);
70+
this.name = "GitActionExecutionError";
71+
this.failure = input.failure;
72+
this.cause = input.cause;
73+
}
74+
}
75+
6076
/**
6177
* GitManagerServiceError - Errors emitted by stacked Git workflow orchestration.
6278
*/
6379
export type GitManagerServiceError =
80+
| GitActionExecutionError
6481
| GitManagerError
6582
| GitCommandError
6683
| GitHubCliError

apps/server/src/git/Layers/GitManager.test.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -962,7 +962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
962962
Effect.map((error) => error.message),
963963
);
964964

965-
expect(errorMessage).toContain("no changes to commit");
965+
expect(errorMessage).toContain("no staged or working tree changes");
966966
}),
967967
);
968968

@@ -1413,7 +1413,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
14131413
Effect.flip,
14141414
Effect.map((error) => error.message),
14151415
);
1416-
expect(errorMessage).toContain("GitHub CLI (`gh`) is required");
1416+
expect(errorMessage).toContain("requires GitHub CLI");
14171417
}),
14181418
);
14191419

@@ -1442,7 +1442,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
14421442
Effect.flip,
14431443
Effect.map((error) => error.message),
14441444
);
1445-
expect(errorMessage).toContain("gh auth login");
1445+
expect(errorMessage).toContain("not authenticated");
14461446
}),
14471447
);
14481448

@@ -2077,7 +2077,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
20772077
Effect.map((error) => error.message),
20782078
);
20792079

2080-
expect(errorMessage).toContain("hook: fail");
2080+
expect(errorMessage).toContain("repository hook rejected");
20812081
expect(events).toEqual(
20822082
expect.arrayContaining([
20832083
expect.objectContaining({
@@ -2087,6 +2087,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
20872087
expect.objectContaining({
20882088
kind: "action_failed",
20892089
phase: "commit",
2090+
failure: expect.objectContaining({
2091+
code: "hook_failed",
2092+
title: "Commit hook blocked the action",
2093+
detail: expect.stringContaining("hook: fail"),
2094+
}),
20902095
}),
20912096
]),
20922097
);

apps/server/src/git/Layers/GitManager.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
sanitizeFeatureBranchName,
1010
} from "@okcode/shared/git";
1111

12-
import { GitManagerError } from "../Errors.ts";
12+
import { GitActionExecutionError, GitManagerError } from "../Errors.ts";
1313
import {
1414
GitManager,
1515
type GitActionProgressReporter,
@@ -19,6 +19,7 @@ import {
1919
import { GitCore } from "../Services/GitCore.ts";
2020
import { GitHubCli } from "../Services/GitHubCli.ts";
2121
import { TextGeneration } from "../Services/TextGeneration.ts";
22+
import { buildGitActionFailure } from "../actionFailure.ts";
2223

2324
const COMMIT_TIMEOUT_MS = 10 * 60_000;
2425
const MAX_PROGRESS_TEXT_LENGTH = 500;
@@ -1261,13 +1262,27 @@ export const makeGitManager = Effect.gen(function* () {
12611262

12621263
return yield* runAction.pipe(
12631264
Effect.catch((error) =>
1264-
progress
1265-
.emit({
1265+
Effect.gen(function* () {
1266+
const failure = buildGitActionFailure({
1267+
action: input.action,
1268+
phase: currentPhase,
1269+
error,
1270+
});
1271+
1272+
yield* progress.emit({
12661273
kind: "action_failed",
12671274
phase: currentPhase,
1268-
message: error.message,
1269-
})
1270-
.pipe(Effect.flatMap(() => Effect.fail(error))),
1275+
message: failure.summary,
1276+
failure,
1277+
});
1278+
1279+
return yield* Effect.fail(
1280+
new GitActionExecutionError({
1281+
failure,
1282+
cause: error,
1283+
}),
1284+
);
1285+
}),
12711286
),
12721287
);
12731288
},
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildGitActionFailure } from "./actionFailure.ts";
3+
import { GitCommandError, GitHubCliError } from "./Errors.ts";
4+
5+
describe("buildGitActionFailure", () => {
6+
it("classifies GitHub auth failures during PR creation", () => {
7+
const failure = buildGitActionFailure({
8+
action: "commit_push_pr",
9+
phase: "pr",
10+
error: new GitHubCliError({
11+
operation: "createPullRequest",
12+
detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",
13+
}),
14+
});
15+
16+
expect(failure.code).toBe("github_auth_required");
17+
expect(failure.title).toBe("Authenticate GitHub CLI");
18+
expect(failure.summary).toContain("branch was pushed");
19+
expect(failure.nextSteps).toContain("Run `gh auth login` in this environment.");
20+
});
21+
22+
it("classifies protected branch push failures", () => {
23+
const failure = buildGitActionFailure({
24+
action: "commit_push",
25+
phase: "push",
26+
error: new GitCommandError({
27+
operation: "GitCore.pushCurrentBranch",
28+
command: "git push origin main",
29+
cwd: "/repo",
30+
detail: "remote: error: GH006: Protected branch update failed for refs/heads/main.",
31+
}),
32+
});
33+
34+
expect(failure.code).toBe("branch_protected");
35+
expect(failure.title).toBe("Protected branch rejected the push");
36+
expect(failure.command).toBe("git push origin main");
37+
});
38+
39+
it("classifies commit hook failures", () => {
40+
const failure = buildGitActionFailure({
41+
action: "commit",
42+
phase: "commit",
43+
error: new GitCommandError({
44+
operation: "GitCore.commit.commit",
45+
command: "git commit -m Fix",
46+
cwd: "/repo",
47+
detail: "pre-commit hook failed:\nlint errors found",
48+
}),
49+
});
50+
51+
expect(failure.code).toBe("hook_failed");
52+
expect(failure.summary).toBe("A repository hook rejected the commit.");
53+
expect(failure.nextSteps[0]).toContain("Review the hook output");
54+
});
55+
});

0 commit comments

Comments
 (0)