Skip to content

Commit bcd7010

Browse files
xesrevinuGit Agent
andcommitted
feat(shared): add utilities for errors, output, process, text
- Introduce custom error classes for common error types in shared/errors.ts - Add functions to print commit results in a functional effect style in shared/output.ts - Implement a robust runProcess utility with error handling in shared/process.ts - Provide text utils for wrapping lines, extracting JSON, parsing CSV, and managing trailers in shared/text.ts This commit adds foundational shared utilities used across the project. It includes strongly typed error classes for standardized error handling, output printers using an effect system, a process runner with scoped resource management and error wrapping, and comprehensive text processing helpers for wrapping, extracting JSON blocks, appending trailers, and CSV parsing. These are intended to support consistency and reusable logic in the codebase. Co-Authored-By: Git Agent <noreply@git-agent.dev>
1 parent 115227b commit bcd7010

4 files changed

Lines changed: 288 additions & 0 deletions

File tree

src/shared/errors.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Schema } from "effect";
2+
3+
export class CliUsageError extends Schema.TaggedErrorClass<CliUsageError>()("CliUsageError", {
4+
message: Schema.String,
5+
}) {}
6+
7+
export class ConfigError extends Schema.TaggedErrorClass<ConfigError>()("ConfigError", {
8+
message: Schema.String,
9+
}) {}
10+
11+
export class ProcessExecutionError extends Schema.TaggedErrorClass<ProcessExecutionError>()(
12+
"ProcessExecutionError",
13+
{
14+
command: Schema.String,
15+
exitCode: Schema.Number,
16+
stdout: Schema.String,
17+
stderr: Schema.String,
18+
},
19+
) {}
20+
21+
export class ApiError extends Schema.TaggedErrorClass<ApiError>()("ApiError", {
22+
message: Schema.String,
23+
status: Schema.optional(Schema.Number),
24+
body: Schema.optional(Schema.String),
25+
}) {}
26+
27+
export class HookBlockedError extends Schema.TaggedErrorClass<HookBlockedError>()(
28+
"HookBlockedError",
29+
{
30+
message: Schema.String,
31+
reason: Schema.optional(Schema.String),
32+
lastMessage: Schema.optional(Schema.String),
33+
},
34+
) {}
35+
36+
export class UnsupportedFeatureError extends Schema.TaggedErrorClass<UnsupportedFeatureError>()(
37+
"UnsupportedFeatureError",
38+
{
39+
message: Schema.String,
40+
},
41+
) {}
42+
43+
export const renderError = (error: unknown): string => {
44+
if (error instanceof CliUsageError) {
45+
return error.message;
46+
}
47+
if (error instanceof ConfigError) {
48+
return error.message;
49+
}
50+
if (error instanceof ApiError) {
51+
const parts = [error.message];
52+
if (typeof error.status === "number") {
53+
parts.push(`status: ${error.status}`);
54+
}
55+
if (typeof error.body === "string" && error.body.trim().length > 0) {
56+
parts.push(error.body.trim());
57+
}
58+
return parts.join("\n");
59+
}
60+
if (error instanceof ProcessExecutionError) {
61+
const details = error.stderr.trim().length > 0 ? error.stderr.trim() : error.stdout.trim();
62+
return `${error.command} exited with code ${error.exitCode}${details.length > 0 ? `\n${details}` : ""}`;
63+
}
64+
if (error instanceof HookBlockedError) {
65+
const lines = [error.message];
66+
if (typeof error.reason === "string" && error.reason.trim().length > 0) {
67+
lines.push("", `hook rejected: ${error.reason.trim()}`);
68+
}
69+
if (typeof error.lastMessage === "string" && error.lastMessage.trim().length > 0) {
70+
lines.push("", "rejected message:", "", error.lastMessage.trim());
71+
}
72+
return lines.join("\n");
73+
}
74+
if (error instanceof UnsupportedFeatureError) {
75+
return error.message;
76+
}
77+
if (error instanceof Error) {
78+
return `${error.name}\n${error.message}`;
79+
}
80+
return String(error);
81+
};

src/shared/output.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Console, Effect } from "effect";
2+
import type { SingleCommitResult } from "../domain/commit";
3+
4+
export const printDryRunResult = (commits: ReadonlyArray<SingleCommitResult>) =>
5+
Effect.forEach(commits, (commit, index) =>
6+
Console.log(`${index + 1}. ${commit.title}\n ${commit.files.join(", ")}`),
7+
);
8+
9+
export const printCommitResult = (commits: ReadonlyArray<SingleCommitResult>) =>
10+
Effect.forEach(commits, (commit) =>
11+
Console.log(
12+
[commit.output?.trim(), commit.explanation.trim()]
13+
.filter((value) => value != null && value.length > 0)
14+
.join("\n"),
15+
),
16+
);

