diff --git a/packages/source-control-gitlab/src/server/GitLabCli.test.ts b/packages/source-control-gitlab/src/server/GitLabCli.test.ts index a0609421e1222..0b6829b59ec7d 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.test.ts @@ -1,17 +1,32 @@ import { assert, it, afterEach, expect, vi } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/process"; -import { VcsProcessExitError } from "@t3tools/contracts"; +import { VcsProcessExitError, VcsProcessTimeoutError } from "@t3tools/contracts"; import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import * as GitLabCli from "./GitLabCli.ts"; const mockedRun = vi.fn(); +const mockedRemotes = vi.fn(); const layer = it.layer( - GitLabCli.layer.pipe(Layer.provide(TestSourceControlHost.layer({ process: { run: mockedRun } }))), + Layer.merge( + GitLabCli.layer.pipe( + Layer.provide( + TestSourceControlHost.layer({ + process: { run: mockedRun }, + git: { remotes: mockedRemotes }, + }), + ), + ), + // Keeps a developer's own GITLAB_HOST out of these tests. + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + ), ); function processOutput(stdout: string): SourceControlHost.SourceControlProcessOutput { @@ -24,8 +39,53 @@ function processOutput(stdout: string): SourceControlHost.SourceControlProcessOu }; } +function mockGlab( + signedInHosts: ReadonlyArray, + remoteUrls: ReadonlyArray = [], +) { + mockedRemotes.mockReturnValue( + Effect.succeed( + Effect.succeed({ + remotes: remoteUrls.map((remote) => ({ + name: "origin", + url: typeof remote === "string" ? remote : remote.url, + pushUrl: typeof remote === "string" ? Option.none() : Option.some(remote.pushUrl), + isPrimary: true, + })), + freshness: { + source: "live-local" as const, + observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), + expiresAt: Option.none(), + }, + }), + ), + ); + mockedRun.mockImplementation((input) => { + if (input.args[0] === "auth") { + return Effect.succeed({ + ...processOutput(""), + stderr: signedInHosts.map((host) => `${host}\n ✓ Logged in to ${host} as user\n`).join(""), + }); + } + if (input.args[1]?.startsWith("namespaces/")) { + return Effect.succeed(processOutput(JSON.stringify({ id: 9 }))); + } + return Effect.succeed( + processOutput( + JSON.stringify({ + path_with_namespace: "team/app", + web_url: "https://gitlab.example.test/team/app", + http_url_to_repo: "https://gitlab.example.test/team/app.git", + ssh_url_to_repo: "git@gitlab.example.test:team/app.git", + }), + ), + ); + }); +} + afterEach(() => { mockedRun.mockReset(); + mockedRemotes.mockReset(); }); layer("GitLabCli.layer", (it) => { @@ -192,7 +252,18 @@ layer("GitLabCli.layer", (it) => { detail: "GET 404 project not found", failureKind: "not-found", }); - mockedRun.mockReturnValueOnce(Effect.fail(cause)); + mockedRun.mockImplementation((input) => + input.args[0] === "auth" + ? Effect.fail( + new VcsProcessTimeoutError({ + operation: "GitLabCli.authStatus", + command: "glab", + cwd: "/repo", + timeoutMs: 5_000, + }), + ) + : Effect.fail(cause), + ); const error = yield* Effect.gen(function* () { const glab = yield* GitLabCli.GitLabCli; @@ -204,6 +275,99 @@ layer("GitLabCli.layer", (it) => { assert.strictEqual(error._tag, "GitLabCliCommandError"); assert.strictEqual(error.cause, cause); + expect(mockedRun).toHaveBeenLastCalledWith( + expect.objectContaining({ args: ["api", "projects/missing%2Fproject"] }), + ); + }), + ); + + it.effect("looks up repositories on the only signed-in host, spelled as glab prints it", () => + Effect.gen(function* () { + mockGlab(["Git.Example.test:8443"]); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab.getRepositoryCloneUrls({ cwd: "/server", repository: "team/app" }); + + expect(mockedRun).toHaveBeenLastCalledWith( + expect.objectContaining({ + args: ["api", "projects/team%2Fapp"], + env: { GITLAB_HOST: "Git.Example.test:8443" }, + }), + ); + }), + ); + + it.effect("leaves an explicit GITLAB_HOST to glab without probing sign-ins", () => + Effect.gen(function* () { + mockGlab(["gitlab.selfhosted.test"]); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab + .getRepositoryCloneUrls({ cwd: "/server", repository: "team/app" }) + .pipe( + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { GITLAB_HOST: "gitlab.com" } })), + ), + ); + + expect(mockedRun.mock.calls.map(([input]) => [input.args[0], input.env])).toEqual([ + ["api", undefined], + ]); + }), + ); + + it.effect.each([ + { case: "several hosts are signed in", hosts: ["gitlab.com", "gitlab.selfhosted.test"] }, + { + case: "the checkout has a gitlab.com remote", + hosts: ["gitlab.selfhosted.test"], + remotes: ["git@gitlab.com:team/app.git"], + }, + { + case: "the checkout has a userless SCP gitlab.com remote", + hosts: ["gitlab.selfhosted.test"], + remotes: ["gitlab.com:team/app.git"], + }, + { + case: "the checkout has a userless SCP gitlab.com remote with a trailing slash", + hosts: ["gitlab.selfhosted.test"], + remotes: ["gitlab.com:team/app.git/"], + }, + { + case: "the checkout fetches from a mirror and pushes to gitlab.com", + hosts: ["gitlab.selfhosted.test"], + remotes: [{ url: "/srv/mirror/app.git", pushUrl: "git@gitlab.com:team/app.git" }], + }, + ])("leaves the host to glab when $case", ({ hosts, remotes }) => + Effect.gen(function* () { + mockGlab(hosts, remotes); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab.getRepositoryCloneUrls({ cwd: "/server", repository: "team/app" }); + + expect(mockedRun.mock.lastCall?.[0].env).toBeUndefined(); + }), + ); + + it.effect("publishes to the only signed-in host", () => + Effect.gen(function* () { + mockGlab(["gitlab.selfhosted.test"]); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab.createRepository({ + cwd: "/server", + repository: "team/app", + visibility: "private", + }); + + expect( + mockedRun.mock.calls + .filter(([input]) => input.args[0] === "api") + .map(([input]) => [input.args[1], input.env]), + ).toEqual([ + ["namespaces/team", { GITLAB_HOST: "gitlab.selfhosted.test" }], + ["--method", { GITLAB_HOST: "gitlab.selfhosted.test" }], + ]); }), ); diff --git a/packages/source-control-gitlab/src/server/GitLabCli.ts b/packages/source-control-gitlab/src/server/GitLabCli.ts index 0234b6fcd9f5e..58d66eee7c123 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.ts @@ -1,3 +1,4 @@ +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -14,7 +15,10 @@ import { type VcsProcessExitFailureKind, } from "@t3tools/contracts"; +import { combinedAuthOutput } from "@t3tools/source-control-core/server/discovery"; +import { parseGitRemoteHost } from "@t3tools/source-control-core/shared/definition"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; import { decodeGitLabMergeRequestJson, decodeGitLabMergeRequestListJson, @@ -22,6 +26,14 @@ import { import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; const DEFAULT_TIMEOUT_MS = 30_000; +const AUTH_STATUS_TIMEOUT_MS = 5_000; + +// glab reads its fallback host from these, so a set one means the user chose the host. +const glabHostIsExplicit = Config.all( + ["GITLAB_HOST", "GITLAB_URI", "GL_HOST"].map((name) => + Config.String(name).pipe(Config.withDefault("")), + ), +).pipe(Config.map((hosts) => hosts.some((host) => host.length > 0))); const gitLabCliExecutionErrorContext = { operation: Schema.Literal("execute"), @@ -410,6 +422,12 @@ function parseRepositoryPath(repository: string): { return { namespacePath, projectPath }; } +export function parseGitLabRemoteHost(remoteUrl: string) { + // Remote host parsing does not recognize userless SCP remotes. + const scpHost = /^([^@:/\\\s]{2,}):\/?[^/\s]+(?:\/[^/\s]+)+\/?$/u.exec(remoteUrl.trim())?.[1]; + return parseGitRemoteHost(scpHost ? `ssh://${scpHost}` : remoteUrl); +} + // glab prints a missing project or merge request as a bare 404. const classifyGlabFailure = (stderr: string): VcsProcessExitFailureKind | null => { const normalized = stderr.toLowerCase(); @@ -418,14 +436,15 @@ const classifyGlabFailure = (stderr: string): VcsProcessExitFailureKind | null = return null; }; +type ExecuteInput = Parameters[0] & { + readonly env?: NodeJS.ProcessEnv | undefined; +}; + /** @public */ export const make = Effect.gen(function* () { - const { process } = yield* SourceControlHost.SourceControlHost; + const { process, git } = yield* SourceControlHost.SourceControlHost; - const run = ( - input: Parameters[0], - mapError: (error: VcsError) => GitLabCliError, - ) => + const run = (input: ExecuteInput, mapError: (error: VcsError) => GitLabCliError) => process .run({ operation: "GitLabCli.execute", @@ -437,10 +456,43 @@ export const make = Effect.gen(function* () { timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(input.stdin === undefined ? {} : { stdin: input.stdin }), ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + ...(input.env === undefined ? {} : { env: input.env }), }) .pipe(Effect.mapError(mapError)); - const execute: GitLabCli["Service"]["execute"] = (input) => + // glab takes its host from a remote on a host it knows, else falls back to gitlab.com. Point only + // that fallback at the only signed-in host: GITLAB_HOST would override a remote's host too. + const signedInHostEnv = Effect.fn("GitLabCli.signedInHostEnv")( + function* (cwd: string) { + if (yield* glabHostIsExplicit) return undefined; + const result = yield* process.run({ + operation: "GitLabCli.authStatus", + command: "glab", + args: ["auth", "status"], + cwd, + allowNonZeroExit: true, + timeoutMs: AUTH_STATUS_TIMEOUT_MS, + }); + const hosts = parseGitLabAuthStatusHosts(combinedAuthOutput(result)); + const [only, ...others] = hosts.filter((host) => host.account !== null); + if (!only || others.length > 0) return undefined; + const knownHosts = new Set(["gitlab.com", ...hosts.map((host) => host.host.toLowerCase())]); + const remotes = yield* git.remotes(cwd).pipe( + Effect.flatten, + Effect.map((result) => result.remotes), + Effect.orElseSucceed(() => []), + ); + const followsRemote = remotes.some((remote) => + [remote.url, ...Option.toArray(remote.pushUrl)].some((url) => + knownHosts.has(parseGitLabRemoteHost(url)?.host ?? ""), + ), + ); + return followsRemote ? undefined : { GITLAB_HOST: only.host }; + }, + Effect.orElseSucceed(() => undefined), + ); + + const execute = (input: ExecuteInput) => run(input, (error) => GitLabCliCommandError.fromVcsError( { operation: "execute", command: "glab", cwd: input.cwd }, @@ -534,10 +586,14 @@ export const make = Effect.gen(function* () { ); }), getRepositoryCloneUrls: Effect.fn("GitLabCli.getRepositoryCloneUrls")(function (input) { - return execute({ - cwd: input.cwd, - args: ["api", `projects/${encodeURIComponent(input.repository)}`], - }).pipe( + return signedInHostEnv(input.cwd).pipe( + Effect.flatMap((env) => + execute({ + cwd: input.cwd, + args: ["api", `projects/${encodeURIComponent(input.repository)}`], + env, + }), + ), Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => decodeGitLabRepositoryCloneUrls(raw).pipe( @@ -556,12 +612,14 @@ export const make = Effect.gen(function* () { Effect.map(normalizeRepositoryCloneUrls), ); }), - createRepository: Effect.fn("GitLabCli.createRepository")(function (input) { + createRepository: Effect.fn("GitLabCli.createRepository")(function* (input) { + const env = yield* signedInHostEnv(input.cwd); const { namespacePath, projectPath } = parseRepositoryPath(input.repository); const namespaceId: Effect.Effect = namespacePath ? execute({ cwd: input.cwd, args: ["api", `namespaces/${encodeURIComponent(namespacePath)}`], + env, }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => @@ -582,7 +640,7 @@ export const make = Effect.gen(function* () { ) : Effect.succeed(null); - return namespaceId.pipe( + return yield* namespaceId.pipe( Effect.flatMap((resolvedNamespaceId) => execute({ cwd: input.cwd, @@ -601,6 +659,7 @@ export const make = Effect.gen(function* () { ? [] : ["--raw-field", `namespace_id=${resolvedNamespaceId}`]), ], + env, }), ), Effect.map((result) => result.stdout.trim()), diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts index beeb6b38af1d0..c2c7579a68c68 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts @@ -3,10 +3,7 @@ import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; -import { - parseGitRemoteHost, - unknownRemoteProvider as unknownHostProvider, -} from "@t3tools/source-control-core/shared/definition"; +import { unknownRemoteProvider as unknownHostProvider } from "@t3tools/source-control-core/shared/definition"; import * as GitLabCli from "./GitLabCli.ts"; import * as GitLabChangeRequestTemplate from "./gitLabChangeRequestTemplate.ts"; @@ -66,7 +63,7 @@ function parseGitLabAuth(input: SourceControlAuthProbeInput) { /Logged in to .* account\s+([^\s(]+)/iu, /account:\s*([^\s(]+)/iu, ]); - const host = authenticatedHost?.host ?? parseCliHost(output); + const host = authenticatedHost?.host.toLowerCase() ?? parseCliHost(output); if (account) { return providerAuth({ status: "authenticated", account, host }); @@ -94,7 +91,7 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn const host = input.context.provider.name.toLowerCase(); const output = combinedAuthOutput(input.auth); const authenticated = parseGitLabAuthStatusHosts(output).some( - (entry) => entry.account !== null && entry.host === host, + (entry) => entry.account !== null && entry.host.toLowerCase() === host, ); if (!authenticated) { @@ -110,9 +107,7 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn /** The caller refines only an identity no host recognised, so its remote is on an unknown host. */ function unknownRemoteProvider(remoteUrl: string) { - // Remote host parsing does not recognize userless SCP remotes. - const scpHost = /^([^@:/\\\s]{2,}):\/?[^/\s]+(?:\/[^/\s]+)+$/u.exec(remoteUrl.trim())?.[1]; - const remote = parseGitRemoteHost(scpHost ? `ssh://${scpHost}` : remoteUrl); + const remote = GitLabCli.parseGitLabRemoteHost(remoteUrl); return remote === null ? null : unknownHostProvider(remote); } diff --git a/packages/source-control-gitlab/src/server/gitLabAuthStatus.ts b/packages/source-control-gitlab/src/server/gitLabAuthStatus.ts index f54e300353ad4..9c89c451e8612 100644 --- a/packages/source-control-gitlab/src/server/gitLabAuthStatus.ts +++ b/packages/source-control-gitlab/src/server/gitLabAuthStatus.ts @@ -28,7 +28,7 @@ export function parseGitLabAuthStatusHosts(text: string): ReadonlyArray