import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { SourceControlProviderError, type ChangeRequest, type ChangeRequestState, } from "@t3tools/contracts"; import * as GitHubCli from "./GitHubCli.ts"; import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as GitHubPullRequests from "./gitHubPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; const isSourceControlProviderError = Schema.is(SourceControlProviderError); function providerError( operation: string, cause: GitHubCli.GitHubCliError, ): SourceControlProviderError { return new SourceControlProviderError({ provider: "github", operation, detail: cause.detail, cause, }); } function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { return { provider: "github", number: summary.number, title: summary.title, url: summary.url, baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", updatedAt: Option.none(), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } : {}), ...(summary.headRepositoryNameWithOwner !== undefined ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } : {}), ...(summary.headRepositoryOwnerLogin !== undefined ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } : {}), }; } function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { const output = SourceControlProviderDiscovery.combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts); const host = authenticatedAccount?.host; if (authenticatedAccount) { return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account: authenticatedAccount.account, host, }); } const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0]; if (authStatus.parsed) { return SourceControlProviderDiscovery.providerAuth({ status: "unauthenticated", host: failedAccount?.host, detail: failedAccount?.error ?? "Run `gh auth login` to authenticate GitHub CLI with an active account.", }); } if (input.exitCode !== 0) { return SourceControlProviderDiscovery.providerAuth({ status: "unauthenticated", host, detail: SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", }); } return SourceControlProviderDiscovery.providerAuth({ status: "unknown", host, detail: SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", }); } export const discovery = { type: "cli", kind: "github", label: "GitHub", executable: "gh", versionArgs: ["--version"], authArgs: ["auth", "status", "--json", "hosts"], parseAuth: parseGitHubAuth, installHint: "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", } satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { const github = yield* GitHubCli.GitHubCli; const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] = (input) => { if (input.state === "open") { return github .listOpenPullRequests({ cwd: input.cwd, headSelector: input.headSelector, ...(input.limit !== undefined ? { limit: input.limit } : {}), }) .pipe( Effect.map((items) => items.map(toChangeRequest)), Effect.mapError((error) => providerError("listChangeRequests", error)), ); } const stateArg: ChangeRequestState | "all" = input.state; return github .execute({ cwd: input.cwd, args: [ "pr", "list", "--head", input.headSelector, "--state", stateArg, "--limit", String(input.limit ?? 20), "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }) .pipe( Effect.flatMap((result) => { const raw = result.stdout.trim(); if (raw.length === 0) { return Effect.succeed([]); } return Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => Result.isSuccess(decoded) ? Effect.succeed( decoded.success.map((item) => ({ ...toChangeRequest(item), updatedAt: item.updatedAt, })), ) : Effect.fail( new SourceControlProviderError({ provider: "github", operation: "listChangeRequests", detail: "GitHub CLI returned invalid change request JSON.", cause: decoded.failure, }), ), ), ); }), Effect.mapError((error) => isSourceControlProviderError(error) ? error : providerError("listChangeRequests", error), ), ); }; return SourceControlProvider.SourceControlProvider.of({ kind: "github", listChangeRequests, getChangeRequest: (input) => github.getPullRequest(input).pipe( Effect.map(toChangeRequest), Effect.mapError((error) => providerError("getChangeRequest", error)), ), createChangeRequest: (input) => github .createPullRequest({ cwd: input.cwd, baseBranch: input.baseRefName, headSelector: input.headSelector, title: input.title, bodyFile: input.bodyFile, }) .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))), getRepositoryCloneUrls: (input) => github .getRepositoryCloneUrls(input) .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), createRepository: (input) => github .createRepository(input) .pipe(Effect.mapError((error) => providerError("createRepository", error))), getDefaultBranch: (input) => github .getDefaultBranch(input) .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => github .checkoutPullRequest(input) .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), }); }); export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make());