src/shared/process.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { Effect, Stream } from "effect";
2+
import { ChildProcess } from "effect/unstable/process";
3+
import { ProcessExecutionError } from "./errors";
4+
5+
export interface ProcessResult {
6+
readonly stdout: string;
7+
readonly stderr: string;
8+
readonly exitCode: number;
9+
}
10+
11+
export interface RunProcessOptions {
12+
readonly command: string;
13+
readonly args?: ReadonlyArray<string>;
14+
readonly cwd?: string;
15+
readonly env?: Record<string, string | undefined>;
16+
readonly stdin?: string;
17+
readonly allowFailure?: boolean;
18+
}
19+
20+
const encoder = new TextEncoder();
21+
22+
const renderCommand = (command: string, args: ReadonlyArray<string>): string =>
23+
[command, ...args].join(" ");
24+
25+
const toProcessExecutionError = (command: string, args: ReadonlyArray<string>, cause: unknown) =>
26+
new ProcessExecutionError({
27+
command: renderCommand(command, args),
28+
exitCode: 1,
29+
stdout: "",
30+
stderr: cause instanceof Error ? cause.message : String(cause),
31+
});
32+
33+
export const runProcess = ({
34+
command,
35+
args = [],
36+
cwd,
37+
env,
38+
stdin,
39+
allowFailure = false,
40+
}: RunProcessOptions) =>
41+
Effect.scoped(
42+
Effect.gen(function* () {
43+
const handle = yield* ChildProcess.make(command, [...args], {
44+
cwd,
45+
env,
46+
extendEnv: true,
47+
...(typeof stdin === "string" ? { stdin: Stream.succeed(encoder.encode(stdin)) } : {}),
48+
});
49+
const { stdout, stderr, exitCode } = yield* Effect.all({
50+
stdout: Stream.mkString(Stream.decodeText(handle.stdout)),
51+
stderr: Stream.mkString(Stream.decodeText(handle.stderr)),
52+
exitCode: handle.exitCode,
53+
});
54+
const result = {
55+
stdout,
56+
stderr,
57+
exitCode: Number(exitCode),
58+
} satisfies ProcessResult;
59+
60+
if (result.exitCode !== 0 && !allowFailure) {
61+
return yield* Effect.fail(
62+
new ProcessExecutionError({
63+
command: renderCommand(command, args),
64+
exitCode: result.exitCode,
65+
stdout: result.stdout,
66+
stderr: result.stderr,
67+
}),
68+
);
69+
}
70+
71+
return result;
72+
}).pipe(
73+
Effect.catch((cause) =>
74+
cause instanceof ProcessExecutionError
75+
? Effect.fail(cause)
76+
: Effect.fail(toProcessExecutionError(command, args, cause)),
77+
),
78+
),
79+
);

src/shared/text.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { Trailer } from "../domain/commit";
2+
3+
export const countLines = (content: string): number => {
4+
if (content.length === 0) {
5+
return 0;
6+
}
7+
return content.split("\n").length - 1;
8+
};
9+
10+
export const wrapLongLine = (line: string, width: number): Array<string> => {
11+
const parts: Array<string> = [];
12+
let remaining = line;
13+
14+
while (remaining.length > width) {
15+
const slice = remaining.slice(0, width + 1);
16+
const lastSpace = slice.lastIndexOf(" ");
17+
if (lastSpace <= 0) {
18+
parts.push(remaining.slice(0, width));
19+
remaining = remaining.slice(width);
20+
continue;
21+
}
22+
parts.push(remaining.slice(0, lastSpace));
23+
remaining = remaining.slice(lastSpace + 1);
24+
}
25+
26+
if (remaining.length > 0) {
27+
parts.push(remaining);
28+
}
29+
30+
return parts;
31+
};
32+
33+
export const wrapExplanation = (text: string, width = 72): string =>
34+
text
35+
.split("\n")
36+
.flatMap((line) => (line.length <= 100 ? [line] : wrapLongLine(line, width)))
37+
.join("\n");
38+
39+
export const extractJson = (input: string): string => {
40+
const candidates: Array<[string, string]> = [
41+
["{", "}"],
42+
["[", "]"],
43+
];
44+
let start = -1;
45+
let open = "";
46+
let close = "";
47+
48+
for (const [candidateOpen, candidateClose] of candidates) {
49+
const index = input.indexOf(candidateOpen);
50+
if (index !== -1 && (start === -1 || index < start)) {
51+
start = index;
52+
open = candidateOpen;
53+
close = candidateClose;
54+
}
55+
}
56+
57+
if (start === -1) {
58+
return input;
59+
}
60+
61+
let depth = 0;
62+
let inString = false;
63+
let escaped = false;
64+
65+
for (let index = start; index < input.length; index += 1) {
66+
const char = input[index];
67+
if (escaped) {
68+
escaped = false;
69+
continue;
70+
}
71+
if (char === "\\" && inString) {
72+
escaped = true;
73+
continue;
74+
}
75+
if (char === '"') {
76+
inString = !inString;
77+
continue;
78+
}
79+
if (inString) {
80+
continue;
81+
}
82+
if (char === open) {
83+
depth += 1;
84+
continue;
85+
}
86+
if (char === close) {
87+
depth -= 1;
88+
if (depth === 0) {
89+
return input.slice(start, index + 1);
90+
}
91+
}
92+
}
93+
94+
return input;
95+
};
96+
97+
export const appendTrailers = (message: string, trailers: ReadonlyArray<Trailer>): string => {
98+
if (trailers.length === 0) {
99+
return message.trimEnd();
100+
}
101+
const footer = trailers.map((trailer) => `${trailer.key}: ${trailer.value}`).join("\n");
102+
return `${message.trimEnd()}\n\n${footer}`;
103+
};
104+
105+
export const parseCsv = (input: string | undefined): Array<string> =>
106+
(input ?? "")
107+
.split(",")
108+
.map((value) => value.trim())
109+
.filter((value) => value.length > 0);
110+
111+
export const parseCsvValues = (inputs: ReadonlyArray<string>): Array<string> =>
112+
inputs.flatMap((input) => parseCsv(input));

0 commit comments

Comments
 (0)