Skip to content

Commit 64c268f

Browse files
fix(source-control): detect authenticated self-hosted gitlab remotes
1 parent f7748a0 commit 64c268f

7 files changed

Lines changed: 242 additions & 14 deletions

apps/server/src/sourceControl/GitLabSourceControlProvider.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
import { Effect, Layer, Option } from "effect";
2-
import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
2+
import {
3+
SourceControlProviderError,
4+
type ChangeRequest,
5+
type SourceControlProviderInfo,
6+
} from "@t3tools/contracts";
37

48
import { GitLabCli, type GitLabCliError, type GitLabMergeRequestSummary } from "./GitLabCli.ts";
5-
import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts";
9+
import {
10+
SourceControlProvider,
11+
type SourceControlProviderRemoteDetector,
12+
type SourceControlRefSelector,
13+
} from "./SourceControlProvider.ts";
14+
import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
15+
import { VcsProcess } from "../vcs/VcsProcess.ts";
616

717
function providerError(operation: string, cause: GitLabCliError): SourceControlProviderError {
818
return new SourceControlProviderError({
@@ -103,4 +113,40 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () {
103113
});
104114
});
105115

116+
export const makeRemoteDetector = Effect.fn("makeGitLabRemoteDetector")(function* () {
117+
const process = yield* VcsProcess;
118+
119+
return ((input) => {
120+
if (input.context.provider.kind !== "unknown") {
121+
return Effect.succeed(null);
122+
}
123+
124+
const host = input.context.provider.name;
125+
return process
126+
.run({
127+
operation: "source-control.provider-detection.gitlab-auth-status",
128+
command: "glab",
129+
args: ["auth", "status"],
130+
cwd: input.cwd,
131+
allowNonZeroExit: true,
132+
})
133+
.pipe(
134+
Effect.map((output) => {
135+
const authenticated = parseGitLabAuthStatusHosts(`${output.stdout}\n${output.stderr}`)
136+
.filter((entry) => entry.account !== null)
137+
.some((entry) => entry.host === host);
138+
139+
return authenticated
140+
? ({
141+
kind: "gitlab",
142+
name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted",
143+
baseUrl: input.context.provider.baseUrl,
144+
} satisfies SourceControlProviderInfo)
145+
: null;
146+
}),
147+
Effect.catch(() => Effect.succeed(null)),
148+
);
149+
}) satisfies SourceControlProviderRemoteDetector;
150+
});
151+
106152
export const layer = Layer.effect(SourceControlProvider, make());

apps/server/src/sourceControl/SourceControlDiscovery.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,62 @@ Logged in as bitbucket-user
212212
);
213213
}).pipe(Effect.provide(testLayer));
214214
});
215+
216+
it.effect("accepts authenticated GitLab hosts when another configured host fails", () => {
217+
const testLayer = layer.pipe(
218+
Layer.provide(
219+
ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-gitlab-auth-" }),
220+
),
221+
Layer.provide(
222+
Layer.mock(VcsProcess.VcsProcess)({
223+
run: (input) => {
224+
if (input.args[0] === "--version") {
225+
return Effect.succeed(processOutput(`${input.command} version test\n`));
226+
}
227+
if (input.command === "glab" && input.args.join(" ") === "auth status") {
228+
return Effect.succeed(
229+
processOutput(
230+
`gitlab.com
231+
x gitlab.com: API call failed: 401 Unauthorized
232+
! No token found
233+
self-hosted.example.test
234+
✓ Logged in to self-hosted.example.test as gitlab-user
235+
✓ Token found: ******
236+
`,
237+
{ exitCode: ChildProcessSpawner.ExitCode(1) },
238+
),
239+
);
240+
}
241+
return Effect.fail(
242+
new VcsProcessSpawnError({
243+
operation: input.operation,
244+
command: input.command,
245+
cwd: input.cwd,
246+
cause: new Error(`${input.command} not found`),
247+
}),
248+
);
249+
},
250+
}),
251+
),
252+
Layer.provideMerge(NodeServices.layer),
253+
);
254+
255+
return Effect.gen(function* () {
256+
const discovery = yield* SourceControlDiscovery;
257+
const result = yield* discovery.discover;
258+
const gitlab = result.sourceControlProviders.find((item) => item.kind === "gitlab");
259+
260+
assert.deepStrictEqual(
261+
{
262+
status: gitlab?.auth.status,
263+
account: gitlab?.auth.account,
264+
host: gitlab?.auth.host,
265+
},
266+
{
267+
status: "authenticated",
268+
account: Option.some("gitlab-user"),
269+
host: Option.some("self-hosted.example.test"),
270+
},
271+
);
272+
}).pipe(Effect.provide(testLayer));
273+
});

apps/server/src/sourceControl/SourceControlDiscovery.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Context, Effect, Layer, Option } from "effect";
1010

