| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import * as DateTime from "effect/DateTime"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import * as Redacted from "effect/Redacted"; |
| 9 | import { ChildProcessSpawner } from "effect/process"; |
| 10 | import { VcsRepositoryDetectionError } from "@t3tools/contracts"; |
| 11 | |
| 12 | import * as ServerSettings from "../serverSettings.ts"; |
| 13 | import * as ServerConfig from "../config.ts"; |
| 14 | import type * as VcsDriver from "../vcs/VcsDriver.ts"; |
| 15 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 16 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 17 | import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; |
| 18 | import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; |
| 19 | import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; |
| 20 | import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; |
| 21 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 22 | import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; |
| 23 | import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; |
| 24 | import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; |
| 25 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| 26 | import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; |
| 27 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 28 | import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; |
| 29 | import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; |
| 30 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 31 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; |
| 32 | |
| 33 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); |
| 34 | |
| 35 | const processOutput = ( |
| 36 | stdout: string, |
| 37 | options?: { |
| 38 | readonly stderr?: string; |
| 39 | readonly exitCode?: ChildProcessSpawner.ExitCode; |
| 40 | }, |
| 41 | ): VcsProcess.VcsProcessOutput => ({ |
| 42 | exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), |
| 43 | stdout, |
| 44 | stderr: options?.stderr ?? "", |
| 45 | stdoutTruncated: false, |
| 46 | stderrTruncated: false, |
| 47 | }); |
| 48 | |
| 49 | function makeRegistry(input: { |
| 50 | readonly remotes: ReadonlyArray<{ |
| 51 | readonly name: string; |
| 52 | readonly url: string; |
| 53 | }>; |
| 54 | readonly process?: Partial<VcsProcess.VcsProcess["Service"]>; |
| 55 | readonly githubApi?: Partial<GitHubApi.GitHubApi["Service"]>; |
| 56 | readonly gitlab?: Partial<GitLabCli.GitLabCli["Service"]>; |
| 57 | readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; |
| 58 | }) { |
| 59 | const driver = { |
| 60 | listRemotes: () => |
| 61 | Effect.succeed({ |
| 62 | remotes: input.remotes.map((remote) => ({ |
| 63 | ...remote, |
| 64 | pushUrl: Option.none(), |
| 65 | isPrimary: remote.name === "origin", |
| 66 | })), |
| 67 | freshness: { |
| 68 | source: "live-local" as const, |
| 69 | observedAt: TEST_EPOCH, |
| 70 | expiresAt: Option.none(), |
| 71 | }, |
| 72 | }), |
| 73 | } satisfies Partial<VcsDriver.VcsDriver["Service"]>; |
| 74 | |
| 75 | const layerRegistry = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ |
| 76 | get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriver["Service"]), |
| 77 | resolve: |
| 78 | input.resolve ?? |
| 79 | (() => |
| 80 | Effect.succeed({ |
| 81 | kind: "git", |
| 82 | repository: { |
| 83 | kind: "git", |
| 84 | rootPath: "/repo", |
| 85 | metadataPath: null, |
| 86 | freshness: { |
| 87 | source: "live-local" as const, |
| 88 | observedAt: TEST_EPOCH, |
| 89 | expiresAt: Option.none(), |
| 90 | }, |
| 91 | }, |
| 92 | driver: driver as unknown as VcsDriver.VcsDriver["Service"], |
| 93 | })), |
| 94 | }); |
| 95 | |
| 96 | const layerProcess = Layer.mock(VcsProcess.VcsProcess)({ |
| 97 | run: () => Effect.succeed(processOutput("")), |
| 98 | ...input.process, |
| 99 | }); |
| 100 | |
| 101 | return SourceControlProviderRegistry.make.pipe( |
| 102 | Effect.provide( |
| 103 | Layer.mergeAll( |
| 104 | NodeServices.layer, |
| 105 | layerRegistry, |
| 106 | layerProcess, |
| 107 | ServerSourceControlHost.layer.pipe( |
| 108 | Layer.provide( |
| 109 | Layer.mergeAll( |
| 110 | layerProcess, |
| 111 | layerRegistry, |
| 112 | ServerSettings.ServerSettingsService.layerTest(), |
| 113 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), |
| 114 | ), |
| 115 | ), |
| 116 | ), |
| 117 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), |
| 118 | Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), |
| 119 | Layer.mock(BitbucketApi.BitbucketApi)({}), |
| 120 | Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), |
| 121 | ServerSettings.ServerSettingsService.layerTest(), |
| 122 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), |
| 123 | Layer.mock(GitHubApi.GitHubApi)({ |
| 124 | // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed. |
| 125 | credential: (host) => Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })), |
| 126 | ...input.githubApi, |
| 127 | }), |
| 128 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), |
| 129 | Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), |
| 130 | Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), |
| 131 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), |
| 132 | Layer.mock(GitCafeApi.GitCafeApi)({}), |
| 133 | Layer.mock(GitCafeCredentials.GitCafeCredentials)({ |
| 134 | cliEnv: () => Effect.succeed({}), |
| 135 | }), |
| 136 | ServerConfig.layerTest(process.cwd(), { |
| 137 | prefix: "t3-source-control-registry-test-", |
| 138 | }).pipe(Layer.provide(NodeServices.layer)), |
| 139 | ), |
| 140 | ), |
| 141 | ); |
| 142 | } |
| 143 | |
| 144 | it.effect("routes GitHub remotes to the GitHub provider", () => |
| 145 | Effect.gen(function* () { |
| 146 | const registry = yield* makeRegistry({ |
| 147 | remotes: [{ name: "origin", url: "git@github.com:pingdotgg/t3code.git" }], |
| 148 | }); |
| 149 | |
| 150 | const provider = yield* registry.resolve({ cwd: "/repo" }); |
| 151 | |
| 152 | assert.strictEqual(provider.kind, "github"); |
| 153 | }), |
| 154 | ); |
| 155 | |
| 156 | it.effect("routes directly by provider kind for remote-first workflows", () => |
| 157 | Effect.gen(function* () { |
| 158 | const registry = yield* makeRegistry({ |
| 159 | remotes: [], |
| 160 | }); |
| 161 | |
| 162 | const provider = yield* registry.get(SourceControlProviderKind.make("github")); |
| 163 | |
| 164 | assert.strictEqual(provider.kind, "github"); |
| 165 | }), |
| 166 | ); |
| 167 | |
| 168 | it.effect("includes the request cwd when an unregistered provider is used", () => |
| 169 | Effect.gen(function* () { |
| 170 | const registry = yield* makeRegistry({ remotes: [] }); |
| 171 | const provider = yield* registry.get(SourceControlProviderKind.make("unknown")); |
| 172 | |
| 173 | const error = yield* provider |
| 174 | .getChangeRequest({ cwd: "/repo", reference: "#42" }) |
| 175 | .pipe(Effect.flip); |
| 176 | |
| 177 | assert.strictEqual(error.provider, "unknown"); |
| 178 | assert.strictEqual(error.operation, "getChangeRequest"); |
| 179 | assert.strictEqual(error.cwd, "/repo"); |
| 180 | assert.strictEqual(error.reference, "#42"); |
| 181 | }), |
| 182 | ); |
| 183 | |
| 184 | it.effect("retains VCS detection failures with structured cwd context", () => |
| 185 | Effect.gen(function* () { |
| 186 | const cause = new VcsRepositoryDetectionError({ |
| 187 | operation: "resolve", |
| 188 | cwd: "/repo", |
| 189 | detail: "raw VCS detection failure", |
| 190 | cause: new Error("raw nested failure"), |
| 191 | }); |
| 192 | const registry = yield* makeRegistry({ |
| 193 | remotes: [], |
| 194 | resolve: () => Effect.fail(cause), |
| 195 | }); |
| 196 | |
| 197 | const error = yield* registry.resolve({ cwd: "/repo" }).pipe(Effect.flip); |
| 198 | |
| 199 | assert.strictEqual(error.provider, "unknown"); |
| 200 | assert.strictEqual(error.operation, "detectProvider"); |
| 201 | assert.strictEqual(error.cwd, "/repo"); |
| 202 | assert.strictEqual(error.detail, "Failed to detect source control provider."); |
| 203 | assert.strictEqual(error.cause, cause); |
| 204 | assert.equal(error.message.includes(cause.message), false); |
| 205 | }), |
| 206 | ); |
| 207 | |
| 208 | it.effect("routes GitLab remotes to the GitLab provider", () => |
| 209 | Effect.gen(function* () { |
| 210 | const registry = yield* makeRegistry({ |
| 211 | remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }], |
| 212 | }); |
| 213 | |
| 214 | const provider = yield* registry.resolve({ cwd: "/repo" }); |
| 215 | |
| 216 | assert.strictEqual(provider.kind, "gitlab"); |
| 217 | }), |
| 218 | ); |
| 219 | |
| 220 | it.effect("routes authenticated self-hosted GitLab remotes without relying on host naming", () => |
| 221 | Effect.gen(function* () { |
| 222 | const registry = yield* makeRegistry({ |
| 223 | remotes: [{ name: "origin", url: "https://self-hosted.example.test/group/project.git" }], |
| 224 | process: { |
| 225 | run: () => |
| 226 | Effect.succeed( |
| 227 | processOutput( |
| 228 | `gitlab.com |
| 229 | x gitlab.com: API call failed: 401 Unauthorized |
| 230 | ! No token found |
| 231 | self-hosted.example.test |
| 232 | ✓ Logged in to self-hosted.example.test as gitlab-user |
| 233 | ✓ Token found: ****** |
| 234 | `, |
| 235 | { exitCode: ChildProcessSpawner.ExitCode(1) }, |
| 236 | ), |
| 237 | ), |
| 238 | }, |
| 239 | }); |
| 240 | |
| 241 | const provider = yield* registry.resolve({ cwd: "/repo" }); |
| 242 | |
| 243 | assert.strictEqual(provider.kind, "gitlab"); |
| 244 | }), |
| 245 | ); |
| 246 | |
| 247 | it.effect("refines the caller-selected remote instead of choosing another configured remote", () => |
| 248 | Effect.gen(function* () { |
| 249 | const registry = yield* makeRegistry({ |
| 250 | remotes: [{ name: "origin", url: "git@github.com:fork/project.git" }], |
| 251 | process: { |
| 252 | run: () => |
| 253 | Effect.succeed( |
| 254 | processOutput(`self-hosted.example.test |
| 255 | ✓ Logged in to self-hosted.example.test as gitlab-user |
| 256 | `), |
| 257 | ), |
| 258 | }, |
| 259 | }); |
| 260 | |
| 261 | const handle = yield* registry.resolveHandle({ |
| 262 | cwd: "/repo", |
| 263 | context: { |
| 264 | provider: { |
| 265 | kind: SourceControlProviderKind.make("unknown"), |
| 266 | name: "self-hosted.example.test", |
| 267 | baseUrl: "https://self-hosted.example.test", |
| 268 | }, |
| 269 | remoteName: "upstream", |
| 270 | remoteUrl: "https://self-hosted.example.test/group/project.git", |
| 271 | }, |
| 272 | }); |
| 273 | |
| 274 | assert.strictEqual(handle.context?.provider.kind, "gitlab"); |
| 275 | assert.strictEqual(handle.context?.remoteName, "upstream"); |
| 276 | }), |
| 277 | ); |
| 278 | |
| 279 | it.effect("routes authenticated self-hosted GitLab remotes on non-standard ports", () => |
| 280 | Effect.gen(function* () { |
| 281 | const registry = yield* makeRegistry({ |
| 282 | remotes: [{ name: "origin", url: "https://self-hosted.example.test:8443/group/project.git" }], |
| 283 | process: { |
| 284 | run: () => |
| 285 | Effect.succeed( |
| 286 | processOutput( |
| 287 | `self-hosted.example.test:8443 |
| 288 | ✓ Logged in to self-hosted.example.test:8443 as gitlab-user |
| 289 | ✓ Token found: ****** |
| 290 | `, |
| 291 | ), |
| 292 | ), |
| 293 | }, |
| 294 | }); |
| 295 | |
| 296 | const provider = yield* registry.resolve({ cwd: "/repo" }); |
| 297 | |
| 298 | assert.strictEqual(provider.kind, "gitlab"); |
| 299 | }), |
| 300 | ); |
| 301 | |
| 302 | it.effect("routes Bitbucket remotes to the Bitbucket provider", () => |
| 303 | Effect.gen(function* () { |
| 304 | const registry = yield* makeRegistry({ |
| 305 | remotes: [{ name: "origin", url: "git@bitbucket.org:pingdotgg/t3code.git" }], |
| 306 | }); |
| 307 | |
| 308 | const provider = yield* registry.resolve({ cwd: "/repo" }); |
| 309 | |
| 310 | assert.strictEqual(provider.kind, "bitbucket"); |
| 311 | }), |
| 312 | ); |
| 313 | |
| 314 | it.effect("routes Azure DevOps remotes to the Azure DevOps provider", () => |
| 315 | Effect.gen(function* () { |
| 316 | const registry = yield* makeRegistry({ |
| 317 | remotes: [{ name: "origin", url: "https://dev.azure.com/acme/project/_git/repo" }], |
| 318 | }); |
| 319 | |
| 320 | const provider = yield* registry.resolve({ cwd: "/repo" }); |
| 321 | |
| 322 | assert.strictEqual(provider.kind, "azure-devops"); |
| 323 | }), |
| 324 | ); |
| 325 | |
| 326 | it.effect("falls back to a non-origin remote when origin is not configured", () => |
| 327 | Effect.gen(function* () { |
| 328 | const registry = yield* makeRegistry({ |
| 329 | remotes: [{ name: "upstream", url: "https://dev.azure.com/acme/project/_git/repo" }], |
| 330 | }); |
| 331 | |
| 332 | const provider = yield* registry.resolve({ cwd: "/repo" }); |
| 333 | |
| 334 | assert.strictEqual(provider.kind, "azure-devops"); |
| 335 | }), |
| 336 | ); |
| 337 | |
| 338 | it.effect( |
| 339 | "routes linked subjects by URL independently of the checkout and skips unsupported links", |
| 340 | () => |
| 341 | Effect.gen(function* () { |
| 342 | const registry = yield* makeRegistry({ |
| 343 | remotes: [{ name: "origin", url: "https://github.com/unrelated/checkout.git" }], |
| 344 | githubApi: { |
| 345 | rest: () => |
| 346 | Effect.succeed({ |
| 347 | status: 200, |
| 348 | headers: {}, |
| 349 | body: JSON.stringify({ title: "GitHub issue", body: null }), |
| 350 | truncated: false, |
| 351 | invalidUtf8: false, |
| 352 | }), |
| 353 | }, |
| 354 | gitlab: { |
| 355 | execute: () => |
| 356 | Effect.succeed( |
| 357 | processOutput(JSON.stringify({ title: "GitLab MR", description: "Nested project" })), |
| 358 | ), |
| 359 | }, |
| 360 | }); |
| 361 | for (const [url, expected] of [ |
| 362 | ["https://github.com/team/project/issues/1", { title: "GitHub issue", body: null }], |
| 363 | [ |
| 364 | "https://gitlab.com/team/sub/project/-/merge_requests/2", |
| 365 | { title: "GitLab MR", body: "Nested project" }, |
| 366 | ], |
| 367 | ] as const) { |
| 368 | const lookup = registry.resolveLink({ cwd: "/unrelated", url: new URL(url) }); |
| 369 | assert.ok(lookup); |
| 370 | assert.deepStrictEqual(yield* lookup, expected); |
| 371 | } |
| 372 | for (const url of [ |
| 373 | "https://example.test/team/project/issues/1", |
| 374 | "https://github.attacker.test/team/project/issues/1", |
| 375 | "https://gitlab.attacker.test/team/project/-/issues/1", |
| 376 | "https://github.com/team/project", |
| 377 | "https://codeberg.org/team/project/issues/1", |
| 378 | "https://bitbucket.org/team/project/pull-requests/1", |
| 379 | "https://dev.azure.com/org/project/_git/repo/pullrequest/1", |
| 380 | "http://github.com/team/project/issues/1", |
| 381 | "https://user:secret@github.com/team/project/issues/1", |
| 382 | ]) { |
| 383 | assert.strictEqual( |
| 384 | registry.resolveLink({ cwd: "/unrelated", url: new URL(url) }), |
| 385 | undefined, |
| 386 | ); |
| 387 | } |
| 388 | }).pipe(Effect.scoped), |
| 389 | ); |
| 390 | |
| 391 | it.effect.each([ |
| 392 | { name: "a credential", credential: "found", expected: "github" }, |
| 393 | // Claimed so the error says the host is turned off, not that it is unsupported. |
| 394 | { name: "a host turned off in Settings", credential: "disabled", expected: "github" }, |
| 395 | { name: "no credential", credential: "missing", expected: "unknown" }, |
| 396 | ] as const)("resolves custom-host remotes with $name", (scenario) => |
| 397 | Effect.gen(function* () { |
| 398 | const hosts: Array<string> = []; |
| 399 | const commands: Array<string> = []; |
| 400 | const registry = yield* makeRegistry({ |
| 401 | remotes: [{ name: "origin", url: "git@code.example.test:team/project.git" }], |
| 402 | process: { |
| 403 | run: ({ command }) => { |
| 404 | commands.push(command); |
| 405 | return Effect.succeed(processOutput("")); |
| 406 | }, |
| 407 | }, |
| 408 | githubApi: { |
| 409 | credential: (host) => { |
| 410 | hosts.push(host); |
| 411 | return scenario.credential === "found" |
| 412 | ? Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:fingerprint` }) |
| 413 | : scenario.credential === "disabled" |
| 414 | ? Effect.fail(new GitHubCredentials.GitHubHostDisabledError({ host })) |
| 415 | : Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })); |
| 416 | }, |
| 417 | }, |
| 418 | }); |
| 419 | const handle = yield* registry.resolveHandle({ cwd: "/repo" }); |
| 420 | assert.strictEqual(handle.provider.kind, SourceControlProviderKind.make(scenario.expected)); |
| 421 | assert.strictEqual(handle.context?.provider.baseUrl, "https://code.example.test"); |
| 422 | assert.deepStrictEqual(hosts, ["code.example.test"]); |
| 423 | // Recognition goes through the credential layer; gh is only one of its sources. |
| 424 | assert.notInclude(commands, "gh"); |
| 425 | }), |
| 426 | ); |
| 427 | |
| 428 | it.effect("skips GitHub discovery for the identity resolver's empty base URL", () => |
| 429 | Effect.gen(function* () { |
| 430 | const hosts: string[] = []; |
| 431 | const remoteUrl = "git@githubenterprise.dev.example.com:team/workspace.git"; |
| 432 | const registry = yield* makeRegistry({ |
| 433 | remotes: [{ name: "origin", url: remoteUrl }], |
| 434 | githubApi: { |
| 435 | credential: (host) => { |
| 436 | hosts.push(host); |
| 437 | return Effect.succeed({ |
| 438 | token: Redacted.make("token"), |
| 439 | fingerprint: `${host}:fingerprint`, |
| 440 | }); |
| 441 | }, |
| 442 | }, |
| 443 | }); |
| 444 | const context = { |
| 445 | provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" }, |
| 446 | remoteName: "origin", |
| 447 | remoteUrl, |
| 448 | }; |
| 449 | const unresolved = yield* registry.resolveHandle({ cwd: "/repo", context }); |
| 450 | assert.deepStrictEqual(unresolved.context, context); |
| 451 | assert.deepStrictEqual(hosts, []); |
| 452 | |
| 453 | const resolved = yield* registry.resolveHandle({ cwd: "/repo" }); |
| 454 | assert.strictEqual(resolved.provider.kind, "github"); |
| 455 | assert.strictEqual( |
| 456 | resolved.context?.provider.baseUrl, |
| 457 | "https://githubenterprise.dev.example.com", |
| 458 | ); |
| 459 | assert.deepStrictEqual(hosts, ["githubenterprise.dev.example.com"]); |
| 460 | }), |
| 461 | ); |