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";
6
7import * as GitLabCli from "./GitLabCli.ts";
8import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
9import {
10 combinedAuthOutput,
11 firstSafeAuthLine,
12 matchFirst,
13 parseCliHost,
14 providerAuth,
15 type SourceControlAuthProbeInput,
16 type SourceControlCliDiscoverySpec,
17 type SourceControlUnknownRemoteRefinementInput,
18} from "@t3tools/source-control-core/server/discovery";
19import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
20
21const decodeLinkSubject = Schema.decodeUnknownEffect(
22 Schema.fromJsonString(
23 Schema.Struct({ title: Schema.String, description: Schema.NullOr(Schema.String) }),
24 ),
25);
26
27function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest {
28 return {
29 provider: SourceControlProviderKind.make("gitlab"),
30 number: summary.number,
31 title: summary.title,
32 url: summary.url,
33 baseRefName: summary.baseRefName,
34 headRefName: summary.headRefName,
35 state: summary.state ?? "open",
36 ...(summary.isDraft === true ? { isDraft: true } : {}),
37 closedAt: summary.closedAt ?? null,
38 mergedAt: summary.mergedAt ?? null,
39 updatedAt: summary.updatedAt ?? Option.none(),
40 ...(summary.isCrossRepository !== undefined
41 ? { isCrossRepository: summary.isCrossRepository }
42 : {}),
43 ...(summary.headRepositoryNameWithOwner !== undefined
44 ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
45 : {}),
46 ...(summary.headRepositoryOwnerLogin !== undefined
47 ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
48 : {}),
49 };
50}
51
52function parseGitLabAuth(input: SourceControlAuthProbeInput) {
53 const output = combinedAuthOutput(input);
54 const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output));
55 const account =
56 authenticatedHost?.account ??
57 matchFirst(output, [
58 /Logged in to .* as\s+([^\s(]+)/iu,
59 /Logged in to .* account\s+([^\s(]+)/iu,
60 /account:\s*([^\s(]+)/iu,
61 ]);
62 const host = authenticatedHost?.host ?? parseCliHost(output);
63
64 if (account) {
65 return providerAuth({ status: "authenticated", account, host });
66 }
67
68 if (input.exitCode !== 0) {
69 return providerAuth({
70 status: "unauthenticated",
71 host,
72 detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.",
73 });
74 }
75
76 return providerAuth({
77 status: "unknown",
78 host,
79 detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.",
80 });
81}
82
83function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementInput) {
84 const host = input.context.provider.name.toLowerCase();
85 const authenticated = parseGitLabAuthStatusHosts(combinedAuthOutput(input.auth)).some(
86 (entry) => entry.account !== null && entry.host === host,
87 );
88
89 if (!authenticated) {
90 return null;
91 }
92
93 return {
94 kind: SourceControlProviderKind.make("gitlab"),
95 name: "GitLab Self-Hosted",
96 baseUrl: input.context.provider.baseUrl,
97 } as const;
98}
99
100export const discovery = {
101 type: "cli",
102 kind: SourceControlProviderKind.make("gitlab"),
103 label: "GitLab",
104 executable: "glab",
105 versionArgs: ["--version"],
106 authArgs: ["auth", "status"],
107 parseAuth: parseGitLabAuth,
108 refineUnknownRemote: refineUnknownGitLabRemote,
109 installHint:
110 "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).",
111} satisfies SourceControlCliDiscoverySpec;
112
113export const make = Effect.gen(function* () {
114 const gitlab = yield* GitLabCli.GitLabCli;
115
116 const readLinkSubject = Effect.fn("GitLabSourceControlProvider.readLinkSubject")(function* (
117 input: { readonly cwd: string; readonly url: URL },
118 endpoint: string,
119 ) {
120 const result = yield* gitlab
121 .execute({
122 cwd: input.cwd,
123 args: ["api", "--hostname", input.url.host, endpoint],
124 timeoutMs: 3_000,
125 maxOutputBytes: 32_000,
126 })
127 .pipe(
128 Effect.mapError(
129 (cause) =>
130 new SourceControlProviderError({
131 provider: SourceControlProviderKind.make("gitlab"),
132 operation: "resolveLink",
133 cwd: input.cwd,
134 detail: "The linked subject could not be read.",
135 cause,
136 }),
137 ),
138 );
139 const subject = yield* decodeLinkSubject(result.stdout).pipe(
140 Effect.mapError(
141 (cause) =>
142 new SourceControlProviderError({
143 provider: SourceControlProviderKind.make("gitlab"),
144 operation: "resolveLink.decode",
145 cwd: input.cwd,
146 detail: "The linked subject could not be read.",
147 cause,
148 }),
149 ),
150 );
151 return { title: subject.title, body: subject.description };
152 });
153
154 return SourceControlProvider.SourceControlProvider.of({
155 kind: SourceControlProviderKind.make("gitlab"),
156 resolveLink: (input) => {
157 // Automatic enrichment must not send ambient CLI credentials to a host from message text.
158 if (input.url.host !== "gitlab.com") return undefined;
159 const match = /^\/(.+)\/-\/(merge_requests|issues)\/([1-9]\d*)(?:\/.*)?$/.exec(
160 input.url.pathname,
161 );
162 if (!match) return undefined;
163 return readLinkSubject(
164 input,
165 `projects/${encodeURIComponent(match[1]!)}/${match[2]}/${match[3]}`,
166 );
167 },
168 listChangeRequests: (input) => {
169 const source = SourceControlProvider.sourceControlRefFromInput(input);
170 return gitlab
171 .listMergeRequests({
172 cwd: input.cwd,
173 headSelector: input.headSelector,
174 ...(source ? { source } : {}),
175 state: input.state,
176 ...(input.limit !== undefined ? { limit: input.limit } : {}),
177 })
178 .pipe(
179 Effect.map((items) => items.map(toChangeRequest)),
180 Effect.mapError(
181 (error) =>
182 new SourceControlProviderError({
183 provider: SourceControlProviderKind.make("gitlab"),
184 operation: "listChangeRequests",
185 command: error.command,
186 cwd: input.cwd,
187 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
188 input.headSelector,
189 ),
190 detail: error.detail,
191 cause: error,
192 }),
193 ),
194 );
195 },
196 getChangeRequest: (input) =>
197 gitlab.getMergeRequest(input).pipe(
198 Effect.map(toChangeRequest),
199 Effect.mapError(
200 (error) =>
201 new SourceControlProviderError({
202 provider: SourceControlProviderKind.make("gitlab"),
203 operation: "getChangeRequest",
204 command: error.command,
205 cwd: input.cwd,
206 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
207 input.reference,
208 ),
209 detail: error.detail,
210 cause: error,
211 }),
212 ),
213 ),
214 createChangeRequest: (input) => {
215 const source = SourceControlProvider.sourceControlRefFromInput(input);
216 return gitlab
217 .createMergeRequest({
218 cwd: input.cwd,
219 baseBranch: input.baseRefName,
220 headSelector: input.headSelector,
221 ...(source ? { source } : {}),
222 ...(input.target ? { target: input.target } : {}),
223 title: input.title,
224 bodyFile: input.bodyFile,
225 })
226 .pipe(
227 Effect.mapError(
228 (error) =>
229 new SourceControlProviderError({
230 provider: SourceControlProviderKind.make("gitlab"),
231 operation: "createChangeRequest",
232 command: error.command,
233 cwd: input.cwd,
234 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
235 input.headSelector,
236 ),
237 detail: error.detail,
238 cause: error,
239 }),
240 ),
241 );
242 },
243 getRepositoryCloneUrls: (input) =>
244 gitlab.getRepositoryCloneUrls(input).pipe(
245 Effect.mapError(
246 (error) =>
247 new SourceControlProviderError({
248 provider: SourceControlProviderKind.make("gitlab"),
249 operation: "getRepositoryCloneUrls",
250 command: error.command,
251 cwd: input.cwd,
252 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
253 input.repository,
254 ),
255 detail: error.detail,
256 cause: error,
257 }),
258 ),
259 ),
260 createRepository: (input) =>
261 gitlab.createRepository(input).pipe(
262 Effect.mapError(
263 (error) =>
264 new SourceControlProviderError({
265 provider: SourceControlProviderKind.make("gitlab"),
266 operation: "createRepository",
267 command: error.command,
268 cwd: input.cwd,
269 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
270 input.repository,
271 ),
272 detail: error.detail,
273 cause: error,
274 }),
275 ),
276 ),
277 getDefaultBranch: (input) =>
278 gitlab.getDefaultBranch(input).pipe(
279 Effect.mapError(
280 (error) =>
281 new SourceControlProviderError({
282 provider: SourceControlProviderKind.make("gitlab"),
283 operation: "getDefaultBranch",
284 command: error.command,
285 cwd: input.cwd,
286 detail: error.detail,
287 cause: error,
288 }),
289 ),
290 ),
291 checkoutChangeRequest: (input) =>
292 gitlab.checkoutMergeRequest(input).pipe(
293 Effect.mapError(
294 (error) =>
295 new SourceControlProviderError({
296 provider: SourceControlProviderKind.make("gitlab"),
297 operation: "checkoutChangeRequest",
298 command: error.command,
299 cwd: input.cwd,
300 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
301 input.reference,
302 ),
303 detail: error.detail,
304 cause: error,
305 }),
306 ),
307 ),
308 });
309});