diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index ea6624aef892..e61f02ad3e5f 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -401,6 +401,131 @@ it("reports an update hint instead of unauthenticated when gh predates --json", ); }); +const authStatusJson = ( + accounts: ReadonlyArray<{ + readonly host: string; + readonly login: string; + readonly state?: string; + readonly active?: boolean; + }>, +): string => + JSON.stringify({ + hosts: Object.fromEntries( + accounts.map((account) => [ + account.host, + [ + { + state: account.state ?? "success", + active: account.active ?? false, + host: account.host, + login: account.login, + tokenSource: "keyring", + gitProtocol: "https", + }, + ], + ]), + ), + }); + +const refineUnknownRemote = (input: { readonly host: string; readonly stdout: string }) => + GitHubSourceControlProvider.discovery.refineUnknownRemote?.({ + cwd: "/repo", + context: { + provider: { + kind: "unknown", + name: input.host, + baseUrl: `https://${input.host}`, + }, + remoteName: "origin", + remoteUrl: `https://${input.host}/org/repo.git`, + }, + auth: processResult(input.stdout), + }); + +it("refines unknown remotes that gh is signed in to, whether or not the account is active", () => { + assert.deepStrictEqual( + refineUnknownRemote({ + host: "git.example.edu", + stdout: authStatusJson([ + { host: "github.com", login: "active-user", active: true }, + { host: "git.example.edu", login: "enterprise-user" }, + ]), + }), + { + kind: "github", + name: "GitHub Self-Hosted", + baseUrl: "https://git.example.edu", + }, + ); +}); + +it("leaves unknown remotes alone when gh has no working account for the host", () => { + assert.strictEqual( + refineUnknownRemote({ + host: "git.example.edu", + stdout: authStatusJson([ + { host: "git.example.edu", login: "enterprise-user", state: "failure" }, + ]), + }), + null, + ); + + assert.strictEqual( + refineUnknownRemote({ + host: "git.example.edu", + stdout: authStatusJson([{ host: "github.com", login: "active-user", active: true }]), + }), + null, + ); +}); + +it("refuses a port-bearing remote when gh only knows the bare hostname", () => { + // One hostname can serve two forges on separate ports. Refinement takes the first + // provider that claims the remote, so a bare-hostname match here would beat another + // CLI's exact host:port match and route operations through the wrong provider. + assert.strictEqual( + refineUnknownRemote({ + host: "git.example.edu:8443", + stdout: authStatusJson([{ host: "git.example.edu", login: "enterprise-user" }]), + }), + null, + ); +}); + +it("refines port-bearing remotes when gh reports the same host and port", () => { + assert.deepStrictEqual( + refineUnknownRemote({ + host: "git.example.edu:8443", + stdout: authStatusJson([{ host: "git.example.edu:8443", login: "enterprise-user" }]), + }), + { + kind: "github", + name: "GitHub Self-Hosted", + baseUrl: "https://git.example.edu:8443", + }, + ); +}); + +it("ignores stderr noise when refining, so gh warnings do not break host matching", () => { + const provider = GitHubSourceControlProvider.discovery.refineUnknownRemote?.({ + cwd: "/repo", + context: { + provider: { + kind: "unknown", + name: "git.example.edu", + baseUrl: "https://git.example.edu", + }, + remoteName: "origin", + remoteUrl: "https://git.example.edu/org/repo.git", + }, + auth: processResult(authStatusJson([{ host: "git.example.edu", login: "enterprise-user" }]), { + stderr: "warning: ignored diagnostic from gh\n", + }), + }); + + assert.strictEqual(provider?.kind, "github"); +}); + it.effect.each(["pull", "issues"])( "resolves %s subjects on the linked host without using the checkout", (kind) => diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 0b868ba3f439..4684b41c790e 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -31,6 +31,7 @@ import { type SourceControlAuthProbeInput, type SourceControlCliDiscoverySpec, type SourceControlManagedCliDiscoverySpec, + type SourceControlUnknownRemoteRefinementInput, } from "./SourceControlProviderDiscovery.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -165,6 +166,31 @@ export function parseGitHubAuth( }); } +// A GitHub Enterprise host is named by whoever installed it, so hostname guessing misses +// installs like `git.example.edu`. When detection lands on `unknown`, let a host `gh` is +// signed in to claim the remote. Any signed-in account counts rather than only the host's +// active one, so a second login on the same host still resolves the remote; whether `gh` +// knows the host at all is the question, not which account is currently selected. +function refineUnknownGitHubRemote(input: SourceControlUnknownRemoteRefinementInput) { + // Compare the remote host verbatim, port included. A host serving two forges on separate + // ports must not be claimed off a bare-hostname match, since refinement takes the first + // provider that claims the remote and would beat another CLI's exact match. + const host = input.context.provider.name.toLowerCase(); + const authenticated = parseGitHubAuthStatus(input.auth.stdout).accounts.some( + (account) => account.authenticated && account.host === host, + ); + + if (!authenticated) { + return null; + } + + return { + kind: "github", + name: "GitHub Self-Hosted", + baseUrl: input.context.provider.baseUrl, + } as const; +} + export const discovery = { type: "cli", kind: "github", @@ -173,6 +199,7 @@ export const discovery = { versionArgs: ["--version"], authArgs: ["auth", "status", "--json", "hosts"], parseAuth: parseGitHubAuth, + 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; @@ -258,7 +285,28 @@ export const makeDiscovery = Effect.gen(function* () { }, } satisfies SourceControlProviderDiscoveryItem; }), - refineUnknownRemote: () => Effect.succeed(null), + refineUnknownRemote: Effect.fn("GitHubSourceControlProvider.refineUnknownRemote")( + function* (input: { + readonly cwd: string; + readonly context: SourceControlProvider.SourceControlProviderContext; + }) { + const auth = yield* process + .run({ + operation: "source-control.discovery.refine-unknown-remote", + command: discovery.executable, + args: discovery.authArgs, + cwd: input.cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + appendTruncationMarker: true, + }) + .pipe(Effect.orElseSucceed(() => null)); + return auth === null + ? null + : refineUnknownGitHubRemote({ cwd: input.cwd, context: input.context, auth }); + }, + ), } satisfies SourceControlManagedCliDiscoverySpec; }); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 08751017b351..4594356b501d 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -213,6 +213,54 @@ self-hosted.example.test }), ); +it.effect("routes authenticated GitHub Enterprise remotes without relying on host naming", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "https://git.example.edu/org/repo.git" }], + process: { + run: () => + Effect.succeed( + processOutput( + JSON.stringify({ + hosts: { + "github.com": [ + { + state: "success", + active: true, + host: "github.com", + login: "active-user", + tokenSource: "keyring", + gitProtocol: "https", + }, + ], + "git.example.edu": [ + { + state: "success", + active: false, + host: "git.example.edu", + login: "enterprise-user", + tokenSource: "keyring", + gitProtocol: "https", + }, + ], + }, + }), + ), + ), + }, + }); + + const handle = yield* registry.resolveHandle({ cwd: "/repo" }); + + assert.strictEqual(handle.provider.kind, "github"); + assert.deepStrictEqual(handle.context?.provider, { + kind: "github", + name: "GitHub Self-Hosted", + baseUrl: "https://git.example.edu", + }); + }), +); + it.effect("refines the caller-selected remote instead of choosing another configured remote", () => Effect.gen(function* () { const registry = yield* makeRegistry({ diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 03c360ff3a10..e25db9e627e8 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -24,6 +24,10 @@ If `gh` is signed in to several accounts or hosts, expand **GitHub** in the same the account each host uses or turn a host off. A saved token or `GH_TOKEN` takes precedence over that choice; a host turned off stays off either way. +**GitHub Enterprise Server** works the same way, whatever your install is called. Sign in with +`gh auth login --hostname git.example.com`, and T3 Code recognizes projects on that host as GitHub +even when the hostname says nothing about GitHub. + ### Forgejo and Gitea Install [Forgejo CLI (`fj`)](https://codeberg.org/forgejo-contrib/forgejo-cli) or diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index b72f35793de5..db084484bb25 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -156,6 +156,18 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { ).toBe("unknown"); }); + it("leaves enterprise hosts without a provider label unknown for CLI refinement", () => { + // GitHub Enterprise hosts are named by the customer, so hostname matching cannot see + // them. Detection reports `unknown` and the provider CLIs settle it during refinement. + const remoteUrl = "https://git.example.edu/org/repo.git"; + + expect(detectSourceControlProviderFromRemoteUrl(remoteUrl)).toEqual({ + kind: "unknown", + name: "git.example.edu", + baseUrl: "https://git.example.edu", + }); + }); + it("detects SSH remotes with non-git SSH users (e.g. gitlab@, deploy@)", () => { expect( detectSourceControlProviderFromRemoteUrl("gitlab@gitlab.example.com:group/project.git")?.kind,