Skip to content
Open
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
43 changes: 43 additions & 0 deletions apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts
Original file line number Diff line number Diff line change
@@ -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<GitLabCli.GitLabCliShape>) {
Expand Down Expand Up @@ -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" },
],
);
});
44 changes: 34 additions & 10 deletions apps/server/src/sourceControl/GitLabSourceControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -58,10 +66,6 @@ function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuth
});
}

if (account) {
return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host });
}

return SourceControlProviderDiscovery.providerAuth({
status: "unknown",
host,
Expand All @@ -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",
Expand All @@ -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;
Expand Down
58 changes: 58 additions & 0 deletions apps/server/src/sourceControl/SourceControlProviderDiscovery.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand All @@ -25,6 +33,9 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & {
readonly versionArgs: ReadonlyArray<string>;
readonly authArgs: ReadonlyArray<string>;
readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth;
readonly refineUnknownRemote?: (
input: SourceControlUnknownRemoteRefinementInput,
) => SourceControlProviderInfo | null;
};

export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & {
Expand Down Expand Up @@ -235,3 +246,50 @@ export function probeSourceControlProvider(input: {
}),
);
}

export function refineUnknownRemoteProvider(input: {
readonly specs: ReadonlyArray<SourceControlProviderDiscoverySpec>;
readonly process: VcsProcess.VcsProcessShape;
readonly cwd: string;
readonly context: SourceControlProvider.SourceControlProviderContext | null;
}): Effect.Effect<SourceControlProvider.SourceControlProviderContext | null> {
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;
});
}
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<VcsProcess.VcsProcessShape>;
}) {
const driver = {
listRemotes: () =>
Expand Down Expand Up @@ -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),
),
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
},
);

Expand Down
48 changes: 48 additions & 0 deletions apps/server/src/sourceControl/gitLabAuthStatus.ts
Original file line number Diff line number Diff line change
@@ -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<GitLabAuthStatusHost> {
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>,
): GitLabAuthStatusHost | undefined {
return hosts.find((host) => host.account !== null);
}
Loading
Loading