From 120076349598b2caaf7d918eff8a68d9fae63ae2 Mon Sep 17 00:00:00 2001 From: Guilherme Vieira Date: Mon, 4 May 2026 11:17:17 +0100 Subject: [PATCH] fix(source-control): detect authenticated self-hosted gitlab remotes --- .../GitLabSourceControlProvider.test.ts | 43 +++++++++++ .../GitLabSourceControlProvider.ts | 44 ++++++++--- .../SourceControlProviderDiscovery.ts | 58 +++++++++++++++ .../SourceControlProviderRegistry.test.ts | 73 ++++++++++++++++++- .../SourceControlProviderRegistry.ts | 8 +- .../src/sourceControl/gitLabAuthStatus.ts | 48 ++++++++++++ packages/shared/src/sourceControl.test.ts | 19 +++++ packages/shared/src/sourceControl.ts | 25 +++++-- 8 files changed, 298 insertions(+), 20 deletions(-) create mode 100644 apps/server/src/sourceControl/gitLabAuthStatus.ts diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 930c1c018f..516fb2ce84 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -1,7 +1,9 @@ import { assert, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as GitLabCli from "./GitLabCli.ts"; +import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; function makeProvider(gitlab: Partial) { @@ -105,3 +107,44 @@ it.effect("creates GitLab MRs through provider-neutral input names", () => }); }), ); + +it("accepts authenticated GitLab hosts when another configured host fails", () => { + const auth = GitLabSourceControlProvider.discovery.parseAuth({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: `gitlab.com + x gitlab.com: API call failed: 401 Unauthorized + ! No token found +self-hosted.example.test + ✓ Logged in to self-hosted.example.test as gitlab-user + ✓ Token found: ****** +`, + stderr: "", + }); + + assert.deepStrictEqual( + { + status: auth.status, + account: auth.account, + host: auth.host, + }, + { + status: "authenticated", + account: Option.some("gitlab-user"), + host: Option.some("self-hosted.example.test"), + }, + ); +}); + +it("parses authenticated GitLab auth status hosts with ports and single-label names", () => { + assert.deepStrictEqual( + parseGitLabAuthStatusHosts(`localhost:8080 + ✓ Logged in to localhost:8080 as local-user +selfhosted + ✓ Logged in to selfhosted as single-label-user +`), + [ + { host: "localhost:8080", account: "local-user" }, + { host: "selfhosted", account: "single-label-user" }, + ], + ); +}); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 5b0538babd..a4446d86e0 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -4,6 +4,7 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac import * as GitLabCli from "./GitLabCli.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; function providerError( operation: string, @@ -41,12 +42,19 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { const output = SourceControlProviderDiscovery.combinedAuthOutput(input); - const account = SourceControlProviderDiscovery.matchFirst(output, [ - /Logged in to .* as\s+([^\s(]+)/iu, - /Logged in to .* account\s+([^\s(]+)/iu, - /account:\s*([^\s(]+)/iu, - ]); - const host = SourceControlProviderDiscovery.parseCliHost(output); + const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output)); + const account = + authenticatedHost?.account ?? + SourceControlProviderDiscovery.matchFirst(output, [ + /Logged in to .* as\s+([^\s(]+)/iu, + /Logged in to .* account\s+([^\s(]+)/iu, + /account:\s*([^\s(]+)/iu, + ]); + const host = authenticatedHost?.host ?? SourceControlProviderDiscovery.parseCliHost(output); + + if (account) { + return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); + } if (input.exitCode !== 0) { return SourceControlProviderDiscovery.providerAuth({ @@ -58,10 +66,6 @@ function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuth }); } - if (account) { - return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); - } - return SourceControlProviderDiscovery.providerAuth({ status: "unknown", host, @@ -71,6 +75,25 @@ function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuth }); } +function refineUnknownGitLabRemote( + input: SourceControlProviderDiscovery.SourceControlUnknownRemoteRefinementInput, +) { + const host = input.context.provider.name; + const authenticated = parseGitLabAuthStatusHosts( + SourceControlProviderDiscovery.combinedAuthOutput(input.auth), + ).some((entry) => entry.account !== null && entry.host === host); + + if (!authenticated) { + return null; + } + + return { + kind: "gitlab", + name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", + baseUrl: input.context.provider.baseUrl, + } as const; +} + export const discovery = { type: "cli", kind: "gitlab", @@ -79,6 +102,7 @@ export const discovery = { versionArgs: ["--version"], authArgs: ["auth", "status"], parseAuth: parseGitLabAuth, + refineUnknownRemote: refineUnknownGitLabRemote, installHint: "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).", } satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index 87c0c4756a..53b3418140 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -1,10 +1,12 @@ import type { SourceControlProviderAuth, SourceControlProviderDiscoveryItem, + SourceControlProviderInfo, SourceControlProviderKind, } from "@t3tools/contracts"; import { Effect, Option } from "effect"; +import type * as SourceControlProvider from "./SourceControlProvider.ts"; import type * as VcsProcess from "../vcs/VcsProcess.ts"; export interface SourceControlAuthProbeInput { @@ -13,6 +15,12 @@ export interface SourceControlAuthProbeInput { readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; } +export interface SourceControlUnknownRemoteRefinementInput { + readonly cwd: string; + readonly context: SourceControlProvider.SourceControlProviderContext; + readonly auth: SourceControlAuthProbeInput; +} + interface SourceControlDiscoverySpecBase { readonly kind: SourceControlProviderKind; readonly label: string; @@ -25,6 +33,9 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly versionArgs: ReadonlyArray; readonly authArgs: ReadonlyArray; readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; + readonly refineUnknownRemote?: ( + input: SourceControlUnknownRemoteRefinementInput, + ) => SourceControlProviderInfo | null; }; export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { @@ -235,3 +246,50 @@ export function probeSourceControlProvider(input: { }), ); } + +export function refineUnknownRemoteProvider(input: { + readonly specs: ReadonlyArray; + readonly process: VcsProcess.VcsProcessShape; + readonly cwd: string; + readonly context: SourceControlProvider.SourceControlProviderContext | null; +}): Effect.Effect { + if (input.context === null || input.context.provider.kind !== "unknown") { + return Effect.succeed(input.context); + } + const context = input.context; + + return Effect.gen(function* () { + for (const spec of input.specs) { + if (spec.type !== "cli" || !spec.refineUnknownRemote) continue; + + const provider = yield* input.process + .run({ + operation: "source-control.discovery.refine-unknown-remote", + command: spec.executable, + args: spec.authArgs, + cwd: input.cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + truncateOutputAtMaxBytes: true, + }) + .pipe( + Effect.map( + (auth) => + spec.refineUnknownRemote?.({ + cwd: input.cwd, + context, + auth, + }) ?? null, + ), + Effect.catch(() => Effect.succeed(null)), + ); + + if (provider) { + return { ...context, provider }; + } + } + + return context; + }); +} diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 395bd9b5e8..7a62c155a6 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -1,6 +1,7 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { DateTime, Effect, Layer, Option } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { ServerConfig } from "../config.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; @@ -14,11 +15,26 @@ import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry. const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const processOutput = ( + stdout: string, + options?: { + readonly stderr?: string; + readonly exitCode?: ChildProcessSpawner.ExitCode; + }, +): VcsProcess.VcsProcessOutput => ({ + exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), + stdout, + stderr: options?.stderr ?? "", + stdoutTruncated: false, + stderrTruncated: false, +}); + function makeRegistry(input: { readonly remotes: ReadonlyArray<{ readonly name: string; readonly url: string; }>; + readonly process?: Partial; }) { const driver = { listRemotes: () => @@ -55,15 +71,20 @@ function makeRegistry(input: { }), }); + const processLayer = Layer.mock(VcsProcess.VcsProcess)({ + run: () => Effect.succeed(processOutput("")), + ...input.process, + }); + return SourceControlProviderRegistry.make().pipe( Effect.provide( Layer.mergeAll( registryLayer, + processLayer, Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), Layer.mock(GitHubCli.GitHubCli)({}), Layer.mock(GitLabCli.GitLabCli)({}), - Layer.mock(VcsProcess.VcsProcess)({}), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( Layer.provide(NodeServices.layer), ), @@ -108,6 +129,56 @@ it.effect("routes GitLab remotes to the GitLab provider", () => }), ); +it.effect("routes authenticated self-hosted GitLab remotes without relying on host naming", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "https://self-hosted.example.test/group/project.git" }], + process: { + run: () => + Effect.succeed( + processOutput( + `gitlab.com + x gitlab.com: API call failed: 401 Unauthorized + ! No token found +self-hosted.example.test + ✓ Logged in to self-hosted.example.test as gitlab-user + ✓ Token found: ****** +`, + { exitCode: ChildProcessSpawner.ExitCode(1) }, + ), + ), + }, + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "gitlab"); + }), +); + +it.effect("routes authenticated self-hosted GitLab remotes on non-standard ports", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "https://self-hosted.example.test:8443/group/project.git" }], + process: { + run: () => + Effect.succeed( + processOutput( + `self-hosted.example.test:8443 + ✓ Logged in to self-hosted.example.test:8443 as gitlab-user + ✓ Token found: ****** +`, + ), + ), + }, + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "gitlab"); + }), +); + it.effect("routes Bitbucket remotes to the Bitbucket provider", () => Effect.gen(function* () { const registry = yield* makeRegistry({ diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index c8b79f2165..62693c7020 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -174,8 +174,14 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit const remotes = yield* handle.driver .listRemotes(cwd) .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); + const context = selectProviderContext(remotes.remotes); - return selectProviderContext(remotes.remotes); + return yield* SourceControlProviderDiscovery.refineUnknownRemoteProvider({ + specs: discoverySpecs, + process, + cwd, + context, + }); }, ); diff --git a/apps/server/src/sourceControl/gitLabAuthStatus.ts b/apps/server/src/sourceControl/gitLabAuthStatus.ts new file mode 100644 index 0000000000..f54e300353 --- /dev/null +++ b/apps/server/src/sourceControl/gitLabAuthStatus.ts @@ -0,0 +1,48 @@ +const HOST_LINE_PATTERN = /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[a-f0-9:.]+\])(?::\d+)?$/iu; +const LOGGED_IN_PATTERN = /Logged in to .+? as\s+([^\s(]+)/iu; + +export interface GitLabAuthStatusHost { + readonly host: string; + readonly account: string | null; +} + +export function parseGitLabAuthStatusHosts(text: string): ReadonlyArray { + const hosts: GitLabAuthStatusHost[] = []; + let currentHost: string | null = null; + let currentLines: string[] = []; + + const flush = () => { + if (currentHost === null) return; + + const account = LOGGED_IN_PATTERN.exec(currentLines.join("\n"))?.[1]?.trim() || null; + hosts.push({ host: currentHost, account }); + currentHost = null; + currentLines = []; + }; + + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.length === 0) continue; + + const isHostLine = + rawLine.length === rawLine.trimStart().length && HOST_LINE_PATTERN.test(line); + if (isHostLine) { + flush(); + currentHost = line.toLowerCase(); + continue; + } + + if (currentHost !== null) { + currentLines.push(line); + } + } + + flush(); + return hosts; +} + +export function findAuthenticatedGitLabHost( + hosts: ReadonlyArray, +): GitLabAuthStatusHost | undefined { + return hosts.find((host) => host.account !== null); +} diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 697e65f091..785945d0ba 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -56,4 +56,23 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind, ).toBe("bitbucket"); }); + + it("preserves ports while classifying by hostname", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://gitlab.com:8443/group/repo.git"), + ).toEqual({ + kind: "gitlab", + name: "GitLab", + baseUrl: "https://gitlab.com:8443", + }); + expect( + detectSourceControlProviderFromRemoteUrl( + "https://self-hosted.example.test:8443/group/repo.git", + ), + ).toEqual({ + kind: "unknown", + name: "self-hosted.example.test:8443", + baseUrl: "https://self-hosted.example.test:8443", + }); + }); }); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 24ff3a0da8..15a98dc735 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -149,12 +149,20 @@ function parseRemoteHost(remoteUrl: string): string | null { } try { - return new URL(trimmed).hostname.toLowerCase(); + return new URL(trimmed).host.toLowerCase(); } catch { return null; } } +function parseHostName(host: string): string { + try { + return new URL(`https://${host}`).hostname.toLowerCase(); + } catch { + return host.replace(/:\d+$/u, "").toLowerCase(); + } +} + function toBaseUrl(host: string): string { return `https://${host}`; } @@ -182,24 +190,25 @@ export function detectSourceControlProviderFromRemoteUrl( if (!host) { return null; } + const hostname = parseHostName(host); - if (isGitHubHost(host)) { + if (isGitHubHost(hostname)) { return { kind: "github", - name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted", + name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted", baseUrl: toBaseUrl(host), }; } - if (isGitLabHost(host)) { + if (isGitLabHost(hostname)) { return { kind: "gitlab", - name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", + name: hostname === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", baseUrl: toBaseUrl(host), }; } - if (isAzureDevOpsHost(host)) { + if (isAzureDevOpsHost(hostname)) { return { kind: "azure-devops", name: "Azure DevOps", @@ -207,10 +216,10 @@ export function detectSourceControlProviderFromRemoteUrl( }; } - if (isBitbucketHost(host)) { + if (isBitbucketHost(hostname)) { return { kind: "bitbucket", - name: host === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted", + name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted", baseUrl: toBaseUrl(host), }; }