1111
import { ServerConfig } from "../config.ts";
1212
import * as VcsProcess from "../vcs/VcsProcess.ts";
13+
import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
1314

1415
interface DiscoveryProbe {
1516
readonly label: string;
@@ -218,12 +219,19 @@ function parseGitHubAuth(input: AuthProbeInput): SourceControlProviderAuth {
218219

219220
function parseGitLabAuth(input: AuthProbeInput): SourceControlProviderAuth {
220221
const output = combinedAuthOutput(input);
221-
const account = matchFirst(output, [
222-
/Logged in to .* as\s+([^\s(]+)/iu,
223-
/Logged in to .* account\s+([^\s(]+)/iu,
224-
/account:\s*([^\s(]+)/iu,
225-
]);
226-
const host = parseCliHost(output);
222+
const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output));
223+
const account =
224+
authenticatedHost?.account ??
225+
matchFirst(output, [
226+
/Logged in to .* as\s+([^\s(]+)/iu,
227+
/Logged in to .* account\s+([^\s(]+)/iu,
228+
/account:\s*([^\s(]+)/iu,
229+
]);
230+
const host = authenticatedHost?.host ?? parseCliHost(output);
231+
232+
if (account) {
233+
return providerAuth({ status: "authenticated", account, host });
234+
}
227235

228236
if (input.exitCode !== 0) {
229237
return providerAuth({
@@ -233,10 +241,6 @@ function parseGitLabAuth(input: AuthProbeInput): SourceControlProviderAuth {
233241
});
234242
}
235243

236-
if (account) {
237-
return providerAuth({ status: "authenticated", account, host });
238-
}
239-
240244
return providerAuth({
241245
status: "unknown",
242246
host,

apps/server/src/sourceControl/SourceControlProvider.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ export interface SourceControlProviderContext {
1414
readonly remoteUrl: string;
1515
}
1616

17+
export type SourceControlProviderRemoteDetector = (input: {
18+
readonly cwd: string;
19+
readonly context: SourceControlProviderContext;
20+
}) => Effect.Effect<SourceControlProviderInfo | null, SourceControlProviderError>;
21+
1722
export interface SourceControlRefSelector {
1823
readonly refName: string;
1924
readonly owner?: string;

apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { assert, it } from "@effect/vitest";
22
import { DateTime, Effect, Layer, Option } from "effect";
3+
import { ChildProcessSpawner } from "effect/unstable/process";
34

45
import { GitHubCli } from "./GitHubCli.ts";
56
import { GitLabCli } from "./GitLabCli.ts";
67
import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
78
import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
89
import type { VcsDriverShape } from "../vcs/VcsDriver.ts";
10+
import * as VcsProcess from "../vcs/VcsProcess.ts";
911

1012
const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
1113

@@ -14,6 +16,7 @@ function makeRegistry(input: {
1416
readonly name: string;
1517
readonly url: string;
1618
}>;
19+
readonly gitLabAuthStatus?: string;
1720
}) {
1821
const driver = {
1922
listRemotes: () =>
@@ -49,10 +52,25 @@ function makeRegistry(input: {
4952
driver: driver as unknown as VcsDriverShape,
5053
}),
5154
});
55+
const processLayer = Layer.mock(VcsProcess.VcsProcess)({
56+
run: () =>
57+
Effect.succeed({
58+
exitCode: ChildProcessSpawner.ExitCode(input.gitLabAuthStatus ? 1 : 0),
59+
stdout: input.gitLabAuthStatus ?? "",
60+
stderr: "",
61+
stdoutTruncated: false,
62+
stderrTruncated: false,
63+
}),
64+
});
5265

5366
return SourceControlProviderRegistry.make().pipe(
5467
Effect.provide(
55-
Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}), Layer.mock(GitLabCli)({})),
68+
Layer.mergeAll(
69+
registryLayer,
70+
processLayer,
71+
Layer.mock(GitHubCli)({}),
72+
Layer.mock(GitLabCli)({}),
73+
),
5674
),
5775
);
5876
}
@@ -93,6 +111,25 @@ it.effect("routes GitLab remotes to the GitLab provider", () =>
93111
}),
94112
);
95113

114+
it.effect("routes authenticated self-hosted GitLab remotes without relying on host naming", () =>
115+
Effect.gen(function* () {
116+
const registry = yield* makeRegistry({
117+
remotes: [{ name: "origin", url: "https://self-hosted.example.test/group/project.git" }],
118+
gitLabAuthStatus: `gitlab.com
119+
x gitlab.com: API call failed: 401 Unauthorized
120+
! No token found
121+
self-hosted.example.test
122+
✓ Logged in to self-hosted.example.test as gitlab-user
123+
✓ Token found: ******
124+
`,
125+
});
126+
127+
const provider = yield* registry.resolve({ cwd: "/repo" });
128+
129+
assert.strictEqual(provider.kind, "gitlab");
130+
}),
131+
);
132+
96133
it.effect("falls back to a non-origin remote when origin is not configured", () =>
97134
Effect.gen(function* () {
98135
const registry = yield* makeRegistry({

apps/server/src/sourceControl/SourceControlProviderRegistry.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/source
66
import {
77
SourceControlProvider,
88
type SourceControlProviderContext,
9+
type SourceControlProviderRemoteDetector,
910
type SourceControlProviderShape,
1011
} from "./SourceControlProvider.ts";
1112
import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
@@ -18,6 +19,7 @@ const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5);
1819
export interface SourceControlProviderRegistration {
1920
readonly kind: SourceControlProviderKind;
2021
readonly provider: SourceControlProviderShape;
22+
readonly detectRemote?: SourceControlProviderRemoteDetector;
2123
}
2224

2325
export interface SourceControlProviderHandle {
@@ -99,6 +101,30 @@ function selectProviderContext(
99101
);
100102
}
101103

104+
const resolveUnknownProviderContext = Effect.fn(
105+
"SourceControlProviderRegistry.resolveUnknownProviderContext",
106+
)(function* (
107+
registrations: ReadonlyArray<SourceControlProviderRegistration>,
108+
context: SourceControlProviderContext | null,
109+
cwd: string,
110+
) {
111+
if (context === null || context.provider.kind !== "unknown") {
112+
return context;
113+
}
114+
115+
for (const registration of registrations) {
116+
if (!registration.detectRemote) continue;
117+
const provider = yield* registration
118+
.detectRemote({ cwd, context })
119+
.pipe(Effect.catch(() => Effect.succeed(null)));
120+
if (provider) {
121+
return { ...context, provider };
122+
}
123+
}
124+
125+
return context;
126+
});
127+
102128
export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")(
103129
function* (registrations: ReadonlyArray<SourceControlProviderRegistration>) {
104130
const vcsRegistry = yield* VcsDriverRegistry;
@@ -117,8 +143,9 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
117143
const remotes = yield* handle.driver
118144
.listRemotes(cwd)
119145
.pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error)));
146+
const context = selectProviderContext(remotes.remotes);
120147

121-
return selectProviderContext(remotes.remotes);
148+
return yield* resolveUnknownProviderContext(registrations, context, cwd);
122149
},
123150
);
124151

@@ -153,6 +180,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
153180
export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () {
154181
const github = yield* GitHubSourceControlProvider.make();
155182
const gitlab = yield* GitLabSourceControlProvider.make();
183+
const gitlabDetector = yield* GitLabSourceControlProvider.makeRemoteDetector();
156184
return yield* makeWithProviders([
157185
{
158186
kind: "github",
@@ -161,6 +189,7 @@ export const make = Effect.fn("makeSourceControlProviderRegistry")(function* ()
161189
{
162190
kind: "gitlab",
163191
provider: gitlab,
192+
detectRemote: gitlabDetector,
164193
},
165194
]);
166195
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
const HOST_LINE_PATTERN = /^([a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+(?::\d+)?)$/iu;
2+
const LOGGED_IN_PATTERN = /Logged in to .+? as\s+([^\s(]+)/iu;
3+
4+
export interface GitLabAuthStatusHost {
5+
readonly host: string;
6+
readonly account: string | null;
7+
}
8+
9+
export function parseGitLabAuthStatusHosts(text: string): ReadonlyArray<GitLabAuthStatusHost> {
10+
const hosts: GitLabAuthStatusHost[] = [];
11+
let currentHost: string | null = null;
12+
let currentLines: string[] = [];
13+
14+
const flush = () => {
15+
if (currentHost === null) return;
16+
17+
const account = LOGGED_IN_PATTERN.exec(currentLines.join("\n"))?.[1]?.trim() || null;
18+
hosts.push({ host: currentHost, account });
19+
currentHost = null;
20+
currentLines = [];
21+
};
22+
23+
for (const rawLine of text.split(/\r?\n/)) {
24+
const line = rawLine.trim();
25+
if (line.length === 0) continue;
26+
27+
const isHostLine =
28+
rawLine.length === rawLine.trimStart().length && HOST_LINE_PATTERN.test(line);
29+
if (isHostLine) {
30+
flush();
31+
currentHost = line.toLowerCase();
32+
continue;
33+
}
34+
35+
if (currentHost !== null) {
36+
currentLines.push(line);
37+
}
38+
}
39+
40+
flush();
41+
return hosts;
42+
}
43+
44+
export function findAuthenticatedGitLabHost(
45+
hosts: ReadonlyArray<GitLabAuthStatusHost>,
46+
): GitLabAuthStatusHost | undefined {
47+
return hosts.find((host) => host.account !== null);
48+
}

0 commit comments

Comments
 (0)