diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 2ff7fdb0d702..3e7430157554 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -2,12 +2,23 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as PubSub from "effect/PubSub"; import * as Redacted from "effect/Redacted"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/process"; -import { VcsRepositoryDetectionError } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + VcsProcessSpawnError, + VcsProcessTimeoutError, + VcsRepositoryDetectionError, +} from "@t3tools/contracts"; import * as ServerSettings from "../serverSettings.ts"; import * as ServerConfig from "../config.ts"; @@ -54,6 +65,8 @@ function makeRegistry(input: { readonly process?: Partial; readonly githubApi?: Partial; readonly gitlab?: Partial; + readonly forgejo?: Partial; + readonly settings?: Partial; readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; }) { const driver = { @@ -118,7 +131,9 @@ function makeRegistry(input: { Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), - ServerSettings.ServerSettingsService.layerTest(), + input.settings + ? Layer.mock(ServerSettings.ServerSettingsService)(input.settings) + : ServerSettings.ServerSettingsService.layerTest(), Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), Layer.mock(GitHubApi.GitHubApi)({ // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed. @@ -128,7 +143,10 @@ function makeRegistry(input: { Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), - Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), + Layer.mock(ForgejoCli.ForgejoCli)({ + listLogins: () => Effect.succeed([]), + ...input.forgejo, + }), Layer.mock(GitCafeApi.GitCafeApi)({}), Layer.mock(GitCafeCredentials.GitCafeCredentials)({ cliEnv: () => Effect.succeed({}), @@ -459,3 +477,150 @@ it.effect("skips GitHub discovery for the identity resolver's empty base URL", ( assert.deepStrictEqual(hosts, ["githubenterprise.dev.example.com"]); }), ); + +const notFound = (command: string, cwd: string) => + new VcsProcessSpawnError({ + operation: "test", + command, + cwd, + cause: PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", method: "spawn" }), + }); + +const resolveUnknown = ( + registry: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"], + repository: string, +) => + registry.resolveHandle({ + cwd: `/${repository}`, + context: { + provider: { + kind: SourceControlProviderKind.make("unknown"), + name: "git.example.internal", + baseUrl: "https://git.example.internal", + }, + remoteName: "origin", + remoteUrl: `git@Git.Example.Internal:team/${repository}.git`, + }, + }); + +it.effect("asks once for every checkout of a server no integration claims", () => + Effect.gen(function* () { + const asked: Array = []; + const release = yield* Deferred.make(); + const registry = yield* makeRegistry({ + remotes: [], + process: { + run: ({ command, args, cwd }) => { + asked.push([command, ...args].join(" ")); + return Deferred.await(release).pipe(Effect.andThen(Effect.fail(notFound(command, cwd)))); + }, + }, + githubApi: { + credential: (host) => { + asked.push(`credential ${host}`); + return Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })); + }, + }, + forgejo: { + listLogins: ({ command = "tea", cwd }) => { + asked.push(command); + const cause = notFound(command, cwd); + return Effect.fail(new ForgejoCli.ForgejoCliError({ command, cwd, detail: "", cause })); + }, + }, + }); + + // Three checkouts read while the first probe is still running share it. + const started = yield* Effect.forEach(["a", "b", "c"], () => Deferred.make()); + const callers = yield* Effect.forEach( + ["a", "b", "c"], + (repository, index) => + Deferred.succeed(started[index]!, undefined).pipe( + Effect.andThen(resolveUnknown(registry, repository)), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* Effect.forEach(started, Deferred.await); + yield* Deferred.succeed(release, undefined); + const handles = yield* Fiber.join(callers); + yield* resolveUnknown(registry, "d"); + assert.deepStrictEqual( + handles.map((handle) => handle.provider.kind), + ["unknown", "unknown", "unknown"], + ); + const once = asked.slice(); + // glab is asked about the remote's host as the remote spells it. + assert.sameMembers(once, [ + "credential git.example.internal", + "glab auth status --hostname Git.Example.Internal", + "fj", + "tea", + ]); + + // A login made in a terminal sends no signal, so the server is asked again later. + yield* TestClock.adjust("5 minutes"); + yield* resolveUnknown(registry, "a"); + assert.deepStrictEqual(asked, [...once, ...once]); + }), +); + +it.effect("asks again after a check that could not answer", () => + Effect.gen(function* () { + let asks = 0; + const registry = yield* makeRegistry({ + remotes: [], + process: { + run: ({ command, cwd }) => { + asks += 1; + return Effect.fail( + new VcsProcessTimeoutError({ operation: "test", command, cwd, timeoutMs: 1 }), + ); + }, + }, + }); + yield* resolveUnknown(registry, "a"); + yield* resolveUnknown(registry, "b"); + assert.strictEqual(asks, 2); + }), +); + +it.effect("asks again once Settings change", () => + Effect.gen(function* () { + const changes = yield* PubSub.unbounded(); + const handled = yield* Deferred.make(); + let signedIn = false; + const registry = yield* makeRegistry({ + remotes: [], + settings: { + subscribeChanges: PubSub.subscribe(changes).pipe( + Effect.map((subscription) => + // A change is handled once the registry asks for the next one. + Stream.fromSubscription(subscription).pipe( + Stream.flatMap((settings) => + Stream.concat( + Stream.make(settings), + Stream.fromEffectDrain(Deferred.succeed(handled, undefined)), + ), + ), + ), + ), + ), + }, + process: { run: ({ command, cwd }) => Effect.fail(notFound(command, cwd)) }, + githubApi: { + credential: (host) => + signedIn + ? Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:fingerprint` }) + : Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })), + }, + }); + assert.strictEqual((yield* resolveUnknown(registry, "a")).provider.kind, "unknown"); + signedIn = true; + assert.strictEqual((yield* resolveUnknown(registry, "b")).provider.kind, "unknown"); + + // A token saved for the host in Settings. + yield* PubSub.publish(changes, DEFAULT_SERVER_SETTINGS); + yield* Deferred.await(handled); + assert.strictEqual((yield* resolveUnknown(registry, "c")).provider.kind, "github"); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 821e24bf7bc3..fd306a986d10 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -3,8 +3,11 @@ import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; +import * as Hash from "effect/Hash"; import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; import { SourceControlProviderError, type SourceControlProviderDiscoveryItem, @@ -18,14 +21,19 @@ import { probeSourceControlProvider, refineUnknownRemoteProvider, type SourceControlProviderDiscoverySpec, + type UnknownRemoteVerdict, } from "@t3tools/source-control-core/server/discovery"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); +const UNKNOWN_REMOTE_CACHE_CAPACITY = 512; +// Outlasts the one-minute pull request sweep. A login made since is noticed after this. +const UNKNOWN_REMOTE_CACHE_TTL = Duration.minutes(5); export interface SourceControlProviderRegistration { readonly kind: SourceControlProviderKind; @@ -150,6 +158,38 @@ function selectProviderContext( ); } +/** + * One lookup per server: remotes that differ only in `owner/repo` share a verdict, and callers + * that arrive while it runs wait on it. `cwd` is left out because it only matters when a probe + * fails there, and a failed probe is not kept. Equality ignores `lookup`, so this is not a + * `Data.Class`. + */ +class UnknownRemoteLookup { + readonly key: string; + readonly lookup: Effect.Effect; + + constructor( + context: SourceControlProvider.SourceControlProviderContext, + lookup: Effect.Effect, + ) { + this.lookup = lookup; + this.key = [ + context.provider.name, + context.provider.baseUrl, + // Strip a trailing `owner/repo`, never the host after `//`, so one-segment remotes stay apart. + context.remoteUrl.trim().replace(/(?) { const config = yield* ServerConfig.ServerConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; const { process } = yield* SourceControlHost.SourceControlHost; const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const providers = new Map< @@ -209,6 +250,36 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit const get: SourceControlProviderRegistry["Service"]["get"] = (kind) => Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); + // "No integration holds this host" is the common verdict and the one worth keeping. A verdict + // reached while some probe failed is handed to the callers waiting on it, then dropped. + const unknownRemoteCache = yield* Cache.makeWith( + (request: UnknownRemoteLookup) => request.lookup, + { + capacity: UNKNOWN_REMOTE_CACHE_CAPACITY, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value.settled ? UNKNOWN_REMOTE_CACHE_TTL : Duration.zero, + }, + ); + // A token saved or a host toggled in Settings must show on the next read, not after the TTL. + const settingsChanges = yield* serverSettings.subscribeChanges; + yield* Stream.runForEach(settingsChanges, () => Cache.invalidateAll(unknownRemoteCache)).pipe( + Effect.forkScoped, + ); + + const refineContext = ( + cwd: string, + context: SourceControlProvider.SourceControlProviderContext | null, + ): Effect.Effect => + context === null || context.provider.kind !== "unknown" + ? Effect.succeed(context) + : Cache.get( + unknownRemoteCache, + new UnknownRemoteLookup( + context, + refineUnknownRemoteProvider({ specs: discoverySpecs, process, cwd, context }), + ), + ).pipe(Effect.map(({ provider }) => (provider ? { ...context, provider } : context))); + const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")( function* (cwd: string) { const handle = yield* vcsRegistry.resolve({ cwd }).pipe( @@ -235,14 +306,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit }), ), ); - const context = selectProviderContext(remotes.remotes); - - return yield* refineUnknownRemoteProvider({ - specs: discoverySpecs, - process, - cwd, - context, - }); + return yield* refineContext(cwd, selectProviderContext(remotes.remotes)); }, ); @@ -258,12 +322,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit const resolveHandle: SourceControlProviderRegistry["Service"]["resolveHandle"] = (input) => (input.context === undefined ? Cache.get(providerContextCache, input.cwd) - : refineUnknownRemoteProvider({ - specs: discoverySpecs, - process, - cwd: input.cwd, - context: input.context, - }) + : refineContext(input.cwd, input.context) ).pipe( Effect.map((context) => { const kind = context?.provider.kind ?? SourceControlProviderKind.make("unknown"); diff --git a/packages/source-control-core/src/server/discovery.ts b/packages/source-control-core/src/server/discovery.ts index 70d26b4a963c..6a50de973e3d 100644 --- a/packages/source-control-core/src/server/discovery.ts +++ b/packages/source-control-core/src/server/discovery.ts @@ -4,14 +4,19 @@ * @module source-control-core/server/discovery */ import * as NodeUtil from "node:util"; -import type { - SourceControlProviderAuth, - SourceControlProviderDiscoveryItem, - SourceControlProviderInfo, - SourceControlProviderKind, +import { + VcsProcessSpawnError, + type SourceControlProviderAuth, + type SourceControlProviderDiscoveryItem, + type SourceControlProviderInfo, + type SourceControlProviderKind, } from "@t3tools/contracts"; +import type * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; import type * as SourceControlHost from "./SourceControlHost.ts"; @@ -39,12 +44,15 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly executable: string; readonly versionArgs: ReadonlyArray; readonly authArgs: ReadonlyArray; - readonly remoteRefinementArgs?: ReadonlyArray; + readonly remoteRefinementArgs?: ( + context: SourceControlProvider.SourceControlProviderContext, + ) => ReadonlyArray; readonly probeTimeoutMs?: number; readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; + /** `null` when the CLI holds nothing for the remote; `undefined` when its output cannot say. */ readonly refineUnknownRemote?: ( input: SourceControlUnknownRemoteRefinementInput, - ) => SourceControlProviderInfo | null; + ) => SourceControlProviderInfo | null | undefined; }; export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { @@ -55,10 +63,14 @@ export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { export type SourceControlManagedCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly type: "managed-cli"; readonly probe: (cwd: string) => Effect.Effect; + /** + * `null` when the integration holds nothing for the remote, which includes its CLI not being + * installed. A failure means it could not tell, and the registry does not keep that answer. + */ readonly refineUnknownRemote: (input: { readonly cwd: string; readonly context: SourceControlProvider.SourceControlProviderContext; - }) => Effect.Effect; + }) => Effect.Effect; }; export type SourceControlProviderDiscoverySpec = @@ -294,47 +306,85 @@ export function probeSourceControlProvider(input: { ); } +const isVcsProcessSpawnError = Schema.is(VcsProcessSpawnError); + +/** + * A hosting CLI that is not installed claims no remote. The spawner checks the cwd before the + * executable, so only the executable's own ENOENT comes from `ChildProcess`. + */ +export function isMissingExecutable(error: unknown): boolean { + return ( + isVcsProcessSpawnError(error) && + PlatformError.isPlatformError(error.cause) && + error.cause.reason._tag === "NotFound" && + error.cause.reason.module === "ChildProcess" + ); +} + +/** The CLI ran, but its output does not say whether it holds the remote's host. */ +class UnknownRemoteUnanswered extends Schema.TaggedError()( + "UnknownRemoteUnanswered", + {}, +) {} + +export interface UnknownRemoteVerdict { + readonly provider: SourceControlProviderInfo | null; + /** Every integration answered, so the verdict holds for other checkouts of the same server. */ + readonly settled: boolean; +} + +function refineWithSpec(input: { + readonly spec: SourceControlProviderDiscoverySpec; + readonly process: SourceControlHost.SourceControlHost["Service"]["process"]; + readonly cwd: string; + readonly context: SourceControlProvider.SourceControlProviderContext; +}): Effect.Effect { + const { spec, context } = input; + if (spec.type === "managed-cli") { + return spec.refineUnknownRemote({ cwd: input.cwd, context }); + } + if (!isCliRemoteRefinementSpec(spec)) return Effect.succeed(null); + return input.process + .run({ + operation: "source-control.discovery.refine-unknown-remote", + command: spec.executable, + args: spec.remoteRefinementArgs?.(context) ?? spec.authArgs, + cwd: input.cwd, + allowNonZeroExit: true, + timeoutMs: probeTimeoutMs(spec), + maxOutputBytes: 8_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.catchIf(isMissingExecutable, () => Effect.void), + Effect.flatMap((auth) => { + const provider = + auth === undefined ? null : spec.refineUnknownRemote({ cwd: input.cwd, context, auth }); + return provider === undefined + ? Effect.fail(new UnknownRemoteUnanswered()) + : Effect.succeed(provider); + }), + ); +} + +/** Asks every integration whether it holds the remote's server; the first claim wins. */ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvider")( function* (input: { readonly specs: ReadonlyArray; readonly process: SourceControlHost.SourceControlHost["Service"]["process"]; readonly cwd: string; - readonly context: SourceControlProvider.SourceControlProviderContext | null; - }): Effect.fn.Return { - if (input.context === null || input.context.provider.kind !== "unknown") { - return input.context; + readonly context: SourceControlProvider.SourceControlProviderContext; + }): Effect.fn.Return { + const results = yield* Effect.forEach(input.specs, (spec) => + refineWithSpec({ ...input, spec }).pipe(Effect.result), + ); + // A claim reached after another integration could not answer is not settled: that one might + // have claimed the host first. + let settled = true; + for (const result of results) { + if (Result.isFailure(result)) settled = false; + else if (result.success !== null) return { provider: result.success, settled }; } - const context = input.context; - - const providers = yield* Effect.forEach(input.specs, (spec) => { - if (spec.type === "managed-cli") { - return spec.refineUnknownRemote({ cwd: input.cwd, context }); - } - if (!isCliRemoteRefinementSpec(spec)) return Effect.succeed(null); - return input.process - .run({ - operation: "source-control.discovery.refine-unknown-remote", - command: spec.executable, - args: spec.remoteRefinementArgs ?? spec.authArgs, - cwd: input.cwd, - allowNonZeroExit: true, - timeoutMs: probeTimeoutMs(spec), - maxOutputBytes: 8_000, - appendTruncationMarker: true, - }) - .pipe( - Effect.map((auth) => - spec.refineUnknownRemote({ - cwd: input.cwd, - context, - auth, - }), - ), - Effect.orElseSucceed(() => null), - ); - }); - const provider = providers.find((candidate) => candidate !== null); - - return provider ? { ...context, provider } : context; + return { provider: null, settled }; }, ); diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts index 4ea8e16ed35f..2c69f5776ebb 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts @@ -8,6 +8,7 @@ import * as SourceControlHost from "@t3tools/source-control-core/server/SourceCo import * as ForgejoCli from "./ForgejoCli.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import { + isMissingExecutable, providerAuth, probeSourceControlProvider, type SourceControlCliDiscoverySpec, @@ -24,7 +25,7 @@ export const discovery = { executable: "tea", versionArgs: ["--version"], authArgs: ["login", "status", "--output", "json"], - remoteRefinementArgs: ["login", "list", "--output", "json"], + remoteRefinementArgs: () => ["login", "list", "--output", "json"], parseAuth: (input) => { const logins = ForgejoCli.parseForgejoLogins(input.stdout); const login = logins.find((entry) => entry.default === "true") ?? logins[0]; @@ -150,13 +151,30 @@ export const makeDiscovery = Effect.gen(function* () { }) { const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl); if (!remote) return null; + // Both CLIs claim Forgejo, so one failing does not stop the other from claiming the host. + let failure: ForgejoCli.ForgejoCliError | undefined; for (const command of ["fj", "tea"] as const) { const logins = yield* listLogins({ cwd: input.cwd, command, remoteUrl: input.context.remoteUrl, - }).pipe(Effect.orElseSucceed(() => [])); - const login = ForgejoCli.matchForgejoLogin(logins, remote, input.context.requestedHost); + }).pipe( + // A CLI that is not installed holds no login. + Effect.catchIf( + (error) => isMissingExecutable(error.cause), + () => Effect.succeed([]), + ), + Effect.result, + ); + if (Result.isFailure(logins)) { + failure ??= logins.failure; + continue; + } + const login = ForgejoCli.matchForgejoLogin( + logins.success, + remote, + input.context.requestedHost, + ); if (login) return { kind: SourceControlProviderKind.make("forgejo"), @@ -164,6 +182,7 @@ export const makeDiscovery = Effect.gen(function* () { baseUrl: login.url, }; } + if (failure) return yield* failure; return null; }, ), diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index c52b3c2dd2c8..04fe68874412 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -269,10 +269,14 @@ export const makeDiscovery = Effect.gen(function* () { // Identity resolution also probes providers before a web base URL is known. const url = URL.parse(context.provider.baseUrl); if (!url?.host) return Effect.succeed(null); + // A timeout or keyring failure propagates: the registry does not keep that answer. return api.credential(url.host).pipe( Effect.as(true), - Effect.catchTags({ GitHubHostDisabledError: () => Effect.succeed(true) }), - Effect.orElseSucceed(() => false), + Effect.catchTags({ + GitHubCliMissingError: () => Effect.succeed(false), + GitHubHostDisabledError: () => Effect.succeed(true), + GitHubNotSignedInError: () => Effect.succeed(false), + }), Effect.map((known) => known ? ({ diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts index ccfc49aff208..c12e4e6100d4 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts @@ -214,6 +214,34 @@ it("refines unknown GitLab remotes with mixed-case provider hosts", () => { }); }); +it("tells a host glab has no login for apart from output that cannot say", () => { + const refine = (stderr: string) => + GitLabSourceControlProvider.discovery.refineUnknownRemote?.({ + cwd: "/repo", + context: { + provider: { + kind: SourceControlProviderKind.make("unknown"), + name: "git.example.test", + baseUrl: "https://git.example.test", + }, + remoteName: "origin", + remoteUrl: "https://git.example.test/group/project.git", + }, + auth: { exitCode: ChildProcessSpawner.ExitCode(1), stdout: "", stderr }, + }); + + // A settled "no" is cached; anything else makes the next read ask glab again. + assert.strictEqual( + refine("x git.example.test has not been authenticated with glab. Run `glab auth login`"), + null, + ); + assert.strictEqual( + refine("No GitLab instances have been authenticated with glab. Run `glab auth login`"), + null, + ); + assert.strictEqual(refine("git.example.test\n x API call failed: 503"), undefined); +}); + it("parses authenticated GitLab auth status hosts with ports and single-label names", () => { assert.deepStrictEqual( parseGitLabAuthStatusHosts(`localhost:8080 diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts index 6c15029d0e3f..e7d62ab8e8c4 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts @@ -80,14 +80,19 @@ function parseGitLabAuth(input: SourceControlAuthProbeInput) { }); } +// What glab prints when it holds no login for the host, across its 1.x releases. +const GITLAB_HOST_NOT_LOGGED_IN_PATTERN = + /\bnot (?:been )?authenticated with glab\b|\bno gitlab instances? ha(?:s|ve) been authenticated\b/iu; + function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementInput) { const host = input.context.provider.name.toLowerCase(); - const authenticated = parseGitLabAuthStatusHosts(combinedAuthOutput(input.auth)).some( + const output = combinedAuthOutput(input.auth); + const authenticated = parseGitLabAuthStatusHosts(output).some( (entry) => entry.account !== null && entry.host === host, ); if (!authenticated) { - return null; + return GITLAB_HOST_NOT_LOGGED_IN_PATTERN.test(output) ? null : undefined; } return { @@ -104,6 +109,12 @@ export const discovery = { executable: "glab", versionArgs: ["--version"], authArgs: ["auth", "status"], + // glab matches its configured hosts by exact name, so ask with the remote's own spelling. + remoteRefinementArgs: ({ provider, remoteUrl }) => { + const at = remoteUrl.toLowerCase().indexOf(provider.name.toLowerCase()); + const host = at < 0 ? provider.name : remoteUrl.slice(at, at + provider.name.length); + return ["auth", "status", "--hostname", host]; + }, parseAuth: parseGitLabAuth, refineUnknownRemote: refineUnknownGitLabRemote, installHint: