| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import { ChildProcessSpawner } from "effect/process"; |
| 7 | |
| 8 | import * as GitLabCli from "./GitLabCli.ts"; |
| 9 | import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; |
| 10 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; |
| 11 | |
| 12 | function makeProvider(gitlab: Partial<GitLabCli.GitLabCli["Service"]>) { |
| 13 | return GitLabSourceControlProvider.make.pipe( |
| 14 | Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), |
| 15 | ); |
| 16 | } |
| 17 | |
| 18 | it.effect("maps GitLab MR summaries into provider-neutral change requests", () => |
| 19 | Effect.gen(function* () { |
| 20 | const provider = yield* makeProvider({ |
| 21 | getMergeRequest: () => |
| 22 | Effect.succeed({ |
| 23 | number: 42, |
| 24 | title: "Add GitLab provider", |
| 25 | url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", |
| 26 | baseRefName: "main", |
| 27 | headRefName: "feature/source-control", |
| 28 | state: "closed", |
| 29 | closedAt: "2026-08-23T10:00:00Z", |
| 30 | isCrossRepository: true, |
| 31 | headRepositoryNameWithOwner: "fork/t3code", |
| 32 | headRepositoryOwnerLogin: "fork", |
| 33 | }), |
| 34 | }); |
| 35 | |
| 36 | const changeRequest = yield* provider.getChangeRequest({ |
| 37 | cwd: "/repo", |
| 38 | reference: "42", |
| 39 | }); |
| 40 | |
| 41 | assert.deepStrictEqual(changeRequest, { |
| 42 | provider: SourceControlProviderKind.make("gitlab"), |
| 43 | number: 42, |
| 44 | title: "Add GitLab provider", |
| 45 | url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", |
| 46 | baseRefName: "main", |
| 47 | headRefName: "feature/source-control", |
| 48 | state: "closed", |
| 49 | closedAt: "2026-08-23T10:00:00Z", |
| 50 | mergedAt: null, |
| 51 | updatedAt: Option.none(), |
| 52 | isCrossRepository: true, |
| 53 | headRepositoryNameWithOwner: "fork/t3code", |
| 54 | headRepositoryOwnerLogin: "fork", |
| 55 | }); |
| 56 | }), |
| 57 | ); |
| 58 | |
| 59 | it.effect("adds repository context while retaining GitLab CLI causes", () => |
| 60 | Effect.gen(function* () { |
| 61 | const cause = new GitLabCli.GitLabCliCommandError({ |
| 62 | operation: "execute", |
| 63 | command: "glab", |
| 64 | cwd: "/repo", |
| 65 | cause: new Error("raw upstream detail that should remain in the cause"), |
| 66 | }); |
| 67 | const provider = yield* makeProvider({ |
| 68 | createRepository: () => Effect.fail(cause), |
| 69 | }); |
| 70 | |
| 71 | const error = yield* provider |
| 72 | .createRepository({ |
| 73 | cwd: "/repo", |
| 74 | repository: "owner/repo", |
| 75 | visibility: "private", |
| 76 | }) |
| 77 | .pipe(Effect.flip); |
| 78 | |
| 79 | assert.deepStrictEqual( |
| 80 | { |
| 81 | provider: error.provider, |
| 82 | operation: error.operation, |
| 83 | command: error.command, |
| 84 | cwd: error.cwd, |
| 85 | repository: error.repository, |
| 86 | detail: error.detail, |
| 87 | }, |
| 88 | { |
| 89 | provider: "gitlab", |
| 90 | operation: "createRepository", |
| 91 | command: "glab", |
| 92 | cwd: "/repo", |
| 93 | repository: "owner/repo", |
| 94 | detail: "GitLab CLI command failed.", |
| 95 | }, |
| 96 | ); |
| 97 | assert.strictEqual(error.cause, cause); |
| 98 | assert.equal(error.message.includes("raw upstream detail"), false); |
| 99 | }), |
| 100 | ); |
| 101 | |
| 102 | it.effect("lists GitLab MRs through provider-neutral input names", () => |
| 103 | Effect.gen(function* () { |
| 104 | let listInput: Parameters<GitLabCli.GitLabCli["Service"]["listMergeRequests"]>[0] | null = null; |
| 105 | const provider = yield* makeProvider({ |
| 106 | listMergeRequests: (input) => { |
| 107 | listInput = input; |
| 108 | return Effect.succeed([]); |
| 109 | }, |
| 110 | }); |
| 111 | |
| 112 | yield* provider.listChangeRequests({ |
| 113 | cwd: "/repo", |
| 114 | headSelector: "feature/provider", |
| 115 | state: "all", |
| 116 | limit: 10, |
| 117 | }); |
| 118 | |
| 119 | assert.deepStrictEqual(listInput, { |
| 120 | cwd: "/repo", |
| 121 | headSelector: "feature/provider", |
| 122 | state: "all", |
| 123 | limit: 10, |
| 124 | }); |
| 125 | }), |
| 126 | ); |
| 127 | |
| 128 | it.effect("creates GitLab MRs through provider-neutral input names", () => |
| 129 | Effect.gen(function* () { |
| 130 | let createInput: Parameters<GitLabCli.GitLabCli["Service"]["createMergeRequest"]>[0] | null = |
| 131 | null; |
| 132 | const provider = yield* makeProvider({ |
| 133 | createMergeRequest: (input) => { |
| 134 | createInput = input; |
| 135 | return Effect.void; |
| 136 | }, |
| 137 | }); |
| 138 | |
| 139 | yield* provider.createChangeRequest({ |
| 140 | cwd: "/repo", |
| 141 | baseRefName: "main", |
| 142 | headSelector: "owner:feature/provider", |
| 143 | title: "Provider MR", |
| 144 | bodyFile: "/tmp/body.md", |
| 145 | }); |
| 146 | |
| 147 | assert.deepStrictEqual(createInput, { |
| 148 | cwd: "/repo", |
| 149 | baseBranch: "main", |
| 150 | headSelector: "owner:feature/provider", |
| 151 | source: { |
| 152 | owner: "owner", |
| 153 | refName: "feature/provider", |
| 154 | }, |
| 155 | title: "Provider MR", |
| 156 | bodyFile: "/tmp/body.md", |
| 157 | }); |
| 158 | }), |
| 159 | ); |
| 160 | |
| 161 | it("accepts authenticated GitLab hosts when another configured host fails", () => { |
| 162 | const auth = GitLabSourceControlProvider.discovery.parseAuth({ |
| 163 | exitCode: ChildProcessSpawner.ExitCode(1), |
| 164 | stdout: `gitlab.com |
| 165 | x gitlab.com: API call failed: 401 Unauthorized |
| 166 | ! No token found |
| 167 | self-hosted.example.test |
| 168 | ✓ Logged in to self-hosted.example.test as gitlab-user |
| 169 | ✓ Token found: ****** |
| 170 | `, |
| 171 | stderr: "", |
| 172 | }); |
| 173 | |
| 174 | assert.deepStrictEqual( |
| 175 | { |
| 176 | status: auth.status, |
| 177 | account: auth.account, |
| 178 | host: auth.host, |
| 179 | }, |
| 180 | { |
| 181 | status: "authenticated", |
| 182 | account: Option.some("gitlab-user"), |
| 183 | host: Option.some("self-hosted.example.test"), |
| 184 | }, |
| 185 | ); |
| 186 | }); |
| 187 | |
| 188 | it("refines unknown GitLab remotes with mixed-case provider hosts", () => { |
| 189 | const provider = GitLabSourceControlProvider.discovery.refineUnknownRemote?.({ |
| 190 | cwd: "/repo", |
| 191 | context: { |
| 192 | provider: { |
| 193 | kind: SourceControlProviderKind.make("unknown"), |
| 194 | name: "Self-Hosted.Example.Test", |
| 195 | baseUrl: "https://Self-Hosted.Example.Test", |
| 196 | }, |
| 197 | remoteName: "origin", |
| 198 | remoteUrl: "https://Self-Hosted.Example.Test/group/project.git", |
| 199 | }, |
| 200 | auth: { |
| 201 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 202 | stdout: `self-hosted.example.test |
| 203 | ✓ Logged in to self-hosted.example.test as gitlab-user |
| 204 | ✓ Token found: ****** |
| 205 | `, |
| 206 | stderr: "", |
| 207 | }, |
| 208 | }); |
| 209 | |
| 210 | assert.deepStrictEqual(provider, { |
| 211 | kind: "gitlab", |
| 212 | name: "GitLab Self-Hosted", |
| 213 | baseUrl: "https://Self-Hosted.Example.Test", |
| 214 | }); |
| 215 | }); |
| 216 | |
| 217 | it("parses authenticated GitLab auth status hosts with ports and single-label names", () => { |
| 218 | assert.deepStrictEqual( |
| 219 | parseGitLabAuthStatusHosts(`localhost:8080 |
| 220 | ✓ Logged in to localhost:8080 as local-user |
| 221 | selfhosted |
| 222 | ✓ Logged in to selfhosted as single-label-user |
| 223 | `), |
| 224 | [ |
| 225 | { host: "localhost:8080", account: "local-user" }, |
| 226 | { host: "selfhosted", account: "single-label-user" }, |
| 227 | ], |
| 228 | ); |
| 229 | }); |
| 230 | |
| 231 | it.effect.each(["merge_requests", "issues"])( |
| 232 | "resolves %s subjects on the linked host without using the checkout", |
| 233 | (kind) => |
| 234 | Effect.gen(function* () { |
| 235 | const provider = yield* makeProvider({ |
| 236 | execute: (input) => { |
| 237 | assert.deepStrictEqual(input.args, [ |
| 238 | "api", |
| 239 | "--hostname", |
| 240 | "gitlab.com", |
| 241 | `projects/group%2Fsubgroup%2Fproject/${kind}/42`, |
| 242 | ]); |
| 243 | assert.strictEqual(input.maxOutputBytes, 32_000); |
| 244 | assert.strictEqual(input.timeoutMs, 3_000); |
| 245 | return Effect.succeed({ |
| 246 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 247 | stdout: JSON.stringify({ |
| 248 | title: "Pairing expiry", |
| 249 | description: "Preserve remote access", |
| 250 | }), |
| 251 | stderr: "", |
| 252 | stdoutTruncated: false, |
| 253 | stderrTruncated: false, |
| 254 | }); |
| 255 | }, |
| 256 | }); |
| 257 | const lookup = provider.resolveLink?.({ |
| 258 | cwd: "/unrelated", |
| 259 | url: new URL(`https://gitlab.com/group/subgroup/project/-/${kind}/42`), |
| 260 | }); |
| 261 | assert.ok(lookup); |
| 262 | assert.deepStrictEqual(yield* lookup, { |
| 263 | title: "Pairing expiry", |
| 264 | body: "Preserve remote access", |
| 265 | }); |
| 266 | assert.strictEqual( |
| 267 | provider.resolveLink?.({ |
| 268 | cwd: "/unrelated", |
| 269 | url: new URL("https://gitlab.com/owner/repo"), |
| 270 | }), |
| 271 | undefined, |
| 272 | ); |
| 273 | }), |
| 274 | ); |
| 275 | |
| 276 | it.effect.each(["read", "decode"] as const)( |
| 277 | "retains the %s failure without exposing its raw contents", |
| 278 | (stage) => |
| 279 | Effect.gen(function* () { |
| 280 | const cause = new GitLabCli.GitLabCliCommandError({ |
| 281 | command: "glab", |
| 282 | cwd: "/repo", |
| 283 | operation: "execute", |
| 284 | cause: new Error("private response text"), |
| 285 | }); |
| 286 | const provider = yield* makeProvider({ |
| 287 | execute: () => |
| 288 | stage === "read" |
| 289 | ? Effect.fail(cause) |
| 290 | : Effect.succeed({ |
| 291 | exitCode: ChildProcessSpawner.ExitCode(0), |
| 292 | stdout: "private response text", |
| 293 | stderr: "", |
| 294 | stdoutTruncated: false, |
| 295 | stderrTruncated: false, |
| 296 | }), |
| 297 | }); |
| 298 | const lookup = provider.resolveLink?.({ |
| 299 | cwd: "/repo", |
| 300 | url: new URL("https://gitlab.com/owner/repo/-/issues/42"), |
| 301 | }); |
| 302 | assert.ok(lookup); |
| 303 | const error = yield* Effect.flip(lookup); |
| 304 | assert.strictEqual(error.operation, stage === "read" ? "resolveLink" : "resolveLink.decode"); |
| 305 | assert.strictEqual(error.detail, "The linked subject could not be read."); |
| 306 | assert.notInclude(error.message, "private response text"); |
| 307 | if (stage === "read") assert.strictEqual(error.cause, cause); |
| 308 | else assert.propertyVal(error.cause, "_tag", "SchemaError"); |
| 309 | }), |
| 310 | ); |