diff --git a/apps/mobile/src/components/SourceControlIcon.tsx b/apps/mobile/src/components/SourceControlIcon.tsx index b1d4918037c..046bdfdce83 100644 --- a/apps/mobile/src/components/SourceControlIcon.tsx +++ b/apps/mobile/src/components/SourceControlIcon.tsx @@ -1,6 +1,11 @@ import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg"; -export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops"; +export type SourceControlIconKind = + | "github" + | "github-enterprise" + | "gitlab" + | "bitbucket" + | "azure-devops"; export function SourceControlIcon(props: { readonly kind: SourceControlIconKind; @@ -11,6 +16,7 @@ export function SourceControlIcon(props: { switch (props.kind) { case "github": + case "github-enterprise": return ( ) { const params = route.params ?? {}; const source = Array.isArray(params.source) ? params.source[0] : params.source; + const host = Array.isArray(params.host) ? params.host[0] : params.host; const title = source === "github" || + source === "github-enterprise" || source === "gitlab" || source === "bitbucket" || source === "azure-devops" - ? addProjectRemoteSourceLabel(source) + ? addProjectRemoteTargetLabel({ id: source, source, host: host ?? null }) : "Git URL"; return ( diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 39e6bda3c44..e88ba5ec5ec 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,8 +1,10 @@ import { - addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, addProjectRemoteSourceProvider, + addProjectRemoteTargetLabel, + addProjectRemoteTargetReadiness, buildAddProjectRemoteSourceReadiness, + buildAddProjectRemoteTargets, buildProjectCreateCommand, canCreateProjectInEnvironment, findExistingAddProject, @@ -10,6 +12,7 @@ import { resolveAddProjectPath, sortAddProjectProviderSources, type AddProjectRemoteSource, + type AddProjectRemoteTarget, } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText, @@ -97,6 +100,7 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote if ( source === "url" || source === "github" || + source === "github-enterprise" || source === "gitlab" || source === "bitbucket" || source === "azure-devops" @@ -368,7 +372,7 @@ function EmptyEnvironmentState() { } function SourceControlRow(props: { - readonly source: AddProjectRemoteSource; + readonly target: AddProjectRemoteTarget; readonly selectedEnvironmentId: EnvironmentId; readonly ready: boolean; readonly hint: string; @@ -376,17 +380,15 @@ function SourceControlRow(props: { }) { const navigation = useNavigation(); const iconColor = useThemeColor("--color-icon"); - const title = - props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`; + const label = addProjectRemoteTargetLabel(props.target); + const title = props.target.source === "url" ? "Git URL" : `${label} repository`; const subtitle = - props.source === "url" - ? "Clone from a remote URL" - : `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`; + props.target.source === "url" ? "Clone from a remote URL" : `Clone ${label} ${props.hint}`; const icon = - props.source === "url" ? ( + props.target.source === "url" ? ( ) : ( - + ); if (!props.ready) { @@ -406,7 +408,8 @@ function SourceControlRow(props: { screen: "AddProjectRepository", params: { environmentId: props.selectedEnvironmentId, - source: props.source, + source: props.target.source, + ...(props.target.host ? { host: props.target.host } : {}), }, }) } @@ -432,6 +435,10 @@ export function AddProjectSourceScreen() { () => buildAddProjectRemoteSourceReadiness(discoveryState.data), [discoveryState.data], ); + const targets = useMemo( + () => buildAddProjectRemoteTargets(discoveryState.data), + [discoveryState.data], + ); return ( @@ -506,22 +513,26 @@ export function AddProjectSourceScreen() { }) } /> - {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map( - (candidate) => ( + {[ + { id: "url", source: "url", host: null } as AddProjectRemoteTarget, + ...sortAddProjectProviderSources(readiness, targets), + ].map((target) => { + const targetReadiness = addProjectRemoteTargetReadiness(readiness, target.id); + return ( - ), - )} + ); + })} {discoveryState.isPending ? : null} @@ -594,6 +605,7 @@ function useEnvironmentFromParam( export function AddProjectRepositoryScreen(props: { readonly environmentId?: string | string[]; readonly source?: string | string[]; + readonly host?: string | string[]; }) { const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, @@ -601,6 +613,7 @@ export function AddProjectRepositoryScreen(props: { const navigation = useNavigation(); const environment = useEnvironmentFromParam(props.environmentId); const source = sourceFromParam(props.source); + const host = stringParam(props.host); const [repositoryInput, setRepositoryInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -630,6 +643,7 @@ export function AddProjectRepositoryScreen(props: { input: { provider, repository: repositoryInput.trim(), + ...(host ? { host } : {}), }, }); if (AsyncResult.isFailure(result)) { @@ -647,7 +661,7 @@ export function AddProjectRepositoryScreen(props: { }); } setIsSubmitting(false); - }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); + }, [environment, host, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); return ( diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 695c7b76f64..aab1d45e447 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -567,6 +567,14 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { cause: new Error(`Unexpected repository create: ${input.repository}`), }), ), + searchRepositories: (input) => + Effect.fail( + new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: input.cwd, + cause: new Error(`Unexpected repository search: ${input.query}`), + }), + ), checkoutPullRequest: (input) => execute({ cwd: input.cwd, @@ -617,6 +625,7 @@ function makeManager(input?: { textGeneration?: Partial; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; + providerKind?: "github" | "github-enterprise"; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); @@ -633,7 +642,7 @@ function makeManager(input?: { ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, - GitHubSourceControlProvider.make.pipe( + GitHubSourceControlProvider.makeProvider(input?.providerKind ?? "github").pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ get: () => Effect.succeed(provider), @@ -2712,6 +2721,68 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("feeds the pull request template to enterprise change requests too", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.mkdirSync(NodePath.join(repoDir, ".github")); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".github", "pull_request_template.md"), + "## What changed?\n\n## Verification", + ); + yield* runGit(repoDir, ["add", ".github/pull_request_template.md"]); + yield* runGit(repoDir, ["commit", "-m", "Add pull request template"]); + yield* runGit(repoDir, ["checkout", "-b", "feature-enterprise-template"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature-enterprise-template"]); + yield* runGit(repoDir, [ + "config", + "branch.feature-enterprise-template.gh-merge-base", + "main", + ]); + let generatedChangeRequestTemplate: string | undefined; + + const { manager } = yield* makeManager({ + providerKind: "github-enterprise", + textGeneration: { + generatePrContent: (input) => { + generatedChangeRequestTemplate = input.changeRequestTemplate; + return Effect.succeed({ + title: "Add stacked git actions", + body: "## What changed?\nAdded stacked git actions.", + }); + }, + }, + ghScenario: { + prListSequence: [ + "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 12, + title: "Add stacked git actions", + url: "https://git.corp.com/owner/repo/pull/12", + baseRefName: "main", + headRefName: "feature-enterprise-template", + }, + ]), + ], + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(generatedChangeRequestTemplate).toBe("## What changed?\n\n## Verification"); + }), + ); + it.effect("generates PR content against the remote base when the local base is stale", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index da002df5e6c..2c14cea44b1 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1597,7 +1597,8 @@ export const make = Effect.gen(function* () { const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef); const policy = yield* resolveStylePolicy(cwd, settings.style); const changeRequestTemplate = - settings.style.followChangeRequestTemplates && provider.kind === "github" + settings.style.followChangeRequestTemplates && + (provider.kind === "github" || provider.kind === "github-enterprise") ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute)) : undefined; diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5daf7676d60..4dce63b9506 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -344,6 +344,62 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("searches repositories and decodes full names", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { fullName: "Sollit/core-documentation" }, + { fullName: "Sollit/core" }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.searchRepositories({ + cwd: "/repo", + query: "core", + }); + + assert.deepStrictEqual(result, [ + { fullName: "Sollit/core-documentation" }, + { fullName: "Sollit/core" }, + ]); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["search", "repos", "core", "--limit", "20", "--json", "fullName"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("treats empty repository search output as no results", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.searchRepositories({ cwd: "/repo", query: "core" }); + + assert.deepStrictEqual(result, []); + }).pipe(Effect.provide(layer)), + ); + + it.effect("surfaces a decode error for invalid repository search JSON", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not json"))); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh.searchRepositories({ cwd: "/repo", query: "core" }).pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitHubRepositorySearchDecodeError"); + }).pipe(Effect.provide(layer)), + ); + it.effect("surfaces a friendly error when the pull request is not found", () => Effect.gen(function* () { const cause = new VcsProcessExitError({ @@ -374,3 +430,95 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); }); + +describe("GitHubCli host targeting", () => { + it.effect("sets GH_HOST when a host is supplied", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + nameWithOwner: "owner/repo", + url: "https://git.corp.com/owner/repo", + sshUrl: "git@git.corp.com:owner/repo.git", + }), + ), + ), + ); + + const cli = yield* GitHubCli.GitHubCli; + yield* cli.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "owner/repo", + host: "git.corp.com", + }); + + expect(mockRun.mock.calls[0]![0].env?.GH_HOST).toBe("git.corp.com"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("omits env entirely when no host is supplied", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + nameWithOwner: "owner/repo", + url: "https://github.com/owner/repo", + sshUrl: "git@github.com:owner/repo.git", + }), + ), + ), + ); + + const cli = yield* GitHubCli.GitHubCli; + yield* cli.getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }); + + expect(mockRun.mock.calls[0]![0]).not.toHaveProperty("env"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("sets GH_HOST when searching repositories with a host", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([{ fullName: "Sollit/core" }]), + ), + ), + ); + + const cli = yield* GitHubCli.GitHubCli; + yield* cli.searchRepositories({ + cwd: "/repo", + query: "core", + host: "sollit.ghe.com", + }); + + expect(mockRun.mock.calls[0]![0].env?.GH_HOST).toBe("sollit.ghe.com"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("derives enterprise clone urls when repo create prints no url", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const cli = yield* GitHubCli.GitHubCli; + const urls = yield* cli.createRepository({ + cwd: "/repo", + repository: "owner/repo", + visibility: "private", + host: "git.corp.com", + }); + + expect(urls).toEqual({ + nameWithOwner: "owner/repo", + url: "https://git.corp.com/owner/repo", + sshUrl: "git@git.corp.com:owner/repo.git", + }); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5..4eb7752066d 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -135,6 +135,19 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass()( + "GitHubRepositorySearchDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid repository search JSON."; + } + + override get message(): string { + return `GitHub CLI failed in searchRepositories: ${this.detail}`; + } +} + export const GitHubCliError = Schema.Union([ GitHubCliUnavailableError, GitHubCliAuthenticationError, @@ -144,6 +157,7 @@ export const GitHubCliError = Schema.Union([ GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, GitHubRepositoryDecodeError, + GitHubRepositorySearchDecodeError, ]); export type GitHubCliError = typeof GitHubCliError.Type; @@ -196,6 +210,10 @@ export interface GitHubRepositoryCloneUrls { readonly sshUrl: string; } +export interface GitHubRepositorySearchResult { + readonly fullName: string; +} + export class GitHubCli extends Context.Service< GitHubCli, { @@ -203,6 +221,7 @@ export class GitHubCli extends Context.Service< readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; + readonly host?: string; }) => Effect.Effect; readonly listOpenPullRequests: (input: { @@ -219,12 +238,21 @@ export class GitHubCli extends Context.Service< readonly getRepositoryCloneUrls: (input: { readonly cwd: string; readonly repository: string; + readonly host?: string; }) => Effect.Effect; + readonly searchRepositories: (input: { + readonly cwd: string; + readonly query: string; + readonly host?: string; + readonly limit?: number; + }) => Effect.Effect, GitHubCliError>; + readonly createRepository: (input: { readonly cwd: string; readonly repository: string; readonly visibility: SourceControlRepositoryVisibility; + readonly host?: string; }) => Effect.Effect; readonly createPullRequest: (input: { @@ -256,6 +284,15 @@ const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), ); +const RawGitHubRepositorySearchResultsSchema = Schema.Array( + Schema.Struct({ + fullName: TrimmedNonEmptyString, + }), +); +const decodeRawGitHubRepositorySearchResults = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubRepositorySearchResultsSchema), +); + function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitHubRepositoryCloneUrls { @@ -275,8 +312,8 @@ function normalizeRepositoryCloneUrls( function deriveRepositoryCloneUrlsFromCreateOutput( stdout: string, repository: string, + host: string = "github.com", ): GitHubRepositoryCloneUrls { - const fallbackHost = "github.com"; const match = stdout.match(/https?:\/\/[^\s]+/); if (match) { const cleaned = match[0].replace(/\.git$/, ""); @@ -298,8 +335,8 @@ function deriveRepositoryCloneUrlsFromCreateOutput( } return { nameWithOwner: repository, - url: `https://${fallbackHost}/${repository}`, - sshUrl: `git@${fallbackHost}:${repository}.git`, + url: `https://${host}/${repository}`, + sshUrl: `git@${host}:${repository}.git`, }; } @@ -313,6 +350,7 @@ export const make = Effect.gen(function* () { command: "gh", args: input.args, cwd: input.cwd, + ...(input.host ? { env: { ...globalThis.process.env, GH_HOST: input.host } } : {}), timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); @@ -394,6 +432,7 @@ export const make = Effect.gen(function* () { execute({ cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], + ...(input.host ? { host: input.host } : {}), }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => @@ -410,13 +449,44 @@ export const make = Effect.gen(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), + searchRepositories: (input) => + execute({ + cwd: input.cwd, + args: [ + "search", + "repos", + input.query, + "--limit", + String(input.limit ?? 20), + "--json", + "fullName", + ], + ...(input.host ? { host: input.host } : {}), + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : decodeRawGitHubRepositorySearchResults(raw).pipe( + Effect.mapError( + (cause) => + new GitHubRepositorySearchDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), + ), + ), + ), createRepository: (input) => execute({ cwd: input.cwd, args: ["repo", "create", input.repository, `--${input.visibility}`], + ...(input.host ? { host: input.host } : {}), }).pipe( Effect.map((result) => - deriveRepositoryCloneUrlsFromCreateOutput(result.stdout, input.repository), + deriveRepositoryCloneUrlsFromCreateOutput(result.stdout, input.repository, input.host), ), ), createPullRequest: (input) => diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 9e8a6829566..19039d16051 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -1,4 +1,4 @@ -import { assert, it } from "@effect/vitest"; +import { afterEach, assert, describe, expect, it, vi } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -30,6 +30,20 @@ function makeProvider(github: Partial) { ); } +const mockRun = vi.fn(); + +const cliLayer = GitHubCli.layer.pipe( + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: mockRun, + }), + ), +); + +afterEach(() => { + mockRun.mockReset(); +}); + it.effect("maps GitHub PR summaries into provider-neutral change requests", () => Effect.gen(function* () { const provider = yield* makeProvider({ @@ -381,3 +395,506 @@ it("reports unauthenticated when GitHub JSON has accounts but none are valid", ( }, ); }); + +const authStatusJson = ( + hosts: Record>, +) => + JSON.stringify({ + hosts: Object.fromEntries( + Object.entries(hosts).map(([host, accounts]) => [ + host, + accounts.map((account) => ({ ...account, host })), + ]), + ), + }); + +const probe = (stdout: string) => ({ + stdout, + stderr: "", + exitCode: ChildProcessSpawner.ExitCode(0), +}); + +describe("expandGitHubInstances", () => { + it("emits only a github row when no enterprise host is logged in", () => { + const instances = GitHubSourceControlProvider.expandGitHubInstances( + probe( + authStatusJson({ "github.com": [{ login: "octocat", state: "success", active: true }] }), + ), + ); + + expect(instances.map((instance) => instance.id)).toEqual(["github"]); + expect(instances[0]!.kind).toBe("github"); + expect(instances[0]!.auth.status).toBe("authenticated"); + }); + + it("emits one enterprise row per non-github.com host", () => { + const instances = GitHubSourceControlProvider.expandGitHubInstances( + probe( + authStatusJson({ + "github.com": [{ login: "octocat", state: "success", active: true }], + "git.corp.com": [{ login: "dev", state: "success", active: false }], + "acme.ghe.com": [{ login: "dev2", state: "success", active: false }], + }), + ), + ); + + expect(instances.map((instance) => instance.id)).toEqual([ + "github", + "github-enterprise:acme.ghe.com", + "github-enterprise:git.corp.com", + ]); + expect(instances[1]!.kind).toBe("github-enterprise"); + expect(instances[1]!.label).toBe("acme.ghe.com"); + expect(instances[1]!.host).toBe("acme.ghe.com"); + expect(Option.getOrNull(instances[1]!.auth.account)).toBe("dev2"); + }); + + it("collapses two authenticated accounts on the same enterprise host into one row", () => { + const instances = GitHubSourceControlProvider.expandGitHubInstances( + probe( + authStatusJson({ + "github.com": [{ login: "octocat", state: "success", active: true }], + "git.corp.com": [ + { login: "dev", state: "success", active: true }, + { login: "dev2", state: "success", active: false }, + ], + }), + ), + ); + + expect(instances.map((instance) => instance.id)).toEqual([ + "github", + "github-enterprise:git.corp.com", + ]); + expect(instances).toHaveLength(2); + }); + + it("still emits a github row when github.com is not logged in", () => { + const instances = GitHubSourceControlProvider.expandGitHubInstances( + probe(authStatusJson({ "git.corp.com": [{ login: "dev", state: "success", active: true }] })), + ); + + expect(instances[0]!.id).toBe("github"); + expect(instances[0]!.auth.status).toBe("unauthenticated"); + expect(instances).toHaveLength(2); + }); + + it("emits only a github row with unknown auth when output is unparseable", () => { + const instances = GitHubSourceControlProvider.expandGitHubInstances(probe("not json")); + + expect(instances).toHaveLength(1); + expect(instances[0]!.id).toBe("github"); + expect(instances[0]!.kind).toBe("github"); + expect(instances[0]!.host).toBe("github.com"); + expect(instances[0]!.auth.status).toBe("unknown"); + }); +}); + +describe("refineUnknownGitHubRemote", () => { + const context = { + provider: { kind: "unknown" as const, name: "git.corp.com", baseUrl: "https://git.corp.com" }, + remoteName: "origin", + remoteUrl: "https://git.corp.com/owner/repo.git", + }; + + it("claims a remote whose host is authenticated in gh", () => { + const refined = GitHubSourceControlProvider.refineUnknownGitHubRemote({ + cwd: "/repo", + context, + auth: probe( + authStatusJson({ "git.corp.com": [{ login: "dev", state: "success", active: true }] }), + ), + }); + + expect(refined).toEqual({ + kind: "github-enterprise", + name: "git.corp.com", + baseUrl: "https://git.corp.com", + }); + }); + + it("claims a remote whose host carries a non-default port", () => { + const refined = GitHubSourceControlProvider.refineUnknownGitHubRemote({ + cwd: "/repo", + context: { + provider: { + kind: "unknown" as const, + name: "git.corp.com:8443", + baseUrl: "https://git.corp.com:8443", + }, + remoteName: "origin", + remoteUrl: "https://git.corp.com:8443/owner/repo.git", + }, + auth: probe( + authStatusJson({ "git.corp.com": [{ login: "dev", state: "success", active: true }] }), + ), + }); + + expect(refined).toEqual({ + kind: "github-enterprise", + name: "git.corp.com", + baseUrl: "https://git.corp.com:8443", + }); + }); + + it("does not claim a host that failed authentication", () => { + expect( + GitHubSourceControlProvider.refineUnknownGitHubRemote({ + cwd: "/repo", + context, + auth: probe( + authStatusJson({ "git.corp.com": [{ login: "dev", state: "error", active: true }] }), + ), + }), + ).toBeNull(); + }); + + it("does not claim a host absent from gh auth status", () => { + expect( + GitHubSourceControlProvider.refineUnknownGitHubRemote({ + cwd: "/repo", + context, + auth: probe( + authStatusJson({ "github.com": [{ login: "octocat", state: "success", active: true }] }), + ), + }), + ).toBeNull(); + }); +}); + +function makeProviderOfKind( + kind: "github" | "github-enterprise", + github: Partial, +) { + return GitHubSourceControlProvider.makeProvider(kind).pipe( + Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)), + ); +} + +describe("getRepositoryCloneUrls bare name resolution", () => { + it.effect("resolves a bare enterprise name via search, preferring the exact-name match", () => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => + Effect.succeed([ + { fullName: "Sollit/core-documentation" }, + { fullName: "Sollit/core" }, + { fullName: "Sollit/frontend-core" }, + { fullName: "Sollit/portal-core-service" }, + ]), + ); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "Sollit/core", + url: "https://sollit.ghe.com/Sollit/core", + sshUrl: "git@sollit.ghe.com:Sollit/core.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { + searchRepositories, + getRepositoryCloneUrls, + }); + + const result = yield* provider.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "core", + host: "sollit.ghe.com", + }); + + expect(searchRepositories).toHaveBeenCalledWith({ + cwd: "/repo", + query: "core", + host: "sollit.ghe.com", + }); + expect(getRepositoryCloneUrls).toHaveBeenCalledWith({ + cwd: "/repo", + repository: "Sollit/core", + host: "sollit.ghe.com", + }); + assert.deepStrictEqual(result, { + nameWithOwner: "Sollit/core", + url: "https://sollit.ghe.com/Sollit/core", + sshUrl: "git@sollit.ghe.com:Sollit/core.git", + }); + }), + ); + + it.effect("resolves a sole near match when no bare name matches exactly", () => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => + Effect.succeed([{ fullName: "Sollit/widget-service" }]), + ); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "Sollit/widget-service", + url: "https://sollit.ghe.com/Sollit/widget-service", + sshUrl: "git@sollit.ghe.com:Sollit/widget-service.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { + searchRepositories, + getRepositoryCloneUrls, + }); + + yield* provider.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "widget", + host: "sollit.ghe.com", + }); + + expect(getRepositoryCloneUrls).toHaveBeenCalledWith({ + cwd: "/repo", + repository: "Sollit/widget-service", + host: "sollit.ghe.com", + }); + }), + ); + + it.effect("fails rather than rank near matches when none of them is exact", () => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => + Effect.succeed([{ fullName: "Sollit/widget-service" }, { fullName: "Sollit/mywidget" }]), + ); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "Sollit/widget-service", + url: "https://sollit.ghe.com/Sollit/widget-service", + sshUrl: "git@sollit.ghe.com:Sollit/widget-service.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { + searchRepositories, + getRepositoryCloneUrls, + }); + + const error = yield* provider + .getRepositoryCloneUrls({ + cwd: "/repo", + repository: "widget", + host: "sollit.ghe.com", + }) + .pipe(Effect.flip); + + expect(getRepositoryCloneUrls).not.toHaveBeenCalled(); + expect(error.detail).toContain("Sollit/widget-service"); + expect(error.detail).toContain("Sollit/mywidget"); + }), + ); + + it.effect("fails with a detail naming the query and host when search returns zero results", () => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => Effect.succeed([])); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "Sollit/typo-name", + url: "https://sollit.ghe.com/Sollit/typo-name", + sshUrl: "git@sollit.ghe.com:Sollit/typo-name.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { + searchRepositories, + getRepositoryCloneUrls, + }); + + const error = yield* provider + .getRepositoryCloneUrls({ + cwd: "/repo", + repository: "typo-name", + host: "sollit.ghe.com", + }) + .pipe(Effect.flip); + + expect(getRepositoryCloneUrls).not.toHaveBeenCalled(); + expect(error.detail).toContain("typo-name"); + expect(error.detail).toContain("sollit.ghe.com"); + }), + ); + + it.effect("fails naming every owner when several exact matches share the bare name", () => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => + Effect.succeed([ + { fullName: "team-a/core" }, + { fullName: "team-b/core" }, + { fullName: "team-c/core-docs" }, + ]), + ); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "team-a/core", + url: "https://sollit.ghe.com/team-a/core", + sshUrl: "git@sollit.ghe.com:team-a/core.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { + searchRepositories, + getRepositoryCloneUrls, + }); + + const error = yield* provider + .getRepositoryCloneUrls({ + cwd: "/repo", + repository: "core", + host: "sollit.ghe.com", + }) + .pipe(Effect.flip); + + expect(getRepositoryCloneUrls).not.toHaveBeenCalled(); + expect(error.detail).toContain("team-a/core"); + expect(error.detail).toContain("team-b/core"); + expect(error.detail).not.toContain("team-c/core-docs"); + }), + ); + + it.effect.each(["core", "Sollit/core"])( + "refuses %s on enterprise without a host rather than answering for github.com", + (repository) => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => Effect.succeed([{ fullName: "Sollit/core" }])); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "Sollit/core", + url: "https://sollit.ghe.com/Sollit/core", + sshUrl: "git@sollit.ghe.com:Sollit/core.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { + searchRepositories, + getRepositoryCloneUrls, + }); + + const error = yield* provider + .getRepositoryCloneUrls({ cwd: "/repo", repository }) + .pipe(Effect.flip); + + expect(searchRepositories).not.toHaveBeenCalled(); + expect(getRepositoryCloneUrls).not.toHaveBeenCalled(); + expect(error.detail).toContain("host"); + }), + ); + + it.effect("refuses to create an enterprise repository without a host", () => + Effect.gen(function* () { + const createRepository = vi.fn(() => + Effect.succeed({ + nameWithOwner: "Sollit/core", + url: "https://github.com/Sollit/core", + sshUrl: "git@github.com:Sollit/core.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { createRepository }); + + const error = yield* provider + .createRepository({ cwd: "/repo", repository: "Sollit/core", visibility: "private" }) + .pipe(Effect.flip); + + expect(createRepository).not.toHaveBeenCalled(); + expect(error.detail).toContain("host"); + }), + ); + + it.effect("never calls search for an owner/repo reference on enterprise", () => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => Effect.succeed([])); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "Sollit/core", + url: "https://sollit.ghe.com/Sollit/core", + sshUrl: "git@sollit.ghe.com:Sollit/core.git", + }), + ); + + const provider = yield* makeProviderOfKind("github-enterprise", { + searchRepositories, + getRepositoryCloneUrls, + }); + + yield* provider.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "Sollit/core", + host: "sollit.ghe.com", + }); + + expect(searchRepositories).not.toHaveBeenCalled(); + expect(getRepositoryCloneUrls).toHaveBeenCalledWith({ + cwd: "/repo", + repository: "Sollit/core", + host: "sollit.ghe.com", + }); + }), + ); + + it.effect("never calls search for a bare name on plain github.com", () => + Effect.gen(function* () { + const searchRepositories = vi.fn(() => Effect.succeed([])); + const getRepositoryCloneUrls = vi.fn(() => + Effect.succeed({ + nameWithOwner: "octocat/core", + url: "https://github.com/octocat/core", + sshUrl: "git@github.com:octocat/core.git", + }), + ); + + const provider = yield* makeProviderOfKind("github", { + searchRepositories, + getRepositoryCloneUrls, + }); + + const result = yield* provider.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "core", + }); + + expect(searchRepositories).not.toHaveBeenCalled(); + expect(getRepositoryCloneUrls).toHaveBeenCalledWith({ + cwd: "/repo", + repository: "core", + }); + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/core", + url: "https://github.com/octocat/core", + sshUrl: "git@github.com:octocat/core.git", + }); + }), + ); +}); + +describe("makeProvider", () => { + it.effect("tags change requests and errors with the enterprise kind", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processResult( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 7, + title: "Add widget", + url: "https://git.corp.com/owner/repo/pull/7", + baseRefName: "main", + headRefName: "feature", + state: "OPEN", + }, + ]), + ), + ), + ); + + const provider = yield* GitHubSourceControlProvider.makeProvider("github-enterprise"); + const requests = yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature", + state: "open", + }); + + expect(provider.kind).toBe("github-enterprise"); + expect(requests[0]!.provider).toBe("github-enterprise"); + }).pipe(Effect.provide(cliLayer)), + ); +}); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b5d5d3a55f8..7fefe2af437 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -18,11 +18,18 @@ import { providerAuth, type SourceControlAuthProbeInput, type SourceControlCliDiscoverySpec, + type SourceControlDiscoveryInstance, + type SourceControlUnknownRemoteRefinementInput, } from "./SourceControlProviderDiscovery.ts"; -function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { +type GitHubProviderKind = "github" | "github-enterprise"; + +function toChangeRequest( + kind: GitHubProviderKind, + summary: GitHubCli.GitHubPullRequestSummary, +): ChangeRequest { return { - provider: "github", + provider: kind, number: summary.number, title: summary.title, url: summary.url, @@ -82,6 +89,103 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) { }); } +function githubComAuth(status: ReturnType) { + const accounts = status.accounts.filter((account) => account.host === "github.com"); + const authenticated = findAuthenticatedGitHubAccount(accounts); + if (authenticated) { + return providerAuth({ + status: "authenticated", + account: authenticated.account, + host: "github.com", + }); + } + return providerAuth({ + status: "unauthenticated", + host: "github.com", + detail: + accounts[0]?.error ?? + "Run `gh auth login` to authenticate GitHub CLI with an active account.", + }); +} + +export function expandGitHubInstances( + input: SourceControlAuthProbeInput, +): ReadonlyArray { + const status = parseGitHubAuthStatus(input.stdout); + if (!status.parsed) { + return [ + { + kind: "github", + id: "github", + host: "github.com", + label: "GitHub", + auth: parseGitHubAuth(input), + }, + ]; + } + + const enterpriseHosts = [ + ...new Set( + status.accounts.map((account) => account.host).filter((host) => host !== "github.com"), + ), + ].sort(); + + return [ + { + kind: "github", + id: "github", + host: "github.com", + label: "GitHub", + auth: githubComAuth(status), + }, + ...enterpriseHosts.map((host) => { + const accounts = status.accounts.filter((account) => account.host === host); + const authenticated = findAuthenticatedGitHubAccount(accounts); + return { + kind: "github-enterprise" as const, + id: `github-enterprise:${host}`, + host, + label: host, + auth: authenticated + ? providerAuth({ status: "authenticated", account: authenticated.account, host }) + : providerAuth({ + status: "unauthenticated", + host, + detail: + accounts[0]?.error ?? `Run \`gh auth login --hostname ${host}\` to authenticate.`, + }), + }; + }), + ]; +} + +// An `unknown` remote's provider name is the raw host, port included, so both +// sides have to drop the port before they can be compared. +function toHostName(host: string): string { + try { + return new URL(`https://${host}`).hostname.toLowerCase(); + } catch { + return host.replace(/:\d+$/u, "").toLowerCase(); + } +} + +export function refineUnknownGitHubRemote(input: SourceControlUnknownRemoteRefinementInput) { + const host = toHostName(input.context.provider.name); + const authenticated = parseGitHubAuthStatus(input.auth.stdout).accounts.some( + (account) => toHostName(account.host) === host && account.authenticated, + ); + + if (!authenticated) { + return null; + } + + return { + kind: "github-enterprise", + name: host, + baseUrl: input.context.provider.baseUrl, + } as const; +} + export const discovery = { type: "cli", kind: "github", @@ -90,28 +194,216 @@ export const discovery = { versionArgs: ["--version"], authArgs: ["auth", "status", "--json", "hosts"], parseAuth: parseGitHubAuth, + expandInstances: expandGitHubInstances, + refineUnknownRemote: refineUnknownGitHubRemote, 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; +function isBareRepositoryName(repository: string): boolean { + return !repository.includes("/"); +} + +const AMBIGUOUS_REPOSITORY_CANDIDATE_LIMIT = 5; + +type RepositorySearchMatch = + | { readonly _tag: "match"; readonly fullName: string } + | { readonly _tag: "ambiguous"; readonly candidates: ReadonlyArray } + | { readonly _tag: "none" }; - const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = - (input) => { - if (input.state === "open") { +// Bare names resolve against the caller's personal namespace on `gh repo +// view`, which is usually empty on an enterprise host, so they go through +// search instead. An exact repo-name match wins outright; a sole near match +// still resolves, since that is the whole point of accepting a bare name. +// Anything else is a choice between repositories that search ranking is no +// basis for making, so report the candidates rather than guess. +function pickRepositorySearchMatch( + query: string, + results: ReadonlyArray, +): RepositorySearchMatch { + if (results.length === 0) { + return { _tag: "none" }; + } + + const normalizedQuery = query.toLowerCase(); + const exact = results.filter( + (result) => result.fullName.split("/").pop()?.toLowerCase() === normalizedQuery, + ); + if (exact.length === 1) { + return { _tag: "match", fullName: exact[0]!.fullName }; + } + const candidates = exact.length > 1 ? exact : results; + if (candidates.length > 1) { + return { _tag: "ambiguous", candidates: candidates.map((result) => result.fullName) }; + } + return { _tag: "match", fullName: candidates[0]!.fullName }; +} + +export const makeProvider = (kind: GitHubProviderKind) => + Effect.gen(function* () { + const github = yield* GitHubCli.GitHubCli; + + // Every repo-less `gh` call falls back to github.com without `GH_HOST`, so + // an enterprise operation missing its host does not fail — it quietly + // answers for public GitHub. The repository service already refuses this, + // but the provider is reachable on its own. + const ensureEnterpriseHost = (input: { + readonly cwd: string; + readonly repository: string; + readonly host?: string; + readonly operation: string; + }): Effect.Effect => + kind === "github-enterprise" && !input.host + ? Effect.fail( + new SourceControlProviderError({ + provider: kind, + operation: input.operation, + command: "gh", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: "Choose a GitHub Enterprise host before continuing.", + }), + ) + : Effect.void; + + const resolveRepositoryReference = (input: { + readonly cwd: string; + readonly repository: string; + readonly host?: string; + }): Effect.Effect => { + const repository = input.repository.trim(); + if (kind !== "github-enterprise" || !isBareRepositoryName(repository)) { + return Effect.succeed(repository); + } + + return github + .searchRepositories({ + cwd: input.cwd, + query: repository, + ...(input.host ? { host: input.host } : {}), + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: kind, + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue(repository), + detail: error.detail, + cause: error, + }), + ), + Effect.flatMap((results) => { + const match = pickRepositorySearchMatch(repository, results); + if (match._tag === "match") { + return Effect.succeed(match.fullName); + } + + const safeRepository = + SourceControlProvider.transportSafeSourceControlErrorValue(repository); + const hostLabel = input.host + ? SourceControlProvider.transportSafeSourceControlErrorValue(input.host) + : "the configured host"; + return Effect.fail( + new SourceControlProviderError({ + provider: kind, + operation: "getRepositoryCloneUrls", + command: "gh", + cwd: input.cwd, + repository: safeRepository, + detail: + match._tag === "ambiguous" + ? `More than one repository on ${hostLabel} matches "${safeRepository}": ${match.candidates + .slice(0, AMBIGUOUS_REPOSITORY_CANDIDATE_LIMIT) + .map((candidate) => + SourceControlProvider.transportSafeSourceControlErrorValue(candidate), + ) + .join(", ")}. Enter the full owner/repo path.` + : `No repository named "${safeRepository}" was found on ${hostLabel}.`, + }), + ); + }), + ); + }; + + 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((summary) => toChangeRequest(kind, summary))), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: kind, + 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 - .listOpenPullRequests({ + .execute({ cwd: input.cwd, - headSelector: input.headSelector, - ...(input.limit !== undefined ? { limit: input.limit } : {}), + 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.map((items) => items.map(toChangeRequest)), + 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) => ({ + ...toChangeRequest(kind, item), + updatedAt: item.updatedAt, + })), + ) + : Effect.fail( + new GitHubCli.GitHubChangeRequestListDecodeError({ + command: "gh", + cwd: input.cwd, + cause: decoded.failure, + }), + ), + ), + ); + }), Effect.mapError( (error) => new SourceControlProviderError({ - provider: "github", + provider: kind, operation: "listChangeRequests", command: error.command, cwd: input.cwd, @@ -123,179 +415,144 @@ export const make = Effect.gen(function* () { }), ), ); - } + }; - 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(() => decodeGitHubPullRequestListJson(raw)).pipe( - Effect.flatMap((decoded) => - Result.isSuccess(decoded) - ? Effect.succeed( - decoded.success.map((item) => ({ - ...toChangeRequest(item), - updatedAt: item.updatedAt, - })), - ) - : Effect.fail( - new GitHubCli.GitHubChangeRequestListDecodeError({ - command: "gh", - cwd: input.cwd, - cause: decoded.failure, - }), - ), - ), - ); - }), + return SourceControlProvider.SourceControlProvider.of({ + kind, + listChangeRequests, + getChangeRequest: (input) => + github.getPullRequest(input).pipe( + Effect.map((summary) => toChangeRequest(kind, summary)), Effect.mapError( (error) => new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", + provider: kind, + operation: "getChangeRequest", command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, + input.reference, ), 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, + ), + 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: kind, + operation: "createChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ), + getRepositoryCloneUrls: (input) => + ensureEnterpriseHost({ ...input, operation: "getRepositoryCloneUrls" }).pipe( + Effect.andThen(() => resolveRepositoryReference(input)), + Effect.flatMap((repository) => + github + .getRepositoryCloneUrls({ + cwd: input.cwd, + repository, + ...(input.host ? { host: input.host } : {}), + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: kind, + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), ), - 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( + createRepository: (input) => + ensureEnterpriseHost({ ...input, operation: "createRepository" }).pipe( + Effect.andThen(() => + github + .createRepository({ + cwd: input.cwd, + repository: input.repository, + visibility: input.visibility, + ...(input.host ? { host: input.host } : {}), + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: kind, + 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: "createChangeRequest", + provider: kind, + 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: kind, + operation: "checkoutChangeRequest", command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, + input.reference, ), 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, - }), - ), - ), + }); }); -}); + +export const make = makeProvider("github"); export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 9e4702af04c..61ed5c66786 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -166,6 +166,24 @@ it.effect("reports implemented tools separately from locally available executabl const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); assert.ok(bitbucket); assert.strictEqual(bitbucket.executable, undefined); + + // github is "available", so expandGitHubInstances stamps the github.com host on it; + // gitlab/azure-devops are "missing" (no executable) and take the defaulting path, + // which stamps id = the spec's own kind and an absent host. + const github = result.sourceControlProviders.find((item) => item.kind === "github"); + assert.ok(github); + assert.strictEqual(github.id, "github"); + assert.deepStrictEqual(github.host, Option.some("github.com")); + + const gitlab = result.sourceControlProviders.find((item) => item.kind === "gitlab"); + assert.ok(gitlab); + assert.strictEqual(gitlab.id, "gitlab"); + assert.deepStrictEqual(gitlab.host, Option.none()); + + const azureDevOps = result.sourceControlProviders.find((item) => item.kind === "azure-devops"); + assert.ok(azureDevOps); + assert.strictEqual(azureDevOps.id, "azure-devops"); + assert.deepStrictEqual(azureDevOps.host, Option.none()); }).pipe(Effect.provide(testLayer)); }); @@ -281,3 +299,79 @@ Logged in to gitlab.com as gitlab-user ); }).pipe(Effect.provide(testLayer)); }); + +it.effect("defaults identity fields when the auth probe itself fails", () => { + const processMock = { + run: (input: VcsProcess.VcsProcessInput) => { + if (input.args[0] === "--version") { + return Effect.succeed(processOutput(`${input.command} version test\n`)); + } + if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error("gh auth status crashed"), + }), + ); + } + if (input.command === "glab" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`gitlab.com\nLogged in to gitlab.com as gitlab-user\n`), + ); + } + if ( + input.command === "az" && + input.args.join(" ") === "account show --query user.name -o tsv" + ) { + return Effect.succeed(processOutput("azure-user@example.com\n")); + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ); + }, + } satisfies Partial; + const testLayer = SourceControlDiscovery.layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-auth-error-discovery-", + }), + ), + Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), + Layer.provide( + sourceControlProviderRegistryTestLayer({ + process: processMock, + bitbucket: { + probeAuth: Effect.succeed({ + status: "authenticated", + account: Option.some("bitbucket-user"), + host: Option.some("bitbucket.org"), + detail: Option.none(), + }), + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; + const result = yield* discovery.discover; + + // gh's --version succeeds ("available") but its auth probe throws, so this + // exercises the Effect.catch defaulting path distinct from the missing-executable + // and non-expanding-success paths covered by the tests above. + const github = result.sourceControlProviders.find((item) => item.kind === "github"); + assert.ok(github); + assert.strictEqual(github.status, "available"); + assert.strictEqual(github.auth.status, "unknown"); + assert.strictEqual(github.id, "github"); + assert.deepStrictEqual(github.host, Option.none()); + }).pipe(Effect.provide(testLayer)); +}); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 5f93dbcaa42..72339491890 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -110,11 +110,13 @@ export class SourceControlProvider extends Context.Service< readonly cwd: string; readonly context?: SourceControlProviderContext; readonly repository: string; + readonly host?: string; }) => Effect.Effect; readonly createRepository: (input: { readonly cwd: string; readonly repository: string; readonly visibility: SourceControlRepositoryVisibility; + readonly host?: string; }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index e3a6bd1fb20..48c8fceffd7 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -28,6 +28,14 @@ interface SourceControlDiscoverySpecBase { readonly installHint: string; } +export interface SourceControlDiscoveryInstance { + readonly kind: SourceControlProviderKind; + readonly id: string; + readonly host: string | null; + readonly label: string; + readonly auth: SourceControlProviderAuth; +} + export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly type: "cli"; readonly executable: string; @@ -37,6 +45,9 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly refineUnknownRemote?: ( input: SourceControlUnknownRemoteRefinementInput, ) => SourceControlProviderInfo | null; + readonly expandInstances?: ( + input: SourceControlAuthProbeInput, + ) => ReadonlyArray; }; export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { @@ -204,21 +215,22 @@ export function probeSourceControlProvider(input: { readonly spec: SourceControlProviderDiscoverySpec; readonly process: VcsProcess.VcsProcess["Service"]; readonly cwd: string; -}): Effect.Effect { +}): Effect.Effect> { if (input.spec.type === "api") { return input.spec.probeAuth.pipe( - Effect.map( - (auth) => - ({ - kind: input.spec.kind, - label: input.spec.label, - status: "available" as const, - version: Option.none(), - installHint: input.spec.installHint, - detail: Option.none(), - auth, - }) satisfies SourceControlProviderDiscoveryItem, - ), + Effect.map((auth) => [ + { + kind: input.spec.kind, + id: input.spec.kind, + host: Option.none(), + label: input.spec.label, + status: "available" as const, + version: Option.none(), + installHint: input.spec.installHint, + detail: Option.none(), + auth, + } satisfies SourceControlProviderDiscoveryItem, + ]), ); } @@ -231,10 +243,14 @@ export function probeSourceControlProvider(input: { }).pipe( Effect.flatMap((item) => { if (item.status !== "available") { - return Effect.succeed({ - ...item, - auth: unknownAuth("Hosting integration command was not found on the server PATH."), - } satisfies SourceControlProviderDiscoveryItem); + return Effect.succeed([ + { + ...item, + id: spec.kind, + host: Option.none(), + auth: unknownAuth("Hosting integration command was not found on the server PATH."), + } satisfies SourceControlProviderDiscoveryItem, + ]); } return input.process @@ -249,18 +265,40 @@ export function probeSourceControlProvider(input: { appendTruncationMarker: true, }) .pipe( - Effect.map( - (result) => - ({ + Effect.map((result) => { + const instances = spec.expandInstances?.(result); + if (instances) { + return instances.map( + (instance) => + ({ + ...item, + kind: instance.kind, + id: instance.id, + host: + instance.host === null ? Option.none() : Option.some(instance.host), + label: instance.label, + auth: instance.auth, + }) satisfies SourceControlProviderDiscoveryItem, + ); + } + return [ + { ...item, + id: spec.kind, + host: Option.none(), auth: spec.parseAuth(result), - }) satisfies SourceControlProviderDiscoveryItem, - ), + } satisfies SourceControlProviderDiscoveryItem, + ]; + }), Effect.catch((cause) => - Effect.succeed({ - ...item, - auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), - } satisfies SourceControlProviderDiscoveryItem), + Effect.succeed([ + { + ...item, + id: spec.kind, + host: Option.none(), + auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), + } satisfies SourceControlProviderDiscoveryItem, + ]), ), ); }), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 5c4d27e46f9..639715b650f 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -1,10 +1,11 @@ -import { assert, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; 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 { ChildProcessSpawner } from "effect/unstable/process"; +import type { SourceControlProviderKind } from "@t3tools/contracts"; import { VcsRepositoryDetectionError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; @@ -15,8 +16,25 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; import * as GitLabCli from "./GitLabCli.ts"; +import { + providerAuth, + type SourceControlCliDiscoverySpec, +} from "./SourceControlProviderDiscovery.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +const stubProvider = (kind: SourceControlProviderKind) => + SourceControlProvider.SourceControlProvider.of({ + kind, + listChangeRequests: () => Effect.succeed([]), + getChangeRequest: () => Effect.die("unused"), + createChangeRequest: () => Effect.die("unused"), + getRepositoryCloneUrls: () => Effect.die("unused"), + createRepository: () => Effect.die("unused"), + getDefaultBranch: () => Effect.succeed(null), + checkoutChangeRequest: () => Effect.die("unused"), + }); + const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); const processOutput = ( @@ -261,3 +279,60 @@ it.effect("falls back to a non-origin remote when origin is not configured", () assert.strictEqual(provider.kind, "azure-devops"); }), ); + +describe("SourceControlProviderRegistry.discover", () => { + it.effect("flattens a spec that expands into multiple instances", () => + Effect.gen(function* () { + const spec = { + type: "cli", + kind: "github", + label: "GitHub", + executable: "gh", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: () => providerAuth({ status: "unknown" }), + expandInstances: () => [ + { + kind: "github" as const, + id: "github", + host: "github.com", + label: "GitHub", + auth: providerAuth({ status: "authenticated", account: "octocat", host: "github.com" }), + }, + { + kind: "github-enterprise" as const, + id: "github-enterprise:git.corp.com", + host: "git.corp.com", + label: "git.corp.com", + auth: providerAuth({ status: "authenticated", account: "dev", host: "git.corp.com" }), + }, + ], + installHint: "Install gh.", + } satisfies SourceControlCliDiscoverySpec; + + const registry = yield* SourceControlProviderRegistry.makeWithProviders([ + { kind: "github", provider: stubProvider("github"), discovery: spec }, + { kind: "github-enterprise", provider: stubProvider("github-enterprise") }, + ]).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), + Layer.mock(VcsProcess.VcsProcess)({ + run: () => Effect.succeed(processOutput("")), + }), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-registry-expand-test-", + }).pipe(Layer.provide(NodeServices.layer)), + ), + ), + ); + + const items = yield* registry.discover; + + expect(items.map((item) => item.id)).toEqual(["github", "github-enterprise:git.corp.com"]); + expect(items.map((item) => item.kind)).toEqual(["github", "github-enterprise"]); + expect(Option.getOrNull(items[1]!.host)).toBe("git.corp.com"); + expect(items[1]!.label).toBe("git.corp.com"); + }), + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index fb70d677e43..72ddec09d7f 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -31,7 +31,7 @@ const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); export interface SourceControlProviderRegistration { readonly kind: SourceControlProviderKind; readonly provider: SourceControlProvider.SourceControlProvider["Service"]; - readonly discovery: SourceControlProviderDiscoverySpec; + readonly discovery?: SourceControlProviderDiscoverySpec; } export interface SourceControlProviderHandle { @@ -202,7 +202,9 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit SourceControlProviderKind, SourceControlProvider.SourceControlProvider["Service"] >(registrations.map((registration) => [registration.kind, registration.provider])); - const discoverySpecs = registrations.map((registration) => registration.discovery); + const discoverySpecs = registrations.flatMap((registration) => + registration.discovery ? [registration.discovery] : [], + ); const get: SourceControlProviderRegistry["Service"]["get"] = (kind) => Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); @@ -278,13 +280,14 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit }), ), { concurrency: "unbounded" }, - ), + ).pipe(Effect.map((results) => results.flat())), }); }, ); export const make = Effect.gen(function* () { - const github = yield* GitHubSourceControlProvider.make; + const github = yield* GitHubSourceControlProvider.makeProvider("github"); + const githubEnterprise = yield* GitHubSourceControlProvider.makeProvider("github-enterprise"); const gitlab = yield* GitLabSourceControlProvider.make; const bitbucket = yield* BitbucketSourceControlProvider.make; const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; @@ -295,6 +298,11 @@ export const make = Effect.gen(function* () { provider: github, discovery: GitHubSourceControlProvider.discovery, }, + { + // Rows come from the `gh` spec's expandInstances; no spec of its own. + kind: "github-enterprise", + provider: githubEnterprise, + }, { kind: "gitlab", provider: gitlab, diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e0..afb075e0d0e 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -1,6 +1,6 @@ import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { afterEach, assert, describe, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -21,6 +21,14 @@ const CLONE_URLS = { sshUrl: "git@github.com:octocat/t3code.git", }; +const getRepositoryCloneUrls = vi.fn< + SourceControlProvider.SourceControlProvider["Service"]["getRepositoryCloneUrls"] +>(() => Effect.succeed(CLONE_URLS)); + +afterEach(() => { + getRepositoryCloneUrls.mockClear(); +}); + function makeProvider( overrides: Partial = {}, ): SourceControlProvider.SourceControlProvider["Service"] { @@ -35,7 +43,7 @@ function makeProvider( listChangeRequests: () => unsupported("listChangeRequests"), getChangeRequest: () => unsupported("getChangeRequest"), createChangeRequest: () => unsupported("createChangeRequest"), - getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS), + getRepositoryCloneUrls, createRepository: () => Effect.succeed(CLONE_URLS), getDefaultBranch: () => Effect.succeed(null), checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), @@ -397,3 +405,29 @@ it.effect("publish succeeds with status remote_added when the local repo has no ), ); }); + +describe("github-enterprise host requirement", () => { + it.effect("rejects an enterprise lookup with no host", () => + Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* service + .lookupRepository({ provider: "github-enterprise", repository: "owner/repo" }) + .pipe(Effect.flip); + + expect(error.detail).toBe("Choose a GitHub Enterprise host before continuing."); + }).pipe(Effect.provide(makeLayer({}))), + ); + + it.effect("forwards the host to the provider", () => + Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + yield* service.lookupRepository({ + provider: "github-enterprise", + repository: "owner/repo", + host: "git.corp.com", + }); + + expect(getRepositoryCloneUrls.mock.calls[0]![0].host).toBe("git.corp.com"); + }).pipe(Effect.provide(makeLayer({}))), + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1b46369e25c..f44a653b697 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -97,18 +97,29 @@ export const make = Effect.gen(function* () { const ensureConcreteProvider = (input: { readonly operation: string; readonly provider: SourceControlProviderKind; + readonly host?: string | undefined; }) => { - if (input.provider !== "unknown") { - return Effect.succeed(input.provider); + if (input.provider === "unknown") { + return Effect.fail( + new SourceControlRepositoryError({ + operation: input.operation, + provider: input.provider, + detail: "Choose a source control provider before continuing.", + }), + ); } - return Effect.fail( - new SourceControlRepositoryError({ - operation: input.operation, - provider: input.provider, - detail: "Choose a source control provider before continuing.", - }), - ); + if (input.provider === "github-enterprise" && !input.host?.trim()) { + return Effect.fail( + new SourceControlRepositoryError({ + operation: input.operation, + provider: input.provider, + detail: "Choose a GitHub Enterprise host before continuing.", + }), + ); + } + + return Effect.succeed(input.provider); }; const lookupRepository = Effect.fn("SourceControlRepositoryService.lookupRepository")(function* ( @@ -117,11 +128,13 @@ export const make = Effect.gen(function* () { const providerKind = yield* ensureConcreteProvider({ operation: "lookupRepository", provider: input.provider, + host: input.host, }); const provider = yield* providers.get(providerKind); const urls = yield* provider.getRepositoryCloneUrls({ cwd: input.cwd ?? config.cwd, repository: input.repository.trim(), + ...(input.host ? { host: input.host } : {}), }); return toRepositoryInfo(providerKind, urls); }); @@ -190,6 +203,7 @@ export const make = Effect.gen(function* () { provider: input.provider, repository: input.repository, cwd: preparedDestination.parentPath, + ...(input.host ? { host: input.host } : {}), }); remoteUrl = selectRemoteUrl(repository, input.protocol); provider = input.provider; @@ -223,12 +237,14 @@ export const make = Effect.gen(function* () { const providerKind = yield* ensureConcreteProvider({ operation: "publishRepository", provider: input.provider, + host: input.host, }); const provider = yield* providers.get(providerKind); const urls = yield* provider.createRepository({ cwd: input.cwd, repository: input.repository.trim(), visibility: input.visibility, + ...(input.host ? { host: input.host } : {}), }); const remoteUrl = selectRemoteUrl(urls, input.protocol); const remoteName = yield* git.ensureRemote({ diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 60493063664..7dea4a447ab 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,7 +1,19 @@ "use client"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; +import { + addProjectRemoteSourcePathHint, + addProjectRemoteTargetLabel, + addProjectRemoteTargetReadiness, + buildAddProjectRemoteSourceReadiness, + buildAddProjectRemoteTargets, + canCreateProjectInEnvironment, + sortAddProjectProviderSources, + type AddProjectRemoteProviderKind, + type AddProjectRemoteSource, + type AddProjectRemoteSourceReadiness, + type AddProjectRemoteTarget, +} from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { @@ -20,13 +32,10 @@ import { type EnvironmentId, type FilesystemBrowseResult, type ProjectId, - type SourceControlDiscoveryResult, - type SourceControlProviderKind, type SourceControlRepositoryInfo, PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; -import * as Option from "effect/Option"; import { ArrowLeftIcon, CornerLeftUpIcon, @@ -183,71 +192,23 @@ interface AddProjectEnvironmentOption { readonly status: string; } -type AddProjectRemoteProviderKind = Extract< - SourceControlProviderKind, - "github" | "gitlab" | "bitbucket" | "azure-devops" ->; -type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; - type AddProjectCloneFlow = | { readonly step: "repository"; readonly environmentId: EnvironmentId; readonly source: AddProjectRemoteSource; + readonly host: string | null; } | { readonly step: "confirm"; readonly environmentId: EnvironmentId; readonly source: AddProjectRemoteSource; + readonly host: string | null; readonly repositoryInput: string; readonly repository: SourceControlRepositoryInfo | null; readonly remoteUrl: string; }; -const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ - "url", - "github", - "gitlab", - "bitbucket", - "azure-devops", -]; -const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray = [ - "github", - "gitlab", - "bitbucket", - "azure-devops", -]; - -function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { - switch (source) { - case "github": - return "GitHub"; - case "gitlab": - return "GitLab"; - case "bitbucket": - return "Bitbucket"; - case "azure-devops": - return "Azure DevOps"; - case "url": - return "Git URL"; - } -} - -function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string { - switch (source) { - case "github": - return "owner/repo"; - case "gitlab": - return "group/project"; - case "bitbucket": - return "workspace/repository"; - case "azure-devops": - return "project/repository"; - case "url": - return "URL"; - } -} - function remoteProjectSourceProvider( source: AddProjectRemoteSource, ): AddProjectRemoteProviderKind | null { @@ -257,6 +218,7 @@ function remoteProjectSourceProvider( function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode { switch (source) { case "github": + case "github-enterprise": return ; case "gitlab": return ; @@ -275,80 +237,12 @@ function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string if (flow.source === "url") { return "Enter Git clone URL"; } - return `Enter ${remoteProjectSourceLabel(flow.source)} repository (${remoteProjectSourcePathHint(flow.source)})`; -} - -function sourceProviderKind(source: AddProjectRemoteSource): AddProjectRemoteProviderKind | null { - return source === "url" ? null : source; -} - -function sortAddProjectProviderSources( - readinessBySource: AddProjectRemoteSourceReadiness, -): ReadonlyArray { - return REMOTE_PROJECT_PROVIDER_SOURCES.toSorted((left, right) => { - const leftReady = readinessBySource[left].ready; - const rightReady = readinessBySource[right].ready; - if (leftReady !== rightReady) { - return leftReady ? -1 : 1; - } - return remoteProjectSourceLabel(left).localeCompare(remoteProjectSourceLabel(right)); + const label = addProjectRemoteTargetLabel({ + id: flow.source, + source: flow.source, + host: flow.host, }); -} - -type AddProjectRemoteSourceReadiness = Record< - AddProjectRemoteSource, - { readonly ready: boolean; readonly hint: string | null } ->; - -function buildAddProjectRemoteSourceReadiness( - discovery: SourceControlDiscoveryResult | null, -): AddProjectRemoteSourceReadiness { - const unavailable = { - ready: false, - hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", - } as const; - const defaultReadiness: AddProjectRemoteSourceReadiness = { - url: { ready: true, hint: null }, - github: unavailable, - gitlab: unavailable, - bitbucket: unavailable, - "azure-devops": unavailable, - }; - - if (!discovery) { - return defaultReadiness; - } - - const providerByKind = new Map( - discovery.sourceControlProviders.map((provider) => [provider.kind, provider]), - ); - const readiness = { ...defaultReadiness }; - - for (const source of REMOTE_PROJECT_SOURCES) { - const kind = sourceProviderKind(source); - if (!kind) continue; - const provider = providerByKind.get(kind); - if (!provider) { - readiness[source] = unavailable; - continue; - } - if (provider.status !== "available") { - readiness[source] = { ready: false, hint: provider.installHint }; - continue; - } - if (provider.auth.status === "unauthenticated") { - readiness[source] = { - ready: false, - hint: - Option.getOrNull(provider.auth.detail) ?? - `${provider.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, - }; - continue; - } - readiness[source] = { ready: true, hint: null }; - } - - return readiness; + return `Enter ${label} repository (${addProjectRemoteSourcePathHint(flow.source)})`; } function errorMessage(error: unknown): string { @@ -1096,9 +990,9 @@ function OpenCommandPaletteDialog(props: { ); const startAddProjectClone = useCallback( - (environmentId: EnvironmentId, source: AddProjectRemoteSource): void => { + (environmentId: EnvironmentId, source: AddProjectRemoteSource, host: string | null): void => { setAddProjectEnvironmentId(environmentId); - setAddProjectCloneFlow({ step: "repository", environmentId, source }); + setAddProjectCloneFlow({ step: "repository", environmentId, source, host }); pushPaletteView({ addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), groups: [], @@ -1116,6 +1010,7 @@ function OpenCommandPaletteDialog(props: { const buildAddProjectSourceGroups = useCallback( ( environmentId: EnvironmentId, + targets: ReadonlyArray, readinessBySource: AddProjectRemoteSourceReadiness, ): CommandPaletteView["groups"] => { const sourceItems: Array = [ @@ -1133,19 +1028,24 @@ function OpenCommandPaletteDialog(props: { }, ]; - const orderedSources: ReadonlyArray = [ - "url", - ...sortAddProjectProviderSources(readinessBySource), + const urlTarget = targets.find((target) => target.source === "url") ?? { + id: "url", + source: "url" as const, + host: null, + }; + const orderedTargets: ReadonlyArray = [ + urlTarget, + ...sortAddProjectProviderSources(readinessBySource, targets), ]; - for (const source of orderedSources) { - const label = remoteProjectSourceLabel(source); - const title = source === "url" ? "Git URL" : `${label} repository`; + for (const target of orderedTargets) { + const label = addProjectRemoteTargetLabel(target); + const title = target.source === "url" ? "Git URL" : `${label} repository`; const description = - source === "url" + target.source === "url" ? "Clone from a remote URL" - : `Clone ${label} ${remoteProjectSourcePathHint(source)}`; - const readiness = readinessBySource[source]; + : `Clone ${label} ${addProjectRemoteSourcePathHint(target.source)}`; + const readiness = addProjectRemoteTargetReadiness(readinessBySource, target.id); const disabledHint = readiness.hint; const titleTrailingContent = readiness.ready ? undefined : ( @@ -1175,12 +1075,12 @@ function OpenCommandPaletteDialog(props: { if (!readiness.ready) { sourceItems.push({ kind: "action", - value: `action:add-project:${environmentId}:${source}:not-ready`, + value: `action:add-project:${environmentId}:${target.id}:not-ready`, searchTerms: ["clone", "remote", "repository", "repo", "git", label, "setup required"], title, description, disabled: true, - icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), + icon: remoteProjectSourceIcon(target.source, ITEM_ICON_CLASS), ...(titleTrailingContent ? { titleTrailingContent } : {}), run: async () => {}, }); @@ -1189,15 +1089,15 @@ function OpenCommandPaletteDialog(props: { sourceItems.push({ kind: "action", - value: `action:add-project:${environmentId}:${source}`, + value: `action:add-project:${environmentId}:${target.id}`, searchTerms: ["clone", "remote", "repository", "repo", "git", label], title, description, - icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), + icon: remoteProjectSourceIcon(target.source, ITEM_ICON_CLASS), ...(titleTrailingContent ? { titleTrailingContent } : {}), keepOpen: true, run: async () => { - startAddProjectClone(environmentId, source); + startAddProjectClone(environmentId, target.source, target.host); }, }); } @@ -1224,13 +1124,14 @@ function OpenCommandPaletteDialog(props: { } setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow(null); + const discoveryForEnvironment = + browseEnvironmentId === environmentId ? sourceControlDiscovery.data : null; pushPaletteView({ addonIcon: , groups: buildAddProjectSourceGroups( environmentId, - buildAddProjectRemoteSourceReadiness( - browseEnvironmentId === environmentId ? sourceControlDiscovery.data : null, - ), + buildAddProjectRemoteTargets(discoveryForEnvironment), + buildAddProjectRemoteSourceReadiness(discoveryForEnvironment), ), }); }, @@ -1483,6 +1384,7 @@ function OpenCommandPaletteDialog(props: { currentView.groups[0]?.value === sourceSelectionViewValue ? buildAddProjectSourceGroups( addProjectEnvironmentId, + buildAddProjectRemoteTargets(sourceControlDiscovery.data), buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data), ) : (currentView?.groups ?? rootGroups); @@ -1691,6 +1593,7 @@ function OpenCommandPaletteDialog(props: { step: "confirm", environmentId: addProjectCloneFlow.environmentId, source: addProjectCloneFlow.source, + host: addProjectCloneFlow.host, repositoryInput: rawRepository, repository: null, remoteUrl: rawRepository, @@ -1707,6 +1610,7 @@ function OpenCommandPaletteDialog(props: { input: { provider, repository: rawRepository, + ...(addProjectCloneFlow.host ? { host: addProjectCloneFlow.host } : {}), }, }); setIsRemoteProjectLookingUp(false); @@ -1728,6 +1632,7 @@ function OpenCommandPaletteDialog(props: { step: "confirm", environmentId: addProjectCloneFlow.environmentId, source: addProjectCloneFlow.source, + host: addProjectCloneFlow.host, repositoryInput: rawRepository, repository, remoteUrl: repository.sshUrl, diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f302e976ca7..9cc5452dbbe 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1,13 +1,16 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; +import type { SourceControlProviderDiscoveryItem, VcsStatusResult } from "@t3tools/contracts"; +import * as Option from "effect/Option"; import { assert, describe, it } from "vite-plus/test"; import { buildGitActionProgressStages, buildMenuItems, + getPublishProviderReadiness, requiresDefaultBranchConfirmation, resolveAutoFeatureBranchName, resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, resolveQuickAction, + resolveSelectedEnterpriseHost, resolveThreadBranchUpdate, resolveThreadBranchMetadataPatch, } from "./GitActionsControl.logic"; @@ -1153,3 +1156,248 @@ describe("resolveAutoFeatureBranchName", () => { assert.equal(ref, "feature/update"); }); }); + +function discoveryItem(overrides: { + readonly kind: SourceControlProviderDiscoveryItem["kind"]; + readonly id: string; + readonly host?: string; + readonly label?: string; + readonly status?: SourceControlProviderDiscoveryItem["status"]; + readonly authStatus?: SourceControlProviderDiscoveryItem["auth"]["status"]; + readonly authDetail?: string; +}): SourceControlProviderDiscoveryItem { + return { + kind: overrides.kind, + id: overrides.id, + host: overrides.host ? Option.some(overrides.host) : Option.none(), + label: overrides.label ?? overrides.kind, + status: overrides.status ?? "available", + version: Option.none(), + installHint: "Install the CLI.", + detail: Option.none(), + auth: { + status: overrides.authStatus ?? "authenticated", + account: Option.none(), + host: overrides.host ? Option.some(overrides.host) : Option.none(), + detail: overrides.authDetail ? Option.some(overrides.authDetail) : Option.none(), + }, + }; +} + +describe("getPublishProviderReadiness", () => { + const staticProviders = [ + discoveryItem({ kind: "github", id: "github", host: "github.com", label: "GitHub" }), + discoveryItem({ kind: "gitlab", id: "gitlab", host: "gitlab.com", label: "GitLab" }), + discoveryItem({ + kind: "bitbucket", + id: "bitbucket", + host: "bitbucket.org", + label: "Bitbucket", + status: "missing", + }), + discoveryItem({ + kind: "azure-devops", + id: "azure-devops", + host: "dev.azure.com", + label: "Azure DevOps", + authStatus: "unauthenticated", + authDetail: "Run az login.", + }), + ]; + + it("resolves the four static providers from their own rows", () => { + assert.deepEqual( + getPublishProviderReadiness({ + provider: "github", + sourceControlProviders: staticProviders, + }), + { ready: true, hint: null }, + ); + assert.deepEqual( + getPublishProviderReadiness({ + provider: "gitlab", + sourceControlProviders: staticProviders, + }), + { ready: true, hint: null }, + ); + assert.deepEqual( + getPublishProviderReadiness({ + provider: "bitbucket", + sourceControlProviders: staticProviders, + }), + { ready: false, hint: "Install the CLI." }, + ); + assert.deepEqual( + getPublishProviderReadiness({ + provider: "azure-devops", + sourceControlProviders: staticProviders, + }), + { ready: false, hint: "Run az login." }, + ); + }); + + it("reports an unavailable provider when discovery has no matching row", () => { + assert.deepEqual( + getPublishProviderReadiness({ provider: "github", sourceControlProviders: [] }), + { + ready: false, + hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", + }, + ); + }); + + it("reports an unauthenticated enterprise host as not ready", () => { + const readiness = getPublishProviderReadiness({ + provider: "github-enterprise", + host: "git.corp.com", + sourceControlProviders: [ + discoveryItem({ kind: "github", id: "github", host: "github.com", label: "GitHub" }), + discoveryItem({ + kind: "github-enterprise", + id: "github-enterprise:git.corp.com", + host: "git.corp.com", + label: "git.corp.com", + authStatus: "unauthenticated", + authDetail: "Run `gh auth login --hostname git.corp.com` to authenticate.", + }), + ], + }); + + assert.deepEqual(readiness, { + ready: false, + hint: "Run `gh auth login --hostname git.corp.com` to authenticate.", + }); + }); + + it("answers per enterprise host rather than per kind", () => { + const providers = [ + discoveryItem({ + kind: "github-enterprise", + id: "github-enterprise:acme.ghe.com", + host: "acme.ghe.com", + label: "acme.ghe.com", + authStatus: "unauthenticated", + authDetail: "Run `gh auth login --hostname acme.ghe.com` to authenticate.", + }), + discoveryItem({ + kind: "github-enterprise", + id: "github-enterprise:git.corp.com", + host: "git.corp.com", + label: "git.corp.com", + }), + ]; + + assert.deepEqual( + getPublishProviderReadiness({ + provider: "github-enterprise", + host: "git.corp.com", + sourceControlProviders: providers, + }), + { ready: true, hint: null }, + ); + assert.equal( + getPublishProviderReadiness({ + provider: "github-enterprise", + host: "acme.ghe.com", + sourceControlProviders: providers, + }).ready, + false, + ); + }); + + it("falls back to the generic hint when an enterprise row omits a detail", () => { + assert.deepEqual( + getPublishProviderReadiness({ + provider: "github-enterprise", + host: "git.corp.com", + sourceControlProviders: [ + discoveryItem({ + kind: "github-enterprise", + id: "github-enterprise:git.corp.com", + host: "git.corp.com", + label: "git.corp.com", + authStatus: "unauthenticated", + }), + ], + }), + { + ready: false, + hint: "git.corp.com is not authenticated. Open Settings -> Source Control for setup guidance.", + }, + ); + }); +}); + +describe("resolveSelectedEnterpriseHost", () => { + it("keeps the selected host when it is still available", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: "git.corp.com", + availableHosts: ["acme.ghe.com", "git.corp.com"], + readyHosts: ["acme.ghe.com", "git.corp.com"], + }); + assert.equal(host, "git.corp.com"); + }); + + it("defaults to the first available host when nothing is selected yet", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: null, + availableHosts: ["acme.ghe.com", "git.corp.com"], + readyHosts: ["acme.ghe.com", "git.corp.com"], + }); + assert.equal(host, "acme.ghe.com"); + }); + + it("gives up a selected host that lost authentication while another is ready", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: "acme.ghe.com", + availableHosts: ["acme.ghe.com", "git.corp.com"], + readyHosts: ["git.corp.com"], + }); + assert.equal(host, "git.corp.com"); + }); + + it("keeps a selected host that lost authentication when no host is ready", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: "git.corp.com", + availableHosts: ["acme.ghe.com", "git.corp.com"], + readyHosts: [], + }); + assert.equal(host, "git.corp.com"); + }); + + it("skips an unauthenticated first host in favour of a ready one", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: null, + availableHosts: ["acme.ghe.com", "git.corp.com"], + readyHosts: ["git.corp.com"], + }); + assert.equal(host, "git.corp.com"); + }); + + it("falls back to the first available host when none are ready", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: null, + availableHosts: ["acme.ghe.com", "git.corp.com"], + readyHosts: [], + }); + assert.equal(host, "acme.ghe.com"); + }); + + it("falls back to the first available host when the selected host disappears", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: "git.corp.com", + availableHosts: ["acme.ghe.com"], + readyHosts: ["acme.ghe.com"], + }); + assert.equal(host, "acme.ghe.com"); + }); + + it("returns null when no hosts are available", () => { + const host = resolveSelectedEnterpriseHost({ + selectedHost: "git.corp.com", + availableHosts: [], + readyHosts: [], + }); + assert.equal(host, null); + }); +}); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 96f7af794ac..7f7b3a1c9bb 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -1,9 +1,12 @@ import type { GitRunStackedActionResult, GitStackedAction, + SourceControlProviderDiscoveryItem, + SourceControlProviderKind, VcsStatusResult, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; +import * as Option from "effect/Option"; import { DEFAULT_CHANGE_REQUEST_TERMINOLOGY, getChangeRequestTerminology, @@ -413,5 +416,70 @@ export function resolveLiveThreadBranchUpdate(input: { }; } +export interface PublishProviderReadiness { + readonly ready: boolean; + readonly hint: string | null; +} + +/** + * Resolves publish readiness from the discovery row backing a provider card. + * `host` disambiguates kinds that discovery reports once per host, so an + * enterprise card answers for its own connection rather than any enterprise one. + */ +export function getPublishProviderReadiness(input: { + provider: SourceControlProviderKind; + host?: string | null; + sourceControlProviders: ReadonlyArray; +}): PublishProviderReadiness { + const discovered = input.sourceControlProviders.find( + (provider) => + provider.kind === input.provider && + (input.host == null || Option.getOrNull(provider.host) === input.host), + ); + if (!discovered) { + return { + ready: false, + hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", + }; + } + if (discovered.status !== "available") { + return { ready: false, hint: discovered.installHint }; + } + if (discovered.auth.status === "unauthenticated") { + return { + ready: false, + hint: + Option.getOrNull(discovered.auth.detail) ?? + `${discovered.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, + }; + } + return { ready: true, hint: null }; +} + +/** + * Defaulting to the first host alphabetically strands the user when that host + * is the unauthenticated one: its card renders as "Setup Required" rather than + * a radio, and the host picker only exists once the card is selected. Prefer a + * ready host so there is always a way in. + */ +export function resolveSelectedEnterpriseHost(input: { + selectedHost: string | null; + availableHosts: ReadonlyArray; + readyHosts: ReadonlyArray; +}): string | null { + const firstReadyHost = input.availableHosts.find((host) => input.readyHosts.includes(host)); + // A host selected while it was authenticated can lose that state on a later + // discovery pass. Holding onto it strands the user the same way an + // unauthenticated default does, so give it up while another host still works. + const keepsSelection = + input.selectedHost !== null && + input.availableHosts.includes(input.selectedHost) && + (input.readyHosts.includes(input.selectedHost) || firstReadyHost === undefined); + if (keepsSelection) { + return input.selectedHost; + } + return firstReadyHost ?? input.availableHosts[0] ?? null; +} + // Re-export from shared for backwards compatibility in this module's exports export { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 20e5c349790..68f2d4d71f4 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -9,7 +9,6 @@ import type { GitRunStackedActionResult, GitStackedAction, SourceControlCloneProtocol, - SourceControlProviderDiscoveryItem, SourceControlProviderKind, SourceControlPublishRepositoryResult, SourceControlRepositoryVisibility, @@ -42,9 +41,11 @@ import { type GitActionMenuItem, type GitQuickAction, type DefaultBranchConfirmableAction, + getPublishProviderReadiness, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, + resolveSelectedEnterpriseHost, resolveThreadBranchMetadataPatch, resolveQuickAction, resolveThreadBranchUpdate, @@ -108,7 +109,7 @@ interface PendingDefaultBranchAction { type PublishProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "bitbucket" | "azure-devops" | "github-enterprise" >; type GitActionToastId = ReturnType; @@ -155,8 +156,19 @@ function requestVcsStatusRefresh( } const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; +interface PublishProviderOption { + readonly id: string; + readonly value: PublishProviderKind; + readonly label: string; + readonly description: string; + readonly host: string; + readonly pathPlaceholder: string; + readonly Icon: typeof GitHubIcon; +} + const PUBLISH_PROVIDER_OPTIONS = [ { + id: "github", value: "github", label: "GitHub", description: "github.com", @@ -165,6 +177,7 @@ const PUBLISH_PROVIDER_OPTIONS = [ Icon: GitHubIcon, }, { + id: "gitlab", value: "gitlab", label: "GitLab", description: "gitlab.com", @@ -173,6 +186,7 @@ const PUBLISH_PROVIDER_OPTIONS = [ Icon: GitLabIcon, }, { + id: "bitbucket", value: "bitbucket", label: "Bitbucket", description: "bitbucket.org", @@ -181,6 +195,7 @@ const PUBLISH_PROVIDER_OPTIONS = [ Icon: BitbucketIcon, }, { + id: "azure-devops", value: "azure-devops", label: "Azure DevOps", description: "dev.azure.com", @@ -188,55 +203,16 @@ const PUBLISH_PROVIDER_OPTIONS = [ pathPlaceholder: "project/repository", Icon: AzureDevOpsIcon, }, -] as const satisfies ReadonlyArray<{ - readonly value: PublishProviderKind; - readonly label: string; - readonly description: string; - readonly host: string; - readonly pathPlaceholder: string; - readonly Icon: typeof GitHubIcon; -}>; +] as const satisfies ReadonlyArray; -function publishProviderOption(provider: PublishProviderKind) { - return ( - PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ?? - PUBLISH_PROVIDER_OPTIONS[0] - ); -} +type StaticPublishProviderKind = Exclude; function isPublishProviderKind( provider: SourceControlProviderKind, -): provider is PublishProviderKind { +): provider is StaticPublishProviderKind { return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider); } -function getPublishProviderReadiness(input: { - provider: PublishProviderKind; - sourceControlProviders: ReadonlyArray; -}): { readonly ready: boolean; readonly hint: string | null } { - const discovered = input.sourceControlProviders.find( - (provider) => provider.kind === input.provider, - ); - if (!discovered) { - return { - ready: false, - hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", - }; - } - if (discovered.status !== "available") { - return { ready: false, hint: discovered.installHint }; - } - if (discovered.auth.status === "unauthenticated") { - return { - ready: false, - hint: - Option.getOrNull(discovered.auth.detail) ?? - `${discovered.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, - }; - } - return { ready: true, hint: null }; -} - function formatElapsedDescription(startedAtMs: number | null): string | undefined { if (startedAtMs === null) { return undefined; @@ -381,8 +357,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { input: {}, }), ); - const [selectedPublishProvider, setSelectedPublishProvider] = - useState(null); + const [selectedPublishProviderId, setSelectedPublishProviderId] = useState(null); const [publishRepositoryOverride, setPublishRepositoryOverride] = useState(null); const [publishVisibility, setPublishVisibility] = useState("private"); @@ -402,8 +377,67 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { [props.environmentId, props.gitCwd], ); const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope); + const [selectedEnterpriseHost, setSelectedEnterpriseHost] = useState(null); + const enterpriseHosts = useMemo(() => { + const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; + return sourceControlProviders + .filter((item) => item.kind === "github-enterprise" && item.status === "available") + .flatMap((item) => { + const host = Option.getOrNull(item.host); + return host ? [host] : []; + }) + .toSorted((left, right) => left.localeCompare(right)); + }, [sourceControlDiscovery.data]); + // Computed straight from discovery rather than from the readiness map below, + // which is keyed by provider card and so already depends on the active host. + const enterpriseHostReadiness = useMemo(() => { + const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; + return new Map( + enterpriseHosts.map( + (host) => + [ + host, + getPublishProviderReadiness({ + provider: "github-enterprise", + host, + sourceControlProviders, + }), + ] as const, + ), + ); + }, [enterpriseHosts, sourceControlDiscovery.data]); + const readyEnterpriseHosts = useMemo( + () => enterpriseHosts.filter((host) => enterpriseHostReadiness.get(host)?.ready === true), + [enterpriseHosts, enterpriseHostReadiness], + ); + const activeEnterpriseHost = resolveSelectedEnterpriseHost({ + selectedHost: selectedEnterpriseHost, + availableHosts: enterpriseHosts, + readyHosts: readyEnterpriseHosts, + }); + const enterprisePublishProviderOptions = useMemo>(() => { + if (enterpriseHosts.length === 0 || activeEnterpriseHost === null) return []; + return [ + { + id: "github-enterprise", + value: "github-enterprise" as const, + label: "GitHub Enterprise", + description: activeEnterpriseHost, + host: activeEnterpriseHost, + pathPlaceholder: "owner/repo", + Icon: GitHubIcon, + }, + ]; + }, [enterpriseHosts, activeEnterpriseHost]); + const publishProviderOptions = useMemo>( + () => + PUBLISH_PROVIDER_OPTIONS.flatMap((option) => + option.value === "github" ? [option, ...enterprisePublishProviderOptions] : [option], + ), + [enterprisePublishProviderOptions], + ); const publishAccountByProvider = useMemo(() => { - const accounts: Record = { + const accounts: Record = { github: null, gitlab: null, bitbucket: null, @@ -416,47 +450,58 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { } return accounts; }, [sourceControlDiscovery.data]); - const publishProviderReadiness = useMemo(() => { + const publishProviderReadinessById = useMemo(() => { const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; - return Object.fromEntries( - PUBLISH_PROVIDER_OPTIONS.map((option) => [ - option.value, - getPublishProviderReadiness({ + return new Map( + publishProviderOptions.map((option) => { + const readiness = getPublishProviderReadiness({ provider: option.value, + ...(option.value === "github-enterprise" ? { host: option.host } : {}), sourceControlProviders, - }), - ]), - ) as Record; - }, [sourceControlDiscovery.data]); + }); + return [option.id, readiness] as const; + }), + ); + }, [publishProviderOptions, sourceControlDiscovery.data]); + const readinessForOption = useCallback( + (id: string) => publishProviderReadinessById.get(id) ?? { ready: false, hint: null }, + [publishProviderReadinessById], + ); const hasReadyPublishProvider = useMemo( - () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness[option.value].ready), - [publishProviderReadiness], + () => publishProviderOptions.some((option) => readinessForOption(option.id).ready), + [publishProviderOptions, readinessForOption], ); const sortedPublishProviderOptions = useMemo( () => - PUBLISH_PROVIDER_OPTIONS.toSorted((left, right) => { - const leftReady = publishProviderReadiness[left.value].ready; - const rightReady = publishProviderReadiness[right.value].ready; + publishProviderOptions.toSorted((left, right) => { + const leftReady = readinessForOption(left.id).ready; + const rightReady = readinessForOption(right.id).ready; if (leftReady !== rightReady) { return leftReady ? -1 : 1; } return left.label.localeCompare(right.label); }), - [publishProviderReadiness], + [publishProviderOptions, readinessForOption], ); - const firstReadyPublishProvider = sortedPublishProviderOptions.find( - (option) => publishProviderReadiness[option.value].ready, - )?.value; - const publishProvider = - selectedPublishProvider !== null && publishProviderReadiness[selectedPublishProvider].ready - ? selectedPublishProvider - : (firstReadyPublishProvider ?? selectedPublishProvider ?? "github"); - const selectedPublishProviderReadiness = publishProviderReadiness[publishProvider]; - const publishRepositoryPrefill = publishAccountByProvider[publishProvider] - ? `${publishAccountByProvider[publishProvider]}/` - : ""; + const firstReadyPublishProviderId = sortedPublishProviderOptions.find( + (option) => readinessForOption(option.id).ready, + )?.id; + const publishProviderId = + selectedPublishProviderId !== null && readinessForOption(selectedPublishProviderId).ready + ? selectedPublishProviderId + : (firstReadyPublishProviderId ?? selectedPublishProviderId ?? "github"); + const selectedPublishProviderReadiness = readinessForOption(publishProviderId); + const currentPublishProvider = + publishProviderOptions.find((option) => option.id === publishProviderId) ?? + PUBLISH_PROVIDER_OPTIONS[0]; + const publishProvider = currentPublishProvider.value; + const publishRepositoryPrefill = + publishProvider === "github-enterprise" + ? "" + : publishAccountByProvider[publishProvider] + ? `${publishAccountByProvider[publishProvider]}/` + : ""; const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill; - const currentPublishProvider = publishProviderOption(publishProvider); const publishHost = currentPublishProvider.host; const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; const publishProviderLabel = currentPublishProvider.label; @@ -487,6 +532,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { void (async () => { const result = await publishRepositoryAction.run({ provider: publishProvider, + ...(publishProvider === "github-enterprise" ? { host: currentPublishProvider.host } : {}), repository: publishRepository.trim(), visibility: publishVisibility, remoteName: publishRemoteName.trim() || "origin", @@ -508,6 +554,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { })(); }, [ canSubmitPublishRepository, + currentPublishProvider, props.environmentId, props.gitCwd, publishProtocol, @@ -525,6 +572,8 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { setPublishAdvancedOpen(false); setPublishError(null); setPublishResult(null); + setSelectedPublishProviderId(null); + setSelectedEnterpriseHost(null); }, []); const handleOpenChange = useCallback( @@ -612,21 +661,21 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { Provider { - setSelectedPublishProvider(value as PublishProviderKind); + setSelectedPublishProviderId(value as string); setPublishRepositoryOverride(null); }} aria-labelledby="publish-provider-cards-label" className="grid grid-cols-2 gap-2.5" > {sortedPublishProviderOptions.map((option) => { - const readiness = publishProviderReadiness[option.value]; - const isSelected = publishProvider === option.value && readiness.ready; + const readiness = readinessForOption(option.id); + const isSelected = publishProviderId === option.id && readiness.ready; if (!readiness.ready) { return (
- - {option.label} - + {option.value === "github-enterprise" ? ( + + + {option.label} + + + {option.description} + + + ) : ( + + {option.label} + + )} ); })} + + {publishProvider === "github-enterprise" && enterpriseHosts.length > 0 ? ( +
+ + Host + + { + setSelectedEnterpriseHost(value as string); + setPublishRepositoryOverride(null); + }} + aria-labelledby="publish-enterprise-host-label" + className="grid grid-cols-2 gap-2" + > + {enterpriseHosts.map((host) => { + const hostReadiness = enterpriseHostReadiness.get(host); + // Selecting an unauthenticated host would swap the card + // for its "Setup Required" state, which removes this + // picker and leaves no way back to a working host. + if (!hostReadiness?.ready) { + return ( +
+ + + {host} + + + { + event.preventDefault(); + event.stopPropagation(); + openSourceControlSettings(); + }} + > + Setup Required + + } + /> + + {hostReadiness?.hint ?? + `${host} is not authenticated. Open Settings -> Source Control for setup guidance.`} + + +
+ ); + } + + const isSelected = activeEnterpriseHost === host; + return ( + + + + {host} + + + ); + })} +
+
+ ) : null}
diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 10b54f6d7af..647dda73beb 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -64,6 +64,7 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { github: GitHubIcon, + "github-enterprise": GitHubIcon, gitlab: GitLabIcon, "azure-devops": AzureDevOpsIcon, bitbucket: BitbucketIcon, @@ -273,6 +274,7 @@ function DiscoveryItemRow({ readonly children?: ReactNode; }) { const version = optionLabel(item.version); + const host = isProviderDiscoveryItem(item) ? optionLabel(item.host) : null; const enabled = isProviderDiscoveryItem(item) ? item.status === "available" && item.auth.status === "authenticated" : item.status === "available" && item.implemented; @@ -298,6 +300,9 @@ function DiscoveryItemRow({ {item.label} {version ? {version} : null} + {host && host !== item.label ? ( + {host} + ) : null} {isVcsNotReady(item) ? ( Coming Soon @@ -574,7 +579,7 @@ export function SourceControlSettingsPanel() { headerAction={hasVersionControlSystems ? null : scanButton} > {result.sourceControlProviders.map((item) => ( - + ))} ) : null} diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts index 5e534af0a0b..175ca137653 100644 --- a/apps/web/src/pullRequestReference.test.ts +++ b/apps/web/src/pullRequestReference.test.ts @@ -70,4 +70,34 @@ describe("parsePullRequestReference", () => { it("rejects non-pull-request input", () => { expect(parsePullRequestReference("feature/my-branch")).toBeNull(); }); + + it("accepts an enterprise pull request url", () => { + expect(parsePullRequestReference("https://git.corp.com/owner/repo/pull/42")).toBe( + "https://git.corp.com/owner/repo/pull/42", + ); + }); + + it("accepts a ghe.com pull request url", () => { + expect(parsePullRequestReference("https://acme.ghe.com/owner/repo/pull/7")).toBe( + "https://acme.ghe.com/owner/repo/pull/7", + ); + }); + + it("still accepts github.com urls and bare numbers", () => { + expect(parsePullRequestReference("https://github.com/owner/repo/pull/1")).toBe( + "https://github.com/owner/repo/pull/1", + ); + expect(parsePullRequestReference("#12")).toBe("12"); + }); + + it("still rejects a non pull request url", () => { + expect(parsePullRequestReference("https://git.corp.com/owner/repo/issues/42")).toBeNull(); + expect(parsePullRequestReference("https://git.corp.com/owner/repo")).toBeNull(); + }); + + it("still unwraps a gh cli checkout command", () => { + expect(parsePullRequestReference("gh pr checkout https://git.corp.com/owner/repo/pull/9")).toBe( + "https://git.corp.com/owner/repo/pull/9", + ); + }); }); diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts index b919e736cc0..0fe41ca71d9 100644 --- a/apps/web/src/pullRequestReference.ts +++ b/apps/web/src/pullRequestReference.ts @@ -1,5 +1,5 @@ const GITHUB_PULL_REQUEST_URL_PATTERN = - /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; + /^https:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; const GITLAB_MERGE_REQUEST_URL_PATTERN = /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; const AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN = diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 297ae5717df..5076fb09302 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -267,7 +267,8 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi ); const action = useCallback( async (input: { - provider: "github" | "gitlab" | "bitbucket" | "azure-devops"; + provider: "github" | "gitlab" | "bitbucket" | "azure-devops" | "github-enterprise"; + host?: string; repository: string; visibility: SourceControlRepositoryVisibility; remoteName: string; diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 4cca703c145..643648f1663 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -4,11 +4,17 @@ import { ProjectId, CommandId, SourceControlDiscoveryResult, + SourceControlProviderAuthStatus, + SourceControlProviderDiscoveryItem, + SourceControlProviderKind, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { + addProjectRemoteTargetLabel, + addProjectRemoteTargetReadiness, buildAddProjectRemoteSourceReadiness, + buildAddProjectRemoteTargets, buildProjectCreateCommand, canCreateProjectInEnvironment, findExistingAddProject, @@ -18,6 +24,44 @@ import { } from "./projects.ts"; import type { EnvironmentProject } from "../state/models.ts"; +function providerItem(overrides: { + readonly kind: SourceControlProviderKind; + readonly id: string; + readonly host?: string; + readonly label?: string; + readonly status?: "available" | "missing"; + readonly installHint?: string; + readonly auth?: { + readonly status?: SourceControlProviderAuthStatus; + readonly account?: string; + readonly host?: string; + readonly detail?: string; + }; +}): SourceControlProviderDiscoveryItem { + return { + kind: overrides.kind, + id: overrides.id, + host: overrides.host ? Option.some(overrides.host) : Option.none(), + label: overrides.label ?? overrides.kind, + status: overrides.status ?? "available", + version: Option.none(), + installHint: overrides.installHint ?? "Install", + detail: Option.none(), + auth: { + status: overrides.auth?.status ?? "authenticated", + account: overrides.auth?.account ? Option.some(overrides.auth.account) : Option.none(), + host: overrides.auth?.host ? Option.some(overrides.auth.host) : Option.none(), + detail: overrides.auth?.detail ? Option.some(overrides.auth.detail) : Option.none(), + }, + }; +} + +function discoveryResult( + providers: ReadonlyArray, +): SourceControlDiscoveryResult { + return { versionControlSystems: [], sourceControlProviders: providers }; +} + describe("add project shared logic", () => { it("only allows project creation in connected environments", () => { expect(canCreateProjectInEnvironment("connected")).toBe(true); @@ -58,45 +102,31 @@ describe("add project shared logic", () => { }); it("marks authenticated source control providers as ready", () => { - const discovery: SourceControlDiscoveryResult = { - versionControlSystems: [], - sourceControlProviders: [ - { - kind: "github", - label: "GitHub", - status: "available", - installHint: "Install gh", - version: Option.some("1.0.0"), - detail: Option.none(), - auth: { - status: "authenticated", - account: Option.some("octo"), - host: Option.some("github.com"), - detail: Option.none(), - }, - }, - { - kind: "gitlab", - label: "GitLab", - status: "available", - installHint: "Install glab", - version: Option.some("1.0.0"), - detail: Option.none(), - auth: { - status: "unauthenticated", - account: Option.none(), - host: Option.none(), - detail: Option.some("Run glab auth login"), - }, - }, - ], - }; + const discovery = discoveryResult([ + providerItem({ + kind: "github", + id: "github", + host: "github.com", + label: "GitHub", + installHint: "Install gh", + auth: { status: "authenticated", account: "octo", host: "github.com" }, + }), + providerItem({ + kind: "gitlab", + id: "gitlab", + host: "gitlab.com", + label: "GitLab", + installHint: "Install glab", + auth: { status: "unauthenticated", detail: "Run glab auth login" }, + }), + ]); const readiness = buildAddProjectRemoteSourceReadiness(discovery); - expect(readiness.url.ready).toBe(true); - expect(readiness.github.ready).toBe(true); - expect(readiness.gitlab).toEqual({ ready: false, hint: "Run glab auth login" }); - expect(sortAddProjectProviderSources(readiness)[0]).toBe("github"); + const targets = buildAddProjectRemoteTargets(discovery); + expect(readiness.get("url")).toEqual({ ready: true, hint: null }); + expect(readiness.get("github")).toEqual({ ready: true, hint: null }); + expect(readiness.get("gitlab")).toEqual({ ready: false, hint: "Run glab auth login" }); + expect(sortAddProjectProviderSources(readiness, targets)[0]!.id).toBe("github"); }); it("finds existing projects by normalized path in the target environment", () => { @@ -151,3 +181,113 @@ describe("add project shared logic", () => { }); }); }); + +describe("buildAddProjectRemoteTargets", () => { + it("returns url plus one target per discovered connection", () => { + const targets = buildAddProjectRemoteTargets( + discoveryResult([ + providerItem({ kind: "github", id: "github", host: "github.com" }), + providerItem({ + kind: "github-enterprise", + id: "github-enterprise:git.corp.com", + host: "git.corp.com", + label: "git.corp.com", + }), + ]), + ); + + expect(targets.map((target) => target.id)).toEqual([ + "url", + "github", + "github-enterprise:git.corp.com", + ]); + expect(targets[2]!.host).toBe("git.corp.com"); + expect(targets[2]!.source).toBe("github-enterprise"); + }); + + it("leaves the host off non-enterprise targets", () => { + const targets = buildAddProjectRemoteTargets( + discoveryResult([ + providerItem({ kind: "github", id: "github", host: "github.com" }), + providerItem({ kind: "gitlab", id: "gitlab", host: "gitlab.com" }), + providerItem({ kind: "bitbucket", id: "bitbucket", host: "bitbucket.org" }), + providerItem({ kind: "azure-devops", id: "azure-devops", host: "dev.azure.com" }), + ]), + ); + + expect(targets.map((target) => ({ id: target.id, host: target.host }))).toEqual([ + { id: "url", host: null }, + { id: "github", host: null }, + { id: "gitlab", host: null }, + { id: "bitbucket", host: null }, + { id: "azure-devops", host: null }, + ]); + }); + + it("labels an enterprise target with its host", () => { + expect( + addProjectRemoteTargetLabel({ + id: "github-enterprise:git.corp.com", + source: "github-enterprise", + host: "git.corp.com", + }), + ).toBe("git.corp.com"); + }); + + it("keys readiness by target id", () => { + const discovery = discoveryResult([ + providerItem({ + kind: "github-enterprise", + id: "github-enterprise:git.corp.com", + host: "git.corp.com", + auth: { status: "unauthenticated", detail: "Run gh auth login." }, + }), + ]); + + const readiness = buildAddProjectRemoteSourceReadiness(discovery); + + expect(readiness.get("github-enterprise:git.corp.com")).toEqual({ + ready: false, + hint: "Run gh auth login.", + }); + expect(readiness.get("url")).toEqual({ ready: true, hint: null }); + }); + + it("still lists the four base providers, unready, when discovery is unavailable", () => { + const targets = buildAddProjectRemoteTargets(null); + const readiness = buildAddProjectRemoteSourceReadiness(null); + const sorted = sortAddProjectProviderSources(readiness, targets); + + expect(sorted.map((target) => target.id)).toEqual([ + "azure-devops", + "bitbucket", + "github", + "gitlab", + ]); + for (const target of sorted) { + expect(addProjectRemoteTargetReadiness(readiness, target.id)).toEqual({ + ready: false, + hint: "Provider status unavailable. Open Source Control settings and rescan.", + }); + } + }); + + it("lists exactly the four base providers when discovery has no enterprise rows", () => { + const discovery = discoveryResult([ + providerItem({ kind: "github", id: "github", host: "github.com" }), + providerItem({ kind: "gitlab", id: "gitlab", host: "gitlab.com" }), + providerItem({ kind: "bitbucket", id: "bitbucket", host: "bitbucket.org" }), + providerItem({ kind: "azure-devops", id: "azure-devops", host: "dev.azure.com" }), + ]); + const targets = buildAddProjectRemoteTargets(discovery); + const readiness = buildAddProjectRemoteSourceReadiness(discovery); + const sorted = sortAddProjectProviderSources(readiness, targets); + + expect(sorted.map((target) => target.id)).toEqual([ + "azure-devops", + "bitbucket", + "github", + "gitlab", + ]); + }); +}); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 056f96b21de..07e5fea4373 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -24,18 +24,24 @@ import type { EnvironmentProject } from "../state/models.ts"; export type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "bitbucket" | "azure-devops" + "github" | "github-enterprise" | "gitlab" | "bitbucket" | "azure-devops" >; export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; +export interface AddProjectRemoteTarget { + readonly id: string; + readonly source: AddProjectRemoteSource; + readonly host: string | null; +} + export function canCreateProjectInEnvironment( connectionPhase: EnvironmentConnectionPhase | null | undefined, ): boolean { return connectionPhase === "connected"; } -export type AddProjectRemoteSourceReadiness = Record< - AddProjectRemoteSource, +export type AddProjectRemoteSourceReadiness = ReadonlyMap< + string, { readonly ready: boolean; readonly hint: string | null } >; @@ -54,25 +60,12 @@ export type AddProjectCloneFlow = readonly remoteUrl: string; }; -const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [ - "url", - "github", - "gitlab", - "bitbucket", - "azure-devops", -]; - -const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = [ - "github", - "gitlab", - "bitbucket", - "azure-devops", -]; - export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): string { switch (source) { case "github": return "GitHub"; + case "github-enterprise": + return "GitHub Enterprise"; case "gitlab": return "GitLab"; case "bitbucket": @@ -88,6 +81,8 @@ export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): switch (source) { case "github": return "owner/repo"; + case "github-enterprise": + return "owner/repo"; case "gitlab": return "group/project"; case "bitbucket": @@ -105,19 +100,62 @@ export function addProjectRemoteSourceProvider( return source === "url" ? null : source; } +const URL_TARGET: AddProjectRemoteTarget = { id: "url", source: "url", host: null }; + +const BASE_PROVIDER_KINDS: ReadonlyArray = [ + "github", + "gitlab", + "bitbucket", + "azure-devops", +]; + +const BASE_PROVIDER_TARGETS: ReadonlyArray = BASE_PROVIDER_KINDS.map( + (kind) => ({ id: kind, source: kind, host: null }), +); + +export function buildAddProjectRemoteTargets( + discovery: SourceControlDiscoveryResult | null, +): ReadonlyArray { + if (!discovery) return [URL_TARGET, ...BASE_PROVIDER_TARGETS]; + return [ + URL_TARGET, + ...discovery.sourceControlProviders.flatMap((provider) => + provider.kind === "unknown" + ? [] + : [ + { + id: provider.id, + source: provider.kind, + // Only an enterprise target needs to pin a host; the rest keep + // their requests host-free the way they always were. + host: provider.kind === "github-enterprise" ? Option.getOrNull(provider.host) : null, + }, + ], + ), + ]; +} + +export function addProjectRemoteTargetLabel(target: AddProjectRemoteTarget): string { + if (target.source === "github-enterprise" && target.host) { + return target.host; + } + return addProjectRemoteSourceLabel(target.source); +} + export function sortAddProjectProviderSources( readinessBySource: AddProjectRemoteSourceReadiness, -): ReadonlyArray { + targets: ReadonlyArray, +): ReadonlyArray { return Arr.sort( - ADD_PROJECT_REMOTE_PROVIDER_SOURCES, + targets.filter((target) => target.source !== "url"), Order.mapInput( Order.Struct({ ready: Order.flip(Order.Boolean), label: Order.String, }), - (source: AddProjectRemoteProviderKind) => ({ - ready: readinessBySource[source].ready, - label: addProjectRemoteSourceLabel(source), + (target: AddProjectRemoteTarget) => ({ + ready: addProjectRemoteTargetReadiness(readinessBySource, target.id).ready, + label: addProjectRemoteTargetLabel(target), }), ), ); @@ -126,51 +164,43 @@ export function sortAddProjectProviderSources( export function buildAddProjectRemoteSourceReadiness( discovery: SourceControlDiscoveryResult | null, ): AddProjectRemoteSourceReadiness { - const unavailable = { - ready: false, - hint: "Provider status unavailable. Open Source Control settings and rescan.", - } as const; - const readiness: AddProjectRemoteSourceReadiness = { - url: { ready: true, hint: null }, - github: unavailable, - gitlab: unavailable, - bitbucket: unavailable, - "azure-devops": unavailable, - }; + const readiness = new Map([ + ["url", { ready: true, hint: null }], + ]); + if (!discovery) return readiness; - if (!discovery) { - return readiness; - } - - const providerByKind = new Map( - discovery.sourceControlProviders.map((provider) => [provider.kind, provider]), - ); - for (const source of ADD_PROJECT_REMOTE_SOURCES) { - const kind = addProjectRemoteSourceProvider(source); - if (!kind) continue; - const provider = providerByKind.get(kind); - if (!provider) { - readiness[source] = unavailable; - continue; - } + for (const provider of discovery.sourceControlProviders) { + if (provider.kind === "unknown") continue; if (provider.status !== "available") { - readiness[source] = { ready: false, hint: provider.installHint }; + readiness.set(provider.id, { ready: false, hint: provider.installHint }); continue; } if (provider.auth.status === "unauthenticated") { - readiness[source] = { + readiness.set(provider.id, { ready: false, hint: Option.getOrNull(provider.auth.detail) ?? `${provider.label} is not authenticated. Open Source Control settings for setup guidance.`, - }; + }); continue; } - readiness[source] = { ready: true, hint: null }; + readiness.set(provider.id, { ready: true, hint: null }); } return readiness; } +export function addProjectRemoteTargetReadiness( + readiness: AddProjectRemoteSourceReadiness, + targetId: string, +): { readonly ready: boolean; readonly hint: string | null } { + return ( + readiness.get(targetId) ?? { + ready: false, + hint: "Provider status unavailable. Open Source Control settings and rescan.", + } + ); +} + export function getAddProjectInitialQuery(baseDirectory: string | null | undefined): string { const trimmed = baseDirectory?.trim() ?? ""; return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed); diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161..d1007a103a4 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -4,6 +4,7 @@ import { VcsDriverKind } from "./vcs.ts"; export const SourceControlProviderKind = Schema.Literals([ "github", + "github-enterprise", "gitlab", "azure-devops", "bitbucket", @@ -60,6 +61,7 @@ export type SourceControlRepositoryInfo = typeof SourceControlRepositoryInfo.Typ export const SourceControlRepositoryLookupInput = Schema.Struct({ provider: SourceControlProviderKind, repository: TrimmedNonEmptyString, + host: Schema.optional(TrimmedNonEmptyString), cwd: Schema.optional(TrimmedNonEmptyString), }); export type SourceControlRepositoryLookupInput = typeof SourceControlRepositoryLookupInput.Type; @@ -69,6 +71,7 @@ export const SourceControlCloneRepositoryInput = Schema.Struct({ repository: Schema.optional(TrimmedNonEmptyString), remoteUrl: Schema.optional(TrimmedNonEmptyString), destinationPath: TrimmedNonEmptyString, + host: Schema.optional(TrimmedNonEmptyString), protocol: Schema.optional(SourceControlCloneProtocol), }); export type SourceControlCloneRepositoryInput = typeof SourceControlCloneRepositoryInput.Type; @@ -86,6 +89,7 @@ export const SourceControlPublishRepositoryInput = Schema.Struct({ repository: TrimmedNonEmptyString, visibility: SourceControlRepositoryVisibility, remoteName: Schema.optional(TrimmedNonEmptyString), + host: Schema.optional(TrimmedNonEmptyString), protocol: Schema.optional(SourceControlCloneProtocol), }); export type SourceControlPublishRepositoryInput = typeof SourceControlPublishRepositoryInput.Type; @@ -139,6 +143,8 @@ export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type; export const SourceControlProviderDiscoveryItem = Schema.Struct({ kind: SourceControlProviderKind, + id: TrimmedNonEmptyString, + host: Schema.Option(TrimmedNonEmptyString), ...SourceControlDiscoverySharedFields, auth: SourceControlProviderAuth, }); diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 368e8387ee6..6ec0cbb05db 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -39,6 +39,20 @@ describe("source control presentation", () => { }), ); }); + + it("presents github-enterprise with GitHub PR terminology and icon", () => { + const presentation = resolveChangeRequestPresentation({ + kind: "github-enterprise", + name: "git.corp.com", + baseUrl: "https://git.corp.com", + }); + + expect(presentation.icon).toBe("github"); + expect(presentation.shortName).toBe("PR"); + expect(presentation.longName).toBe("pull request"); + expect(presentation.providerName).toBe("GitHub Enterprise"); + expect(presentation.checkoutCommandExample).toBe("gh pr checkout 123"); + }); }); describe("detectSourceControlProviderFromRemoteUrl", () => { @@ -75,4 +89,48 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { baseUrl: "https://self-hosted.example.test:8443", }); }); + + it("classifies github.com as github", () => { + expect(detectSourceControlProviderFromRemoteUrl("https://github.com/owner/repo.git")).toEqual({ + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }); + }); + + it("classifies a ghe.com tenant as github-enterprise", () => { + expect(detectSourceControlProviderFromRemoteUrl("https://acme.ghe.com/owner/repo.git")).toEqual( + { + kind: "github-enterprise", + name: "acme.ghe.com", + baseUrl: "https://acme.ghe.com", + }, + ); + }); + + // Previously classified as kind "github" / name "GitHub Self-Hosted". + // Reclassification is intended; nothing persists the kind. + it("classifies a github-prefixed corporate host as github-enterprise", () => { + expect(detectSourceControlProviderFromRemoteUrl("git@github.acme.com:owner/repo.git")).toEqual({ + kind: "github-enterprise", + name: "github.acme.com", + baseUrl: "https://github.acme.com", + }); + }); + + it("leaves an arbitrary GHES hostname unknown for CLI refinement", () => { + expect(detectSourceControlProviderFromRemoteUrl("https://git.corp.com/owner/repo.git")).toEqual( + { + kind: "unknown", + name: "git.corp.com", + baseUrl: "https://git.corp.com", + }, + ); + }); + + it("still classifies gitlab hosts as gitlab", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://gitlab.acme.com/group/project.git")?.kind, + ).toBe("gitlab"); + }); }); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 15a98dc7355..cb1edec2cbb 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -32,6 +32,17 @@ const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { urlExample: "https://github.com/owner/repo/pull/42", }; +const GITHUB_ENTERPRISE_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "github", + providerName: "GitHub Enterprise", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "GitHub Enterprise pull request", + checkoutCommandExample: "gh pr checkout 123", + urlExample: "https://git.company.com/owner/repo/pull/42", +}; + const GITLAB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "gitlab", providerName: "GitLab", @@ -81,6 +92,8 @@ export function resolveChangeRequestPresentation( case "github": case undefined: return GITHUB_CHANGE_REQUEST_PRESENTATION; + case "github-enterprise": + return GITHUB_ENTERPRISE_CHANGE_REQUEST_PRESENTATION; case "gitlab": return GITLAB_CHANGE_REQUEST_PRESENTATION; case "azure-devops": @@ -168,7 +181,11 @@ function toBaseUrl(host: string): string { } function isGitHubHost(host: string): boolean { - return host === "github.com" || host.includes("github"); + return host === "github.com"; +} + +function isGitHubEnterpriseHost(host: string): boolean { + return host !== "github.com" && (host.endsWith(".ghe.com") || host.includes("github")); } function isGitLabHost(host: string): boolean { @@ -195,7 +212,15 @@ export function detectSourceControlProviderFromRemoteUrl( if (isGitHubHost(hostname)) { return { kind: "github", - name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted", + name: "GitHub", + baseUrl: toBaseUrl(host), + }; + } + + if (isGitHubEnterpriseHost(hostname)) { + return { + kind: "github-enterprise", + name: hostname, baseUrl: toBaseUrl(host), }; }