import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility, type VcsError, } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import { decodeGitHubPullRequestJson, decodeGitHubPullRequestListJson, type NormalizedGitHubPullRequestRecord, } from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const gitHubCliFailureFields = { command: Schema.Literal("gh"), cwd: Schema.String, cause: Schema.Defect(), } as const; export class GitHubCliUnavailableError extends Schema.TaggedError()( "GitHubCliUnavailableError", gitHubCliFailureFields, ) { get detail(): string { return "GitHub CLI (`gh`) is required but not available on PATH."; } override get message(): string { return `GitHub CLI failed in execute: ${this.detail}`; } } export class GitHubCliAuthenticationError extends Schema.TaggedError()( "GitHubCliAuthenticationError", gitHubCliFailureFields, ) { get detail(): string { return "GitHub CLI is not authenticated. Run `gh auth login` and retry."; } override get message(): string { return `GitHub CLI failed in execute: ${this.detail}`; } } export class GitHubCliRateLimitError extends Schema.TaggedError()( "GitHubCliRateLimitError", gitHubCliFailureFields, ) { get detail(): string { return "GitHub API rate limit exceeded. Run `gh api rate_limit` to inspect the quota and reset time."; } override get message(): string { return `GitHub CLI failed in execute: ${this.detail}`; } } export class GitHubPullRequestNotFoundError extends Schema.TaggedError()( "GitHubPullRequestNotFoundError", gitHubCliFailureFields, ) { get detail(): string { return "Pull request not found. Check the PR number or URL and try again."; } override get message(): string { return `GitHub CLI failed in execute: ${this.detail}`; } } export class GitHubCliCommandError extends Schema.TaggedError()( "GitHubCliCommandError", gitHubCliFailureFields, ) { get detail(): string { return "GitHub CLI command failed."; } override get message(): string { return `GitHub CLI failed in execute: ${this.detail}`; } } const gitHubCliDecodeFields = { command: Schema.Literal("gh"), cwd: Schema.String, cause: Schema.Defect(), } as const; export class GitHubPullRequestListDecodeError extends Schema.TaggedError()( "GitHubPullRequestListDecodeError", gitHubCliDecodeFields, ) { get detail(): string { return "GitHub CLI returned invalid PR list JSON."; } override get message(): string { return `GitHub CLI failed in listOpenPullRequests: ${this.detail}`; } } export class GitHubChangeRequestListDecodeError extends Schema.TaggedError()( "GitHubChangeRequestListDecodeError", gitHubCliDecodeFields, ) { get detail(): string { return "GitHub CLI returned invalid change request JSON."; } override get message(): string { return `GitHub CLI failed in listChangeRequests: ${this.detail}`; } } export class GitHubPullRequestDecodeError extends Schema.TaggedError()( "GitHubPullRequestDecodeError", gitHubCliDecodeFields, ) { get detail(): string { return "GitHub CLI returned invalid pull request JSON."; } override get message(): string { return `GitHub CLI failed in getPullRequest: ${this.detail}`; } } export class GitHubRepositoryDecodeError extends Schema.TaggedError()( "GitHubRepositoryDecodeError", gitHubCliDecodeFields, ) { get detail(): string { return "GitHub CLI returned invalid repository JSON."; } override get message(): string { return `GitHub CLI failed in getRepositoryCloneUrls: ${this.detail}`; } } export const GitHubCliError = Schema.Union([ GitHubCliUnavailableError, GitHubCliAuthenticationError, GitHubCliRateLimitError, GitHubPullRequestNotFoundError, GitHubCliCommandError, GitHubPullRequestListDecodeError, GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, GitHubRepositoryDecodeError, ]); export type GitHubCliError = typeof GitHubCliError.Type; export const isGitHubCliError = Schema.is(GitHubCliError); export function fromVcsError( context: { readonly command: "gh"; readonly cwd: string; }, error: VcsError, ): GitHubCliError { if ( error._tag === "VcsProcessSpawnError" && error.cause instanceof PlatformError.PlatformError && error.cause.reason._tag === "NotFound" && error.cause.reason.module === "ChildProcess" && error.cause.reason.method === "spawn" ) { return new GitHubCliUnavailableError({ ...context, cause: error }); } if (error._tag === "VcsProcessExitError") { if (error.failureKind === "authentication") { return new GitHubCliAuthenticationError({ ...context, cause: error }); } if (error.failureKind === "rate-limited") { return new GitHubCliRateLimitError({ ...context, cause: error }); } if (error.failureKind === "not-found") { return new GitHubPullRequestNotFoundError({ ...context, cause: error }); } } return new GitHubCliCommandError({ ...context, cause: error }); } export interface GitHubPullRequestSummary { readonly number: number; readonly title: string; readonly url: string; readonly baseRefName: string; readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; readonly isDraft?: boolean; readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt?: string; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; readonly headRepositoryOwnerLogin?: string | null; } function pullRequestSummary(input: NormalizedGitHubPullRequestRecord): GitHubPullRequestSummary { const { updatedAt, ...summary } = input; return { ...summary, ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}), }; } export interface GitHubRepositoryCloneUrls { readonly nameWithOwner: string; readonly url: string; readonly sshUrl: string; } export class GitHubCli extends Context.Service< GitHubCli, { readonly execute: (input: { readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; /** Piped to the child's stdin, for payloads that must never appear in argv. */ readonly stdin?: string; readonly maxOutputBytes?: number; }) => Effect.Effect; readonly listOpenPullRequests: (input: { readonly cwd: string; readonly headSelector: string; readonly limit?: number; }) => Effect.Effect, GitHubCliError>; readonly getPullRequest: (input: { readonly cwd: string; readonly reference: string; }) => Effect.Effect; readonly getRepositoryCloneUrls: (input: { readonly cwd: string; readonly repository: string; }) => Effect.Effect; readonly createRepository: (input: { readonly cwd: string; readonly repository: string; readonly visibility: SourceControlRepositoryVisibility; }) => Effect.Effect; readonly createPullRequest: (input: { readonly cwd: string; readonly baseBranch: string; readonly headSelector: string; readonly title: string; readonly bodyFile: string; }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; }) => Effect.Effect; readonly checkoutPullRequest: (input: { readonly cwd: string; readonly reference: string; readonly force?: boolean; }) => Effect.Effect; } >()("t3/sourceControl/GitHubCli") {} const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ nameWithOwner: TrimmedNonEmptyString, url: TrimmedNonEmptyString, sshUrl: TrimmedNonEmptyString, }); const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), ); function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitHubRepositoryCloneUrls { return { nameWithOwner: raw.nameWithOwner, url: raw.url, sshUrl: raw.sshUrl, }; } /** * `gh repo create` prints the canonical URL of the new repository on stdout * (e.g. `https://github.com/owner/repo`). Reading it back here avoids a * follow-up `gh repo view`, which can race GitHub's GraphQL eventual * consistency window and falsely report the just-created repo as missing. */ function deriveRepositoryCloneUrlsFromCreateOutput( stdout: string, repository: string, ): GitHubRepositoryCloneUrls { const fallbackHost = "github.com"; const match = stdout.match(/https?:\/\/[^\s]+/); if (match) { const cleaned = match[0].replace(/\.git$/, ""); try { const parsed = new URL(cleaned); const pathname = parsed.pathname.replace(/^\/+|\/+$/g, ""); const segments = pathname.split("/").filter(Boolean); if (segments.length === 2) { const nameWithOwner = `${segments[0]}/${segments[1]}`; return { nameWithOwner, url: `${parsed.origin}/${nameWithOwner}`, sshUrl: `git@${parsed.host}:${nameWithOwner}.git`, }; } } catch { // Fall through to the input-derived defaults below. } } return { nameWithOwner: repository, url: `https://${fallbackHost}/${repository}`, sshUrl: `git@${fallbackHost}:${repository}.git`, }; } /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; const execute: GitHubCli["Service"]["execute"] = (input) => process .run({ operation: "GitHubCli.execute", command: "gh", args: input.args, cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), }) .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); return GitHubCli.of({ 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,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new GitHubPullRequestListDecodeError({ command: "gh", cwd: input.cwd, cause: decoded.failure, }), ); } return Effect.succeed(decoded.success.map(pullRequestSummary)); }), ), ), ), getPullRequest: (input) => execute({ cwd: input.cwd, args: [ "pr", "view", input.reference, "--json", "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( new GitHubPullRequestDecodeError({ command: "gh", cwd: input.cwd, cause: decoded.failure, }), ); } return Effect.succeed(pullRequestSummary(decoded.success)); }), ), ), ), 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) => decodeRawGitHubRepositoryCloneUrls(raw).pipe( Effect.mapError( (cause) => new GitHubRepositoryDecodeError({ command: "gh", cwd: input.cwd, cause, }), ), ), ), Effect.map(normalizeRepositoryCloneUrls), ), createRepository: (input) => execute({ cwd: input.cwd, args: ["repo", "create", input.repository, `--${input.visibility}`], }).pipe( Effect.map((result) => deriveRepositoryCloneUrlsFromCreateOutput(result.stdout, input.repository), ), ), 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), }); }); export const layer = Layer.effect(GitHubCli, make);