| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import * as Schema from "effect/Schema"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; |
| 6 | |
| 7 | import * as GitLabCli from "./GitLabCli.ts"; |
| 8 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; |
| 9 | import { |
| 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"; |
| 19 | import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; |
| 20 | |
| 21 | const decodeLinkSubject = Schema.decodeUnknownEffect( |
| 22 | Schema.fromJsonString( |
| 23 | Schema.Struct({ title: Schema.String, description: Schema.NullOr(Schema.String) }), |
| 24 | ), |
| 25 | ); |
| 26 | |
| 27 | function 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 | |
| 52 | function 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 | |
| 83 | function 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 | |
| 100 | export 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 | |
| 113 | export 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 | }); |