Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
27 changes: 27 additions & 0 deletions apps/server/src/sourceControl/GitHubSourceControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -18,6 +19,7 @@ import {
providerAuth,
type SourceControlAuthProbeInput,
type SourceControlCliDiscoverySpec,
type SourceControlUnknownRemoteRefinementInput,
} from "./SourceControlProviderDiscovery.ts";

function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest {
Expand Down Expand Up @@ -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",
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
4 changes: 4 additions & 0 deletions docs/user/source-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions packages/shared/src/sourceControl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion packages/shared/src/sourceControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading