diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index a480f3bec8..32fd1a91ce 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -7,12 +7,19 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; +import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; -const processResult = (stdout: string): VcsProcess.VcsProcessOutput => ({ - exitCode: ChildProcessSpawner.ExitCode(0), +const processResult = ( + stdout: string, + options?: { + readonly stderr?: string; + readonly exitCode?: ChildProcessSpawner.ExitCode; + }, +): VcsProcess.VcsProcessOutput => ({ + exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), stdout, - stderr: "", + stderr: options?.stderr ?? "", stdoutTruncated: false, stderrTruncated: false, }); @@ -157,3 +164,178 @@ it.effect("creates GitHub PRs through provider-neutral input names", () => }); }), ); + +it("accepts active authenticated GitHub accounts when another account fails", () => { + const auth = GitHubSourceControlProvider.discovery.parseAuth( + processResult( + JSON.stringify({ + hosts: { + "github.com": [ + { + state: "success", + active: true, + host: "github.com", + login: "active-user", + tokenSource: "keyring", + gitProtocol: "ssh", + }, + { + state: "error", + active: false, + host: "github.com", + login: "stale-user", + tokenSource: "keyring", + gitProtocol: "ssh", + error: "The token in keyring is invalid.", + }, + ], + }, + }), + ), + ); + + assert.deepStrictEqual( + { + status: auth.status, + account: auth.account, + host: auth.host, + }, + { + status: "authenticated", + account: Option.some("active-user"), + host: Option.some("github.com"), + }, + ); +}); + +it("parses GitHub auth JSON from stdout when stderr has warnings", () => { + const auth = GitHubSourceControlProvider.discovery.parseAuth( + processResult( + JSON.stringify({ + hosts: { + "github.com": [ + { + state: "success", + active: true, + host: "github.com", + login: "active-user", + tokenSource: "keyring", + gitProtocol: "ssh", + }, + ], + }, + }), + { stderr: "warning: ignored diagnostic from gh\n" }, + ), + ); + + assert.deepStrictEqual( + { + status: auth.status, + account: auth.account, + host: auth.host, + }, + { + status: "authenticated", + account: Option.some("active-user"), + host: Option.some("github.com"), + }, + ); +}); + +it("parses GitHub auth status accounts by host and active state", () => { + assert.deepStrictEqual( + parseGitHubAuthStatus( + JSON.stringify({ + hosts: { + "github.com": [ + { + state: "success", + active: true, + host: "github.com", + login: "active-user", + tokenSource: "keyring", + gitProtocol: "ssh", + }, + { + state: "error", + active: false, + host: "github.com", + login: "stale-user", + tokenSource: "keyring", + gitProtocol: "ssh", + }, + ], + "github.example.test": [ + { + state: "success", + active: false, + host: "github.example.test", + login: "enterprise-user", + tokenSource: "keyring", + gitProtocol: "ssh", + }, + ], + }, + }), + ).accounts, + [ + { + host: "github.com", + account: "active-user", + authenticated: true, + active: true, + error: null, + }, + { + host: "github.com", + account: "stale-user", + authenticated: false, + active: false, + error: null, + }, + { + host: "github.example.test", + account: "enterprise-user", + authenticated: true, + active: false, + error: null, + }, + ], + ); +}); + +it("reports unauthenticated when GitHub JSON has accounts but none are valid", () => { + const auth = GitHubSourceControlProvider.discovery.parseAuth( + processResult( + JSON.stringify({ + hosts: { + "github.com": [ + { + state: "error", + active: true, + host: "github.com", + login: "stale-user", + tokenSource: "keyring", + gitProtocol: "ssh", + error: "The token in keyring is invalid.", + }, + ], + }, + }), + ), + ); + + assert.deepStrictEqual( + { + status: auth.status, + host: auth.host, + detail: auth.detail, + }, + { + status: "unauthenticated", + host: Option.some("github.com"), + detail: Option.some("The token in keyring is invalid."), + }, + ); +}); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index cc892015fc..41329b97f7 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import * as GitHubCli from "./GitHubCli.ts"; +import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as GitHubPullRequests from "./gitHubPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; @@ -51,11 +52,28 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { const output = SourceControlProviderDiscovery.combinedAuthOutput(input); - const account = SourceControlProviderDiscovery.matchFirst(output, [ - /Logged in to .* account\s+([^\s(]+)/iu, - /Logged in to .* as\s+([^\s(]+)/iu, - ]); - const host = SourceControlProviderDiscovery.parseCliHost(output); + const authStatus = parseGitHubAuthStatus(input.stdout); + const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts); + const host = authenticatedAccount?.host; + + if (authenticatedAccount) { + return SourceControlProviderDiscovery.providerAuth({ + status: "authenticated", + account: authenticatedAccount.account, + host, + }); + } + + const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0]; + if (authStatus.parsed) { + return SourceControlProviderDiscovery.providerAuth({ + status: "unauthenticated", + host: failedAccount?.host, + detail: + failedAccount?.error ?? + "Run `gh auth login` to authenticate GitHub CLI with an active account.", + }); + } if (input.exitCode !== 0) { return SourceControlProviderDiscovery.providerAuth({ @@ -67,10 +85,6 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth }); } - if (account) { - return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); - } - return SourceControlProviderDiscovery.providerAuth({ status: "unknown", host, @@ -86,7 +100,7 @@ export const discovery = { label: "GitHub", executable: "gh", versionArgs: ["--version"], - authArgs: ["auth", "status"], + authArgs: ["auth", "status", "--json", "hosts"], parseAuth: parseGitHubAuth, installHint: "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 94d363d1c1..8b59e7d89a 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -1,9 +1,11 @@ import { assert, it } from "@effect/vitest"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as GitLabCli from "./GitLabCli.ts"; +import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; function makeProvider(gitlab: Partial) { @@ -107,3 +109,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 ccab2bd1f7..0dfb2bc040 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -6,6 +6,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, @@ -43,12 +44,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({ @@ -60,10 +68,6 @@ function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuth }); } - if (account) { - return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); - } - return SourceControlProviderDiscovery.providerAuth({ status: "unknown", host, @@ -73,6 +77,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: "GitLab Self-Hosted", + baseUrl: input.context.provider.baseUrl, + } as const; +} + export const discovery = { type: "cli", kind: "gitlab", @@ -81,6 +104,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/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index d1c6c65c75..f65710c4c9 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -59,15 +59,24 @@ it.effect("reports implemented tools separately from locally available executabl if (input.command === "gh" && input.args[0] === "--version") { return Effect.succeed(processOutput("gh version 2.83.0\n")); } - if (input.command === "gh" && input.args.join(" ") === "auth status") { + if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { return Effect.succeed( - processOutput(`github.com -Logged in to github.com account juliusmarminge (keyring) -- Active account: true -- Git operations protocol: ssh -- Token: gho_************************************ -- Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo' -`), + processOutput( + JSON.stringify({ + hosts: { + "github.com": [ + { + state: "success", + active: true, + host: "github.com", + login: "juliusmarminge", + tokenSource: "keyring", + gitProtocol: "ssh", + }, + ], + }, + }), + ), ); } return Effect.fail( @@ -164,13 +173,24 @@ it.effect("probes provider authentication without exposing token details", () => if (input.args[0] === "--version") { return Effect.succeed(processOutput(`${input.command} version test\n`)); } - if (input.command === "gh" && input.args.join(" ") === "auth status") { + if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { return Effect.succeed( - processOutput(`github.com -Logged in to github.com account octocat (keyring) -- Token: gho_************************************ -- Token scopes: 'repo' -`), + processOutput( + JSON.stringify({ + hosts: { + "github.com": [ + { + state: "success", + active: true, + host: "github.com", + login: "octocat", + tokenSource: "keyring", + gitProtocol: "ssh", + }, + ], + }, + }), + ), ); } if (input.command === "glab" && input.args.join(" ") === "auth status") { diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index 425c4f6177..e7705704ec 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -1,11 +1,13 @@ import type { SourceControlProviderAuth, SourceControlProviderDiscoveryItem, + SourceControlProviderInfo, SourceControlProviderKind, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import type * as SourceControlProvider from "./SourceControlProvider.ts"; import type * as VcsProcess from "../vcs/VcsProcess.ts"; export interface SourceControlAuthProbeInput { @@ -14,6 +16,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; @@ -26,6 +34,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 & { @@ -236,3 +247,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 829f6be1eb..c4d86fc89a 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -17,11 +18,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: () => @@ -58,15 +74,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), ), @@ -111,6 +132,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 28826e764b..5a9efc81c8 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -179,8 +179,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/gitHubAuthStatus.ts b/apps/server/src/sourceControl/gitHubAuthStatus.ts new file mode 100644 index 0000000000..d58909c560 --- /dev/null +++ b/apps/server/src/sourceControl/gitHubAuthStatus.ts @@ -0,0 +1,72 @@ +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const GitHubAuthStatusAccountSchema = Schema.Struct({ + state: Schema.String, + error: Schema.optional(Schema.String), + active: Schema.Boolean, + host: Schema.String, + login: Schema.String, +}); + +const GitHubAuthStatusSchema = Schema.Struct({ + hosts: Schema.Record(Schema.String, Schema.Array(GitHubAuthStatusAccountSchema)), +}); + +const decodeGitHubAuthStatusJson = Schema.decodeUnknownOption( + Schema.fromJsonString(GitHubAuthStatusSchema), +); + +export interface GitHubAuthStatusAccount { + readonly host: string; + readonly account: string; + readonly authenticated: boolean; + readonly active: boolean; + readonly error: string | null; +} + +export interface GitHubAuthStatus { + readonly parsed: boolean; + readonly accounts: ReadonlyArray; +} + +function nonEmptyString(value: string): string | null { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function parseGitHubAuthStatus(text: string): GitHubAuthStatus { + return Option.match(decodeGitHubAuthStatusJson(text), { + onNone: () => ({ parsed: false, accounts: [] }), + onSome: (status) => + ({ + parsed: true, + accounts: Object.values(status.hosts).flatMap((accounts) => + accounts.flatMap((account) => { + const host = nonEmptyString(account.host); + const login = nonEmptyString(account.login); + if (host === null || login === null) return []; + + return [ + { + host: host.toLowerCase(), + account: login, + authenticated: account.state === "success", + active: account.active, + error: account.error?.trim() || null, + }, + ]; + }), + ), + }) satisfies GitHubAuthStatus, + }); +} + +export function findAuthenticatedGitHubAccount( + accounts: ReadonlyArray, +): GitHubAuthStatusAccount | undefined { + return ( + accounts.find((account) => account.authenticated && account.active) ?? + accounts.find((account) => account.authenticated) + ); +} 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), }; }