Skip to content
Source
Sign inSign up

GitLabSourceControlProvider.ts

385 lines14.0 KB

1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
6import {
7 parseGitRemoteHost,
8 unknownRemoteProvider as unknownHostProvider,
9} from "@t3tools/source-control-core/shared/definition";
10​
11import * as GitLabCli from "./GitLabCli.ts";
12import * as GitLabChangeRequestTemplate from "./gitLabChangeRequestTemplate.ts";
13import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
14import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
15import {
16 combinedAuthOutput,
17 firstSafeAuthLine,
18 matchFirst,
19 parseCliHost,
20 providerAuth,
21 type SourceControlAuthProbeInput,
22 type SourceControlCliDiscoverySpec,
23 type SourceControlUnknownRemoteRefinementInput,
24} from "@t3tools/source-control-core/server/discovery";
25import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
26​
27const decodeLinkSubject = Schema.decodeUnknownEffect(
28 Schema.fromJsonString(
29 Schema.Struct({ title: Schema.String, description: Schema.NullOr(Schema.String) }),
30 ),
31);
32​
33function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest {
34 return {
35 provider: SourceControlProviderKind.make("gitlab"),
36 number: summary.number,
37 title: summary.title,
38 url: summary.url,
39 baseRefName: summary.baseRefName,
40 headRefName: summary.headRefName,
41 ...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}),
42 state: summary.state ?? "open",
43 ...(summary.isDraft === true ? { isDraft: true } : {}),
44 closedAt: summary.closedAt ?? null,
45 mergedAt: summary.mergedAt ?? null,
46 updatedAt: summary.updatedAt ?? Option.none(),
47 ...(summary.isCrossRepository !== undefined
48 ? { isCrossRepository: summary.isCrossRepository }
49 : {}),
50 ...(summary.headRepositoryNameWithOwner !== undefined
51 ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
52 : {}),
53 ...(summary.headRepositoryOwnerLogin !== undefined
54 ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
55 : {}),
56 };
58​
59function parseGitLabAuth(input: SourceControlAuthProbeInput) {
60 const output = combinedAuthOutput(input);
61 const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output));
62 const account =
63 authenticatedHost?.account ??
64 matchFirst(output, [
65 /Logged in to .* as\s+([^\s(]+)/iu,
66 /Logged in to .* account\s+([^\s(]+)/iu,
67 /account:\s*([^\s(]+)/iu,
68 ]);
69 const host = authenticatedHost?.host ?? parseCliHost(output);
70​
71 if (account) {
72 return providerAuth({ status: "authenticated", account, host });
73 }
74​
75 if (input.exitCode !== 0) {
76 return providerAuth({
77 status: "unauthenticated",
78 host,
79 detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.",
80 });
81 }
82​
83 return providerAuth({
84 status: "unknown",
85 host,
86 detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.",
87 });
89​
90const GITLAB_HOST_NOT_LOGGED_IN_PATTERN =
91 /\bnot (?:been )?authenticated with glab\b|\bno gitlab instances? ha(?:s|ve) been authenticated\b/iu;
92​
93function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementInput) {
94 const host = input.context.provider.name.toLowerCase();
95 const output = combinedAuthOutput(input.auth);
96 const authenticated = parseGitLabAuthStatusHosts(output).some(
97 (entry) => entry.account !== null && entry.host === host,
98 );
99​
100 if (!authenticated) {
101 return GITLAB_HOST_NOT_LOGGED_IN_PATTERN.test(output) ? null : undefined;
102 }
103​
104 return {
105 kind: SourceControlProviderKind.make("gitlab"),
106 name: "GitLab Self-Hosted",
107 baseUrl: input.context.provider.baseUrl,
108 } as const;
110​
111/** The caller refines only an identity no host recognised, so its remote is on an unknown host. */
112function unknownRemoteProvider(remoteUrl: string) {
113 const remote = parseGitRemoteHost(remoteUrl);
114 return remote === null ? null : unknownHostProvider(remote);
116​
117// A glab login confirms a self-hosted GitLab whose hostname does not identify its provider.
118const refineRepositoryIdentity: NonNullable<
119 SourceControlProvider.SourceControlProvider["Service"]["refineRepositoryIdentity"]
120> = Effect.fn("GitLabSourceControlProvider.refineRepositoryIdentity")(function* ({
121 identity,
122 resolveContext,
123}) {
124 if (!identity.rootPath || (identity.provider !== undefined && identity.provider !== "unknown"))
125 return identity;
126 const provider = unknownRemoteProvider(identity.locator.remoteUrl);
127 if (!provider) return identity;
128 const context = yield* resolveContext({
129 cwd: identity.rootPath,
130 context: {
131 provider,
132 remoteName: identity.locator.remoteName,
133 remoteUrl: identity.locator.remoteUrl,
134 },
135 });
136 return context?.provider.kind === "gitlab"
137 ? { ...identity, provider: SourceControlProviderKind.make("gitlab") }
138 : identity;
139});
140​
141export const discovery = {
142 type: "cli",
143 kind: SourceControlProviderKind.make("gitlab"),
144 label: "GitLab",
145 executable: "glab",
146 versionArgs: ["--version"],
147 // Without --all, glab 1.79+ checks only the current host unless that is gitlab.com; older
148 // glab lists every host and rejects --all. --all waits on every host, so ask it only after
149 // the current host reports signed out.
150 authArgs: ["auth", "status"],
151 authRetryArgs: ["auth", "status", "--all"],
152 // glab host matching requires the remote's exact spelling.
153 remoteRefinementArgs: ({ provider, remoteUrl }) => {
154 const authority = /^[a-z][a-z0-9+.-]*:\/\/([^/]+)/iu.exec(remoteUrl.trim())?.[1];
155 const remoteHost = authority
156 ? authority.slice(authority.lastIndexOf("@") + 1)
157 : remoteUrl.trim().replace(/^[^@:/]+@/u, "");
158 const host = remoteHost.toLowerCase().startsWith(provider.name.toLowerCase())
159 ? remoteHost.slice(0, provider.name.length)
160 : provider.name;
161 return ["auth", "status", "--hostname", host];
162 },
163 parseAuth: parseGitLabAuth,
164 refineUnknownRemote: refineUnknownGitLabRemote,
165 installHint:
166 "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).",
167} satisfies SourceControlCliDiscoverySpec;
168​
169export const make = Effect.gen(function* () {
170 const gitlab = yield* GitLabCli.GitLabCli;
171 const { git } = yield* SourceControlHost.SourceControlHost;
172​
173 const readLinkSubject = Effect.fn("GitLabSourceControlProvider.readLinkSubject")(function* (
174 input: { readonly cwd: string; readonly url: URL },
175 endpoint: string,
176 ) {
177 const result = yield* gitlab
178 .execute({
179 cwd: input.cwd,
180 args: ["api", "--hostname", input.url.host, endpoint],
181 timeoutMs: 3_000,
182 maxOutputBytes: 32_000,
183 })
184 .pipe(
185 Effect.mapError(
186 (cause) =>
187 new SourceControlProviderError({
188 provider: SourceControlProviderKind.make("gitlab"),
189 operation: "resolveLink",
190 cwd: input.cwd,
191 detail: "The linked subject could not be read.",
192 cause,
193 }),
194 ),
195 );
196 const subject = yield* decodeLinkSubject(result.stdout).pipe(
197 Effect.mapError(
198 (cause) =>
199 new SourceControlProviderError({
200 provider: SourceControlProviderKind.make("gitlab"),
201 operation: "resolveLink.decode",
202 cwd: input.cwd,
203 detail: "The linked subject could not be read.",
204 cause,
205 }),
206 ),
207 );
208 return { title: subject.title, body: subject.description };
209 });
210​
211 return SourceControlProvider.SourceControlProvider.of({
212 kind: SourceControlProviderKind.make("gitlab"),
213 readChangeRequestTemplate: Effect.fn("GitLabSourceControlProvider.readChangeRequestTemplate")(
214 function ({ cwd, treeish }) {
215 return GitLabChangeRequestTemplate.detect(cwd, treeish, git.execute);
216 },
217 ),
218 refineRepositoryIdentity,
219 resolveLink: (input) => {
220 // Automatic enrichment must not send ambient CLI credentials to a host from message text.
221 if (input.url.host !== "gitlab.com") return undefined;
222 const match = /^\/(.+)\/-\/(merge_requests|issues)\/([1-9]\d*)(?:\/.*)?$/.exec(
223 input.url.pathname,
224 );
225 if (!match) return undefined;
226 return readLinkSubject(
227 input,
228 `projects/${encodeURIComponent(match[1]!)}/${match[2]}/${match[3]}`,
229 );
230 },
231 listChangeRequests: Effect.fn("GitLabSourceControlProvider.listChangeRequests")(
232 function (input) {
233 const source = SourceControlProvider.sourceControlRefFromInput(input);
234 return gitlab
235 .listMergeRequests({
236 cwd: input.cwd,
237 headSelector: input.headSelector,
238 ...(source ? { source } : {}),
239 state: input.state,
240 ...(input.limit !== undefined ? { limit: input.limit } : {}),
241 })
242 .pipe(
243 Effect.map((items) => items.map(toChangeRequest)),
244 Effect.mapError(
245 (error) =>
246 new SourceControlProviderError({
247 provider: SourceControlProviderKind.make("gitlab"),
248 operation: "listChangeRequests",
249 command: error.command,
250 cwd: input.cwd,
251 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
252 input.headSelector,
253 ),
254 detail: error.detail,
255 cause: error,
256 }),
257 ),
258 );
259 },
260 ),
261 getChangeRequest: Effect.fn("GitLabSourceControlProvider.getChangeRequest")(function (input) {
262 return gitlab.getMergeRequest(input).pipe(
263 Effect.map(toChangeRequest),
264 Effect.mapError(
265 (error) =>
266 new SourceControlProviderError({
267 provider: SourceControlProviderKind.make("gitlab"),
268 operation: "getChangeRequest",
269 command: error.command,
270 cwd: input.cwd,
271 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
272 input.reference,
273 ),
274 detail: error.detail,
275 cause: error,
276 }),
277 ),
278 );
279 }),
280 createChangeRequest: Effect.fn("GitLabSourceControlProvider.createChangeRequest")(
281 function (input) {
282 const source = SourceControlProvider.sourceControlRefFromInput(input);
283 return gitlab
284 .createMergeRequest({
285 cwd: input.cwd,
286 baseBranch: input.baseRefName,
287 headSelector: input.headSelector,
288 ...(source ? { source } : {}),
289 ...(input.target ? { target: input.target } : {}),
290 title: input.title,
291 bodyFile: input.bodyFile,
292 })
293 .pipe(
294 Effect.mapError(
295 (error) =>
296 new SourceControlProviderError({
297 provider: SourceControlProviderKind.make("gitlab"),
298 operation: "createChangeRequest",
299 command: error.command,
300 cwd: input.cwd,
301 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
302 input.headSelector,
303 ),
304 detail: error.detail,
305 cause: error,
306 }),
307 ),
308 );
309 },
310 ),
311 getRepositoryCloneUrls: Effect.fn("GitLabSourceControlProvider.getRepositoryCloneUrls")(
312 function (input) {
313 return gitlab.getRepositoryCloneUrls(input).pipe(
314 Effect.mapError(
315 (error) =>
316 new SourceControlProviderError({
317 provider: SourceControlProviderKind.make("gitlab"),
318 operation: "getRepositoryCloneUrls",
319 command: error.command,
320 cwd: input.cwd,
321 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
322 input.repository,
323 ),
324 detail: error.detail,
325 cause: error,
326 }),
327 ),
328 );
329 },
330 ),
331 createRepository: Effect.fn("GitLabSourceControlProvider.createRepository")(function (input) {
332 return gitlab.createRepository(input).pipe(
333 Effect.mapError(
334 (error) =>
335 new SourceControlProviderError({
336 provider: SourceControlProviderKind.make("gitlab"),
337 operation: "createRepository",
338 command: error.command,
339 cwd: input.cwd,
340 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
341 input.repository,
342 ),
343 detail: error.detail,
344 cause: error,
345 }),
346 ),
347 );
348 }),
349 getDefaultBranch: Effect.fn("GitLabSourceControlProvider.getDefaultBranch")(function (input) {
350 return gitlab.getDefaultBranch(input).pipe(
351 Effect.mapError(
352 (error) =>
353 new SourceControlProviderError({
354 provider: SourceControlProviderKind.make("gitlab"),
355 operation: "getDefaultBranch",
356 command: error.command,
357 cwd: input.cwd,
358 detail: error.detail,
359 cause: error,
360 }),
361 ),
362 );
363 }),
364 checkoutChangeRequest: Effect.fn("GitLabSourceControlProvider.checkoutChangeRequest")(
365 function (input) {
366 return gitlab.checkoutMergeRequest(input).pipe(
367 Effect.mapError(
368 (error) =>
369 new SourceControlProviderError({
370 provider: SourceControlProviderKind.make("gitlab"),
371 operation: "checkoutChangeRequest",
372 command: error.command,
373 cwd: input.cwd,
374 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
375 input.reference,
376 ),
377 detail: error.detail,
378 cause: error,
379 }),
380 ),
381 );
382 },
383 ),
384 });
385});