Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions apps/server/src/vcs/VcsProcess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import { TestClock } from "effect/testing";

import { VcsProcessExitError, VcsProcessTimeoutError } from "@t3tools/contracts";
import {
VcsProcessExitError,
VcsProcessSpawnError,
VcsProcessTimeoutError,
} from "@t3tools/contracts";
import * as VcsProcess from "./VcsProcess.ts";

const run = (input: VcsProcess.VcsProcessInput) =>
Expand Down Expand Up @@ -61,14 +65,79 @@ describe("VcsProcess.run", () => {

it.effect("fails with VcsProcessExitError for non-zero exits by default", () =>
Effect.gen(function* () {
const secretArgument = "--token=super-secret-token";
const secretStderr = "remote rejected super-secret-token";
const error = yield* run({
operation: "test.exit",
command: "node",
args: ["-e", "process.stderr.write('boom'); process.exit(2)"],
args: [
"-e",
"process.stderr.write(process.argv[1]); process.exit(2)",
secretStderr,
secretArgument,
],
cwd: process.cwd(),
}).pipe(Effect.flip);

expect(error).toBeInstanceOf(VcsProcessExitError);
expect(error).toMatchObject({
operation: "test.exit",
command: "node",
argumentCount: 4,
exitCode: 2,
detail: "Process exited with a non-zero status.",
failureKind: "command-failed",
stderrLength: secretStderr.length,
stderrTruncated: false,
});
expect(error.message).not.toContain(secretArgument);
expect(error.message).not.toContain(secretStderr);
}).pipe(provideLive),
);

it.effect("classifies authentication failures without retaining stderr", () =>
Effect.gen(function* () {
const secretStderr = "authentication failed for token super-secret-token";
const error = yield* run({
operation: "test.authentication",
command: "node",
args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", secretStderr],
cwd: process.cwd(),
}).pipe(Effect.flip);

expect(error).toBeInstanceOf(VcsProcessExitError);
expect(error).toMatchObject({
operation: "test.authentication",
command: "node",
exitCode: 1,
detail: "Authentication failed.",
failureKind: "authentication",
stderrLength: secretStderr.length,
stderrTruncated: false,
});
expect(error.message).not.toContain(secretStderr);
expect(error.message).not.toContain("super-secret-token");
}).pipe(provideLive),
);

it.effect("retains spawn causes without exposing process arguments in the error message", () =>
Effect.gen(function* () {
const secretArgument = "--token=super-secret-token";
const error = yield* run({
operation: "test.spawn",
command: "definitely-not-a-t3code-executable",
args: [secretArgument],
cwd: process.cwd(),
}).pipe(Effect.flip);

expect(error).toBeInstanceOf(VcsProcessSpawnError);
expect(error).toMatchObject({
operation: "test.spawn",
command: "definitely-not-a-t3code-executable",
argumentCount: 1,
});
expect(error).toHaveProperty("cause");
expect(error.message).not.toContain(secretArgument);
}).pipe(provideLive),
);

Expand Down
59 changes: 47 additions & 12 deletions apps/server/src/vcs/VcsProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
VcsOutputDecodeError,
type VcsError,
VcsProcessExitError,
type VcsProcessExitFailureKind,
VcsProcessSpawnError,
VcsProcessTimeoutError,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -46,19 +47,51 @@ const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]";

function commandLabel(command: string, args: ReadonlyArray<string>): string {
return [command, ...args].join(" ");
}
const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => {
const normalized = stderr.toLowerCase();

if (
normalized.includes("authentication failed") ||
normalized.includes("not logged in") ||
normalized.includes("gh auth login") ||
normalized.includes("glab auth login") ||
normalized.includes("az devops login") ||
normalized.includes("please run az login") ||
normalized.includes("no oauth token") ||
normalized.includes("unauthorized")
) {
return "authentication";
}

if (
(command === "gh" &&
(normalized.includes("could not resolve to a pullrequest") ||
normalized.includes("repository.pullrequest") ||
normalized.includes("no pull requests found for branch") ||
normalized.includes("pull request not found"))) ||
(command === "glab" &&
(normalized.includes("merge request not found") ||
normalized.includes("not found") ||
normalized.includes("404"))) ||
(command === "az" &&
normalized.includes("pull request") &&
(normalized.includes("not found") || normalized.includes("does not exist")))
) {
return "not-found";
}

return "command-failed";
};

export const make = Effect.gen(function* () {
const processRunner = yield* ProcessRunner.ProcessRunner;

const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) {
const label = commandLabel(input.command, input.args);
const baseError = {
operation: input.operation,
command: label,
command: input.command,
cwd: input.cwd,
argumentCount: input.args.length,
};

const result = yield* processRunner
Expand Down Expand Up @@ -97,13 +130,15 @@ export const make = Effect.gen(function* () {
}

if (!input.allowNonZeroExit && result.code !== 0) {
return yield* new VcsProcessExitError({
operation: input.operation,
command: label,
Comment thread
cursor[bot] marked this conversation as resolved.
cwd: input.cwd,
exitCode: result.code,
detail: result.stderr.trim() || `${label} exited with code ${result.code}.`,
});
return yield* VcsProcessExitError.fromProcessExit(
baseError,
{
exitCode: result.code,
stderr: result.stderr,
stderrTruncated: result.stderrTruncated,
},
classifyNonZeroExit(input.command, result.stderr),
);
}

return {
Expand Down
49 changes: 48 additions & 1 deletion packages/contracts/src/vcs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as Schema from "effect/Schema";
import { TrimmedNonEmptyString } from "./baseSchemas.ts";
import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";

export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]);
export type VcsDriverKind = typeof VcsDriverKind.Type;
Expand Down Expand Up @@ -62,6 +62,7 @@ export interface VcsProcessErrorContext {
readonly operation: string;
readonly command: string;
readonly cwd: string;
readonly argumentCount?: number;
}

export interface VcsProcessSpawnFailure {
Expand All @@ -86,12 +87,26 @@ export interface VcsProcessTimeoutFailure {
readonly timeoutMs: number;
}

export const VcsProcessExitFailureKind = Schema.Literals([
"authentication",
"not-found",
"command-failed",
]);
export type VcsProcessExitFailureKind = typeof VcsProcessExitFailureKind.Type;

export interface VcsProcessExitFailure {
readonly exitCode: number;
readonly stderr: string;
readonly stderrTruncated: boolean;
}

export class VcsProcessSpawnError extends Schema.TaggedErrorClass<VcsProcessSpawnError>()(
"VcsProcessSpawnError",
{
operation: Schema.String,
command: Schema.String,
cwd: Schema.String,
argumentCount: Schema.optional(NonNegativeInt),
cause: Schema.Defect(),
},
) {
Expand All @@ -113,13 +128,43 @@ export class VcsProcessExitError extends Schema.TaggedErrorClass<VcsProcessExitE
operation: Schema.String,
command: Schema.String,
cwd: Schema.String,
argumentCount: Schema.optional(NonNegativeInt),
exitCode: Schema.Number,
detail: Schema.String,
failureKind: Schema.optional(VcsProcessExitFailureKind),
stderrLength: Schema.optional(NonNegativeInt),
stderrTruncated: Schema.optional(Schema.Boolean),
},
) {
override get message(): string {
return `VCS process failed in ${this.operation}: ${this.command} (${this.cwd}) exited with ${this.exitCode} - ${this.detail}`;
}

static fromProcessExit(
context: VcsProcessErrorContext,
error: VcsProcessExitFailure,
failureKind: VcsProcessExitFailureKind,
) {
const detail =
failureKind === "authentication"
? "Authentication failed."
: failureKind === "not-found"
? context.command === "glab"
? "Merge request not found."
: context.command === "gh" || context.command === "az"
? "Pull request not found."
: "VCS resource not found."
: "Process exited with a non-zero status.";

return new VcsProcessExitError({
...context,
exitCode: error.exitCode,
detail,
failureKind,
stderrLength: error.stderr.length,
stderrTruncated: error.stderrTruncated,
});
}
}

export class VcsProcessTimeoutError extends Schema.TaggedErrorClass<VcsProcessTimeoutError>()(
Expand All @@ -128,6 +173,7 @@ export class VcsProcessTimeoutError extends Schema.TaggedErrorClass<VcsProcessTi
operation: Schema.String,
command: Schema.String,
cwd: Schema.String,
argumentCount: Schema.optional(NonNegativeInt),
timeoutMs: Schema.Number,
},
) {
Expand All @@ -149,6 +195,7 @@ export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecod
operation: Schema.String,
command: Schema.String,
cwd: Schema.String,
argumentCount: Schema.optional(NonNegativeInt),
detail: Schema.String,
cause: Schema.optional(Schema.Defect()),
},
Expand Down
Loading