172 lines · 5.4 KB · TypeScript
RawDownload
import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Match from "effect/Match";import { ChildProcessSpawner } from "effect/unstable/process"; import {  type VcsError,  VcsProcessExitError,  type VcsProcessExitFailureKind,  VcsProcessMissingExitCodeError,  VcsProcessOutputLimitError,  VcsProcessOutputReadError,  VcsProcessSpawnError,  VcsProcessStdinWriteError,  VcsProcessTimeoutError,} from "@t3tools/contracts";import * as ProcessRunner from "../processRunner.ts"; export interface VcsProcessInput {  readonly operation: string;  readonly command: string;  readonly args: ReadonlyArray<string>;  readonly cwd: string;  readonly spawnCwd?: string;  readonly stdin?: string;  readonly env?: NodeJS.ProcessEnv;  readonly allowNonZeroExit?: boolean;  readonly timeoutMs?: number;  readonly maxOutputBytes?: number;  readonly appendTruncationMarker?: boolean;} export interface VcsProcessOutput {  readonly exitCode: ChildProcessSpawner.ExitCode;  readonly stdout: string;  readonly stderr: string;  readonly stdoutTruncated: boolean;  readonly stderrTruncated: boolean;} export class VcsProcess extends Context.Service<  VcsProcess,  {    readonly run: (input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>;  }>()("t3/vcs/VcsProcess") {} const DEFAULT_TIMEOUT_MS = 30_000;const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; 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 baseError = {      operation: input.operation,      command: input.command,      cwd: input.cwd,      argumentCount: input.args.length,    };     const result = yield* processRunner      .run({        command: input.command,        args: input.args,        cwd: input.cwd,        ...(input.spawnCwd !== undefined ? { spawnCwd: input.spawnCwd } : {}),        ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),        ...(input.env !== undefined ? { env: input.env } : {}),        timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,        maxOutputBytes: input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,        outputMode: "truncate",        truncatedMarker: input.appendTruncationMarker ? OUTPUT_TRUNCATED_MARKER : "",        timeoutBehavior: "error",      })      .pipe(        Effect.mapError(          Match.valueTags({            ProcessSpawnError: (error) =>              VcsProcessSpawnError.fromProcessSpawnError(baseError, error),            ProcessOutputLimitError: (error) =>              new VcsProcessOutputLimitError({                ...baseError,                stream: error.stream,                maxBytes: error.maxBytes,                observedBytes: error.observedBytes,              }),            ProcessTimeoutError: (error) =>              VcsProcessTimeoutError.fromProcessTimeoutError(baseError, error),            ProcessStdinError: (error) =>              new VcsProcessStdinWriteError({                ...baseError,                stdinBytes: error.stdinBytes,                cause: error.cause,              }),            ProcessReadError: (error) =>              new VcsProcessOutputReadError({                ...baseError,                stream: error.stream,                cause: error.cause,              }),          }),        ),      );     if (result.code === null) {      return yield* new VcsProcessMissingExitCodeError(baseError);    }     if (!input.allowNonZeroExit && result.code !== 0) {      return yield* VcsProcessExitError.fromProcessExit(        baseError,        {          exitCode: result.code,          stderr: result.stderr,          stderrTruncated: result.stderrTruncated,        },        classifyNonZeroExit(input.command, result.stderr),      );    }     return {      exitCode: result.code,      stdout: result.stdout,      stderr: result.stderr,      stdoutTruncated: result.stdoutTruncated,      stderrTruncated: result.stderrTruncated,    } satisfies VcsProcessOutput;  });   return VcsProcess.of({ run });}); export const layer = Layer.effect(VcsProcess, make).pipe(Layer.provide(ProcessRunner.layer));