import * as DateTime from "effect/DateTime";import * as Effect from "effect/Effect";import * as Option from "effect/Option";import * as Result from "effect/Result";import { SourceControlProviderError, type ChangeRequest, type ChangeRequestState,} from "@t3tools/contracts"; import * as GitHubCli from "./GitHubCli.ts";import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts";import * as SourceControlProvider from "./SourceControlProvider.ts";import { combinedAuthOutput, firstSafeAuthLine, providerAuth, type SourceControlAuthProbeInput, type SourceControlCliDiscoverySpec,} from "./SourceControlProviderDiscovery.ts"; 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", ...(summary.isDraft === true ? { isDraft: true } : {}), closedAt: summary.closedAt ?? null, mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt === undefined ? Option.none() : Option.some(DateTime.makeUnsafe(summary.updatedAt)), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } : {}), ...(summary.headRepositoryNameWithOwner !== undefined ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } : {}), ...(summary.headRepositoryOwnerLogin !== undefined ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } : {}), };} function parseGitHubAuth(input: SourceControlAuthProbeInput) { const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts); const host = authenticatedAccount?.host; if (authenticatedAccount) { return providerAuth({ status: "authenticated", account: authenticatedAccount.account, host, }); } const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0]; if (authStatus.parsed) { return providerAuth({ status: "unauthenticated", host: failedAccount?.host, detail: failedAccount?.error ?? "Run `gh auth login` to authenticate GitHub CLI with an active account.", }); } // gh gained `auth status --json` in 2.81.0. Older versions reject the flag and exit // non-zero, which reads exactly like a signed-out CLI. Name the real problem instead. if (input.exitCode !== 0 && output.includes("unknown flag: --json")) { return providerAuth({ status: "unknown", detail: "GitHub CLI is too old to report sign-in status. Update `gh` to 2.81.0 or newer (for example `brew upgrade gh`) and rescan.", }); } if (input.exitCode !== 0) { return providerAuth({ status: "unauthenticated", host, detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", }); } return providerAuth({ status: "unknown", host, detail: 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 SourceControlCliDiscoverySpec; export const make = Effect.gen(function* () { const github = yield* GitHubCli.GitHubCli; const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["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) => new SourceControlProviderError({ provider: "github", operation: "listChangeRequests", command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, ), detail: error.detail, cause: 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,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }) .pipe( Effect.flatMap((result) => { const raw = result.stdout.trim(); if (raw.length === 0) { return Effect.succeed([]); } return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( Effect.flatMap((decoded) => Result.isSuccess(decoded) ? Effect.succeed( decoded.success.map((item) => { const { updatedAt, ...summary } = item; return { ...toChangeRequest({ ...summary, ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}), }), updatedAt, }; }), ) : Effect.fail( new GitHubCli.GitHubChangeRequestListDecodeError({ command: "gh", cwd: input.cwd, cause: decoded.failure, }), ), ), ); }), Effect.mapError( (error) => new SourceControlProviderError({ provider: "github", operation: "listChangeRequests", command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, ), detail: error.detail, cause: error, }), ), ); }; return SourceControlProvider.SourceControlProvider.of({ kind: "github", listChangeRequests, getChangeRequest: (input) => github.getPullRequest(input).pipe( Effect.map(toChangeRequest), Effect.mapError( (error) => new SourceControlProviderError({ provider: "github", operation: "getChangeRequest", command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.reference, ), detail: error.detail, cause: error, }), ), ), createChangeRequest: (input) => github .createPullRequest({ cwd: input.cwd, baseBranch: input.baseRefName, headSelector: input.headSelector, title: input.title, bodyFile: input.bodyFile, }) .pipe( Effect.mapError( (error) => new SourceControlProviderError({ provider: "github", operation: "createChangeRequest", command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, ), detail: error.detail, cause: error, }), ), ), getRepositoryCloneUrls: (input) => github.getRepositoryCloneUrls(input).pipe( Effect.mapError( (error) => new SourceControlProviderError({ provider: "github", operation: "getRepositoryCloneUrls", command: error.command, cwd: input.cwd, repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, ), detail: error.detail, cause: error, }), ), ), createRepository: (input) => github.createRepository(input).pipe( Effect.mapError( (error) => new SourceControlProviderError({ provider: "github", operation: "createRepository", command: error.command, cwd: input.cwd, repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, ), detail: error.detail, cause: error, }), ), ), getDefaultBranch: (input) => github.getDefaultBranch(input).pipe( Effect.mapError( (error) => new SourceControlProviderError({ provider: "github", operation: "getDefaultBranch", command: error.command, cwd: input.cwd, detail: error.detail, cause: error, }), ), ), checkoutChangeRequest: (input) => github.checkoutPullRequest(input).pipe( Effect.mapError( (error) => new SourceControlProviderError({ provider: "github", operation: "checkoutChangeRequest", command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.reference, ), detail: error.detail, cause: error, }), ), ), });});
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Result from "effect/Result";
import {
SourceControlProviderError,
type ChangeRequest,
type ChangeRequestState,
} from "@t3tools/contracts";
import * as GitHubCli from "./GitHubCli.ts";
import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts";
import * as SourceControlProvider from "./SourceControlProvider.ts";
import {
combinedAuthOutput,
firstSafeAuthLine,
providerAuth,
type SourceControlAuthProbeInput,
type SourceControlCliDiscoverySpec,
} from "./SourceControlProviderDiscovery.ts";
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",
...(summary.isDraft === true ? { isDraft: true } : {}),
closedAt: summary.closedAt ?? null,
mergedAt: summary.mergedAt ?? null,
updatedAt:
summary.updatedAt === undefined
? Option.none()
: Option.some(DateTime.makeUnsafe(summary.updatedAt)),
...(summary.isCrossRepository !== undefined
? { isCrossRepository: summary.isCrossRepository }
: {}),
...(summary.headRepositoryNameWithOwner !== undefined
? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
: {}),
...(summary.headRepositoryOwnerLogin !== undefined
? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
: {}),
};
}
function parseGitHubAuth(input: SourceControlAuthProbeInput) {
const output = combinedAuthOutput(input);
const authStatus = parseGitHubAuthStatus(input.stdout);
const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts);
const host = authenticatedAccount?.host;
if (authenticatedAccount) {
return providerAuth({
status: "authenticated",
account: authenticatedAccount.account,
host,
});
}
const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0];
if (authStatus.parsed) {
return providerAuth({
status: "unauthenticated",
host: failedAccount?.host,
detail:
failedAccount?.error ??
"Run `gh auth login` to authenticate GitHub CLI with an active account.",
});
}
// gh gained `auth status --json` in 2.81.0. Older versions reject the flag and exit
// non-zero, which reads exactly like a signed-out CLI. Name the real problem instead.
if (input.exitCode !== 0 && output.includes("unknown flag: --json")) {
return providerAuth({
status: "unknown",
detail:
"GitHub CLI is too old to report sign-in status. Update `gh` to 2.81.0 or newer (for example `brew upgrade gh`) and rescan.",
});
}
if (input.exitCode !== 0) {
return providerAuth({
status: "unauthenticated",
host,
detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.",
});
}
return providerAuth({
status: "unknown",
host,
detail: 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 SourceControlCliDiscoverySpec;
export const make = Effect.gen(function* () {
const github = yield* GitHubCli.GitHubCli;
const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["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) =>
new SourceControlProviderError({
provider: "github",
operation: "listChangeRequests",
command: error.command,
cwd: input.cwd,
reference: SourceControlProvider.transportSafeSourceControlErrorValue(
input.headSelector,
),
detail: error.detail,
cause: 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,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
],
})
.pipe(
Effect.flatMap((result) => {
const raw = result.stdout.trim();
if (raw.length === 0) {
return Effect.succeed([]);
}
return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
Effect.flatMap((decoded) =>
Result.isSuccess(decoded)
? Effect.succeed(
decoded.success.map((item) => {
const { updatedAt, ...summary } = item;
return {
...toChangeRequest({
...summary,
...(Option.isSome(updatedAt)
? { updatedAt: DateTime.formatIso(updatedAt.value) }
: {}),
}),
updatedAt,
};
}),
)
: Effect.fail(
new GitHubCli.GitHubChangeRequestListDecodeError({
command: "gh",
cwd: input.cwd,
cause: decoded.failure,
}),
),
),
);
}),
Effect.mapError(
(error) =>
new SourceControlProviderError({
provider: "github",
operation: "listChangeRequests",
command: error.command,
cwd: input.cwd,
reference: SourceControlProvider.transportSafeSourceControlErrorValue(
input.headSelector,
),
detail: error.detail,
cause: error,
}),
),
);
};
return SourceControlProvider.SourceControlProvider.of({
kind: "github",
listChangeRequests,
getChangeRequest: (input) =>
github.getPullRequest(input).pipe(
Effect.map(toChangeRequest),
Effect.mapError(
(error) =>
new SourceControlProviderError({
provider: "github",
operation: "getChangeRequest",
command: error.command,
cwd: input.cwd,
reference: SourceControlProvider.transportSafeSourceControlErrorValue(
input.reference,
),
detail: error.detail,
cause: error,
}),
),
),
createChangeRequest: (input) =>
github
.createPullRequest({
cwd: input.cwd,
baseBranch: input.baseRefName,
headSelector: input.headSelector,
title: input.title,
bodyFile: input.bodyFile,
})
.pipe(
Effect.mapError(
(error) =>
new SourceControlProviderError({
provider: "github",
operation: "createChangeRequest",
command: error.command,
cwd: input.cwd,
reference: SourceControlProvider.transportSafeSourceControlErrorValue(
input.headSelector,
),
detail: error.detail,
cause: error,
}),
),
),
getRepositoryCloneUrls: (input) =>
github.getRepositoryCloneUrls(input).pipe(
Effect.mapError(
(error) =>
new SourceControlProviderError({
provider: "github",
operation: "getRepositoryCloneUrls",
command: error.command,
cwd: input.cwd,
repository: SourceControlProvider.transportSafeSourceControlErrorValue(
input.repository,
),
detail: error.detail,
cause: error,
}),
),
),
createRepository: (input) =>
github.createRepository(input).pipe(
Effect.mapError(
(error) =>
new SourceControlProviderError({
provider: "github",
operation: "createRepository",
command: error.command,
cwd: input.cwd,
repository: SourceControlProvider.transportSafeSourceControlErrorValue(
input.repository,
),
detail: error.detail,
cause: error,
}),
),
),
getDefaultBranch: (input) =>
github.getDefaultBranch(input).pipe(
Effect.mapError(
(error) =>
new SourceControlProviderError({
provider: "github",
operation: "getDefaultBranch",
command: error.command,
cwd: input.cwd,
detail: error.detail,
cause: error,
}),
),
),
checkoutChangeRequest: (input) =>
github.checkoutPullRequest(input).pipe(
Effect.mapError(
(error) =>
new SourceControlProviderError({
provider: "github",
operation: "checkoutChangeRequest",
command: error.command,
cwd: input.cwd,
reference: SourceControlProvider.transportSafeSourceControlErrorValue(
input.reference,
),
detail: error.detail,
cause: error,
}),
),
),
});
});