diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 1381271e6bbc..a6ccbc26d812 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -396,3 +396,115 @@ it("reports an update hint instead of unauthenticated when gh predates --json", /2\.81\.0/, ); }); + +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("refines unknown remotes on non-standard ports against gh's bare hostnames", () => { + assert.deepStrictEqual( + refineUnknownRemote({ + host: "git.example.edu:8443", + stdout: authStatusJson([{ host: "git.example.edu", 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"); +}); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 3dcc8ab826a6..1546788271ae 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -7,6 +7,7 @@ import { type ChangeRequest, type ChangeRequestState, } from "@t3tools/contracts"; +import { parseHostName } from "@t3tools/shared/sourceControl"; import * as GitHubCli from "./GitHubCli.ts"; import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; @@ -18,6 +19,7 @@ import { providerAuth, type SourceControlAuthProbeInput, type SourceControlCliDiscoverySpec, + type SourceControlUnknownRemoteRefinementInput, } from "./SourceControlProviderDiscovery.ts"; function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { @@ -92,6 +94,30 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) { }); } +// 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) { + // Unknown contexts carry the raw remote host in `name`, which keeps any port, while `gh` + // keys its hosts by bare hostname. + const host = parseHostName(input.context.provider.name); + const authenticated = parseGitHubAuthStatus(input.auth.stdout).accounts.some( + (account) => account.authenticated && parseHostName(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", @@ -100,6 +126,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; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 54038502bfde..89b0e41cfdc4 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -203,6 +203,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 916536bbe736..73432e73d748 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -79,6 +79,10 @@ Run a quick **Rescan** after setting up a new machine or changing credentials. You can now clone, publish, and create pull requests. +**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. + ### For GitLab 1. Install the GitLab CLI: diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 86b1ba5912bd..ebe2cb8ba4d6 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -122,6 +122,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, diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index df88de595a3f..820d0b02d92d 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -158,7 +158,12 @@ function parseRemoteHost(remoteUrl: string): string | null { } } -function parseHostName(host: string): string { +/** + * Normalizes a remote host to its bare hostname, dropping any port. Provider CLIs report + * hosts inconsistently — `gh` keys by bare hostname while a remote may carry `:8443` — so + * comparisons against CLI output go through here. + */ +export function parseHostName(host: string): string { try { return new URL(`https://${host}`).hostname.toLowerCase(); } catch {