281 lines · 8.1 KB · TypeScript
RawDownload
import { Effect, Layer, Schema } from "effect";import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; import { runProcess } from "../../processRunner";import { GitHubCliError } from "@t3tools/contracts";import {  GitHubCli,  type GitHubRepositoryCloneUrls,  type GitHubCliShape,  type GitHubPullRequestSummary,} from "../Services/GitHubCli.ts"; const DEFAULT_TIMEOUT_MS = 30_000; function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown): GitHubCliError {  if (error instanceof Error) {    if (error.message.includes("Command not found: gh")) {      return new GitHubCliError({        operation,        detail: "GitHub CLI (`gh`) is required but not available on PATH.",        cause: error,      });    }     const lower = error.message.toLowerCase();    if (      lower.includes("authentication failed") ||      lower.includes("not logged in") ||      lower.includes("gh auth login") ||      lower.includes("no oauth token")    ) {      return new GitHubCliError({        operation,        detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",        cause: error,      });    }     if (      lower.includes("could not resolve to a pullrequest") ||      lower.includes("repository.pullrequest") ||      lower.includes("no pull requests found for branch") ||      lower.includes("pull request not found")    ) {      return new GitHubCliError({        operation,        detail: "Pull request not found. Check the PR number or URL and try again.",        cause: error,      });    }     return new GitHubCliError({      operation,      detail: `GitHub CLI command failed: ${error.message}`,      cause: error,    });  }   return new GitHubCliError({    operation,    detail: "GitHub CLI command failed.",    cause: error,  });} function normalizePullRequestState(input: {  state?: string | null | undefined;  mergedAt?: string | null | undefined;}): "open" | "closed" | "merged" {  const mergedAt = input.mergedAt;  const state = input.state;  if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") {    return "merged";  }  if (state === "CLOSED") {    return "closed";  }  return "open";} const RawGitHubPullRequestSchema = Schema.Struct({  number: PositiveInt,  title: TrimmedNonEmptyString,  url: TrimmedNonEmptyString,  baseRefName: TrimmedNonEmptyString,  headRefName: TrimmedNonEmptyString,  state: Schema.optional(Schema.NullOr(Schema.String)),  mergedAt: Schema.optional(Schema.NullOr(Schema.String)),  isCrossRepository: Schema.optional(Schema.Boolean),  headRepository: Schema.optional(    Schema.NullOr(      Schema.Struct({        nameWithOwner: Schema.String,      }),    ),  ),  headRepositoryOwner: Schema.optional(    Schema.NullOr(      Schema.Struct({        login: Schema.String,      }),    ),  ),}); const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({  nameWithOwner: TrimmedNonEmptyString,  url: TrimmedNonEmptyString,  sshUrl: TrimmedNonEmptyString,}); function normalizePullRequestSummary(  raw: Schema.Schema.Type<typeof RawGitHubPullRequestSchema>,): GitHubPullRequestSummary {  const headRepositoryNameWithOwner = raw.headRepository?.nameWithOwner ?? null;  const headRepositoryOwnerLogin =    raw.headRepositoryOwner?.login ??    (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/")      ? (headRepositoryNameWithOwner.split("/")[0] ?? null)      : null);  return {    number: raw.number,    title: raw.title,    url: raw.url,    baseRefName: raw.baseRefName,    headRefName: raw.headRefName,    state: normalizePullRequestState(raw),    ...(typeof raw.isCrossRepository === "boolean"      ? { isCrossRepository: raw.isCrossRepository }      : {}),    ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),    ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),  };} function normalizeRepositoryCloneUrls(  raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>,): GitHubRepositoryCloneUrls {  return {    nameWithOwner: raw.nameWithOwner,    url: raw.url,    sshUrl: raw.sshUrl,  };} function decodeGitHubJson<S extends Schema.Top>(  raw: string,  schema: S,  operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls",  invalidDetail: string,): Effect.Effect<S["Type"], GitHubCliError, S["DecodingServices"]> {  return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(    Effect.mapError(      (error) =>        new GitHubCliError({          operation,          detail: error instanceof Error ? `${invalidDetail}: ${error.message}` : invalidDetail,          cause: error,        }),    ),  );} const makeGitHubCli = Effect.sync(() => {  const execute: GitHubCliShape["execute"] = (input) =>    Effect.tryPromise({      try: () =>        runProcess("gh", input.args, {          cwd: input.cwd,          timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,          shell: false,        }),      catch: (error) => normalizeGitHubCliError("execute", error),    });   const service = {    execute,    listOpenPullRequests: (input) =>      execute({        cwd: input.cwd,        args: [          "pr",          "list",          "--head",          input.headSelector,          "--state",          "open",          "--limit",          String(input.limit ?? 1),          "--json",          "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",        ],      }).pipe(        Effect.map((result) => result.stdout.trim()),        Effect.flatMap((raw) =>          raw.length === 0            ? Effect.succeed([])            : decodeGitHubJson(                raw,                Schema.Array(RawGitHubPullRequestSchema),                "listOpenPullRequests",                "GitHub CLI returned invalid PR list JSON.",              ),        ),        Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)),      ),    getPullRequest: (input) =>      execute({        cwd: input.cwd,        args: [          "pr",          "view",          input.reference,          "--json",          "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",        ],      }).pipe(        Effect.map((result) => result.stdout.trim()),        Effect.flatMap((raw) =>          decodeGitHubJson(            raw,            RawGitHubPullRequestSchema,            "getPullRequest",            "GitHub CLI returned invalid pull request JSON.",          ),        ),        Effect.map(normalizePullRequestSummary),      ),    getRepositoryCloneUrls: (input) =>      execute({        cwd: input.cwd,        args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],      }).pipe(        Effect.map((result) => result.stdout.trim()),        Effect.flatMap((raw) =>          decodeGitHubJson(            raw,            RawGitHubRepositoryCloneUrlsSchema,            "getRepositoryCloneUrls",            "GitHub CLI returned invalid repository JSON.",          ),        ),        Effect.map(normalizeRepositoryCloneUrls),      ),    createPullRequest: (input) =>      execute({        cwd: input.cwd,        args: [          "pr",          "create",          "--base",          input.baseBranch,          "--head",          input.headSelector,          "--title",          input.title,          "--body-file",          input.bodyFile,        ],      }).pipe(Effect.asVoid),    getDefaultBranch: (input) =>      execute({        cwd: input.cwd,        args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"],      }).pipe(        Effect.map((value) => {          const trimmed = value.stdout.trim();          return trimmed.length > 0 ? trimmed : null;        }),      ),    checkoutPullRequest: (input) =>      execute({        cwd: input.cwd,        args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],      }).pipe(Effect.asVoid),  } satisfies GitHubCliShape;   return service;}); export const GitHubCliLive = Layer.effect(GitHubCli, makeGitHubCli);