| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | /** |
| 3 | * Pins `./GitCafeSourceControlProvider.ts`, which pass 2 creates: |
| 4 | * |
| 5 | * - `discovery: SourceControlCliDiscoverySpec` — kind `"gitcafe"`, executable `"cafe"`, |
| 6 | * `authArgs` ending in `["auth", "status", "--json"]` against `https://git.cafe/api`, and a |
| 7 | * `parseAuth` that reads cafe's `{ schemaVersion: 1, data: { host, username } }` answer and its |
| 8 | * `{ schemaVersion: 1, error: { code, status, message } }` failure envelope. |
| 9 | * - `make: Effect<SourceControlProvider["Service"], never, GitCafeApi | SourceControlHost>` with |
| 10 | * kind `"gitcafe"`, resolving the repository and host from the checkout's remote. |
| 11 | * |
| 12 | * Uses `./GitCafeApi.ts` and `./GitCafeCredentials.ts` as pinned in the sibling tests. |
| 13 | */ |
| 14 | import { assert, describe, it } from "@effect/vitest"; |
| 15 | import { GitCommandError } from "@t3tools/contracts"; |
| 16 | import * as Effect from "effect/Effect"; |
| 17 | import * as Fiber from "effect/Fiber"; |
| 18 | import * as Layer from "effect/Layer"; |
| 19 | import * as Option from "effect/Option"; |
| 20 | import * as Schema from "effect/Schema"; |
| 21 | import * as TestClock from "effect/testing/TestClock"; |
| 22 | import * as HttpClient from "effect/http/HttpClient"; |
| 23 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 24 | import { ChildProcessSpawner } from "effect/process"; |
| 25 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 26 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; |
| 27 | |
| 28 | import * as GitCafeApi from "./GitCafeApi.ts"; |
| 29 | import * as GitCafeCredentials from "./GitCafeCredentials.ts"; |
| 30 | import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; |
| 31 | |
| 32 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 33 | |
| 34 | const authAnswer = (input: { stdout?: unknown; stderr?: unknown; exitCode?: number }) => ({ |
| 35 | stdout: input.stdout === undefined ? "" : encodeJson(input.stdout), |
| 36 | stderr: input.stderr === undefined ? "" : encodeJson(input.stderr), |
| 37 | exitCode: ChildProcessSpawner.ExitCode(input.exitCode ?? 0), |
| 38 | }); |
| 39 | const failure = (code: string, status: number | null) => ({ |
| 40 | schemaVersion: 1, |
| 41 | error: { code, status, message: `Failure ${code}` }, |
| 42 | }); |
| 43 | |
| 44 | describe("GitCafeSourceControlProvider", () => { |
| 45 | it("reads a signed-in account and keeps transient failures apart from sign-out", () => { |
| 46 | const { parseAuth } = GitCafeSourceControlProvider; |
| 47 | const signedIn = parseAuth( |
| 48 | authAnswer({ stdout: { schemaVersion: 1, data: { host: "git.cafe", username: "alice" } } }), |
| 49 | ); |
| 50 | assert.strictEqual(signedIn.status, "authenticated"); |
| 51 | assert.deepStrictEqual(signedIn.account, Option.some("alice")); |
| 52 | assert.strictEqual( |
| 53 | parseAuth(authAnswer({ stderr: failure("AUTHENTICATION_REQUIRED", 401), exitCode: 1 })) |
| 54 | .status, |
| 55 | "unauthenticated", |
| 56 | ); |
| 57 | for (const [code, status] of [ |
| 58 | ["FORBIDDEN", 403], |
| 59 | ["NETWORK_ERROR", null], |
| 60 | ] as const) { |
| 61 | assert.strictEqual( |
| 62 | parseAuth(authAnswer({ stderr: failure(code, status), exitCode: 1 })).status, |
| 63 | "unknown", |
| 64 | ); |
| 65 | } |
| 66 | }); |
| 67 | |
| 68 | it.effect.each([ |
| 69 | ["https://staging.git.cafe/team/project.git", "staging.git.cafe"], |
| 70 | ["ssh@git.cafe:team/project.git", "git.cafe"], |
| 71 | ] as const)("resolves the repository from the remote %s", ([remoteUrl, host]) => { |
| 72 | const urls: Array<string> = []; |
| 73 | const client = HttpClient.make((request) => { |
| 74 | urls.push(request.url); |
| 75 | return Effect.succeed( |
| 76 | HttpClientResponse.fromWeb( |
| 77 | request, |
| 78 | new Response(encodeJson({ name: "project", defaultBranch: "trunk" })), |
| 79 | ), |
| 80 | ); |
| 81 | }); |
| 82 | const context = { |
| 83 | provider: { |
| 84 | kind: SourceControlProviderKind.make("gitcafe"), |
| 85 | name: "GitCafe", |
| 86 | baseUrl: `https://${host}`, |
| 87 | }, |
| 88 | remoteName: "origin", |
| 89 | remoteUrl, |
| 90 | }; |
| 91 | return Effect.gen(function* () { |
| 92 | const provider = yield* GitCafeSourceControlProvider.make; |
| 93 | assert.strictEqual(provider.kind, "gitcafe"); |
| 94 | assert.strictEqual(yield* provider.getDefaultBranch({ cwd: "/repo", context }), "trunk"); |
| 95 | assert.deepStrictEqual( |
| 96 | yield* provider.getRepositoryCloneUrls({ cwd: "/repo", context, repository: remoteUrl }), |
| 97 | { |
| 98 | nameWithOwner: "team/project", |
| 99 | url: `https://${host}/team/project`, |
| 100 | sshUrl: `ssh@${host}:team/project.git`, |
| 101 | }, |
| 102 | ); |
| 103 | assert.deepStrictEqual(urls, [ |
| 104 | `https://${host}/api/repos/team/project`, |
| 105 | `https://${host}/api/repos/team/project`, |
| 106 | ]); |
| 107 | }).pipe( |
| 108 | Effect.provide( |
| 109 | GitCafeApi.layer.pipe( |
| 110 | Layer.provideMerge(GitCafeCredentials.layer), |
| 111 | Layer.provideMerge(TestSourceControlHost.layer()), |
| 112 | Layer.provide( |
| 113 | Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token", CAFE_HOST: host }), |
| 114 | ), |
| 115 | Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), |
| 116 | Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), |
| 117 | ), |
| 118 | ), |
| 119 | ); |
| 120 | }); |
| 121 | |
| 122 | it.effect( |
| 123 | "reads a bare owner/name on git.cafe without a remote, and no other host's pulls", |
| 124 | () => { |
| 125 | const urls: Array<string> = []; |
| 126 | const client = HttpClient.make((request) => { |
| 127 | urls.push(request.url); |
| 128 | return Effect.succeed( |
| 129 | HttpClientResponse.fromWeb( |
| 130 | request, |
| 131 | new Response(encodeJson({ name: "project", defaultBranch: "trunk" })), |
| 132 | ), |
| 133 | ); |
| 134 | }); |
| 135 | return Effect.gen(function* () { |
| 136 | const provider = yield* GitCafeSourceControlProvider.make; |
| 137 | // The checkout has no remote at all, as when a repository is about to be created. |
| 138 | assert.deepStrictEqual( |
| 139 | yield* provider.getRepositoryCloneUrls({ cwd: "/repo", repository: "team/project" }), |
| 140 | { |
| 141 | nameWithOwner: "team/project", |
| 142 | url: "https://git.cafe/team/project", |
| 143 | sshUrl: "ssh@git.cafe:team/project.git", |
| 144 | }, |
| 145 | ); |
| 146 | const foreign = yield* provider |
| 147 | .getChangeRequest({ |
| 148 | cwd: "/repo", |
| 149 | reference: "https://example.com/team/project/pulls/42", |
| 150 | }) |
| 151 | .pipe(Effect.flip); |
| 152 | assert.strictEqual(foreign._tag, "SourceControlProviderError"); |
| 153 | assert.deepStrictEqual(urls, ["https://git.cafe/api/repos/team/project"]); |
| 154 | }).pipe( |
| 155 | Effect.provide( |
| 156 | GitCafeApi.layer.pipe( |
| 157 | Layer.provideMerge(GitCafeCredentials.layer), |
| 158 | Layer.provideMerge( |
| 159 | TestSourceControlHost.layer({ |
| 160 | git: { |
| 161 | resolvePrimaryRemoteName: () => |
| 162 | Effect.fail( |
| 163 | new GitCommandError({ |
| 164 | operation: "resolvePrimaryRemoteName", |
| 165 | command: "git remote", |
| 166 | cwd: "/repo", |
| 167 | detail: "No git remote is configured.", |
| 168 | }), |
| 169 | ), |
| 170 | }, |
| 171 | }), |
| 172 | ), |
| 173 | Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), |
| 174 | Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), |
| 175 | Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), |
| 176 | ), |
| 177 | ), |
| 178 | ); |
| 179 | }, |
| 180 | ); |
| 181 | |
| 182 | it.effect.each([ |
| 183 | ["no context", undefined, "https://git.cafe/fork/project.git"], |
| 184 | ["an HTTPS remote", "https://git.cafe/team/project.git", "https://git.cafe/fork/project.git"], |
| 185 | ["an SSH remote", "ssh@git.cafe:team/project.git", "ssh@git.cafe:fork/project.git"], |
| 186 | ["an ssh:// remote", "ssh://ssh@git.cafe/team/project.git", "ssh@git.cafe:fork/project.git"], |
| 187 | [ |
| 188 | "an HTTPS remote with a user", |
| 189 | "https://alice@git.cafe/team/project.git", |
| 190 | "https://git.cafe/fork/project.git", |
| 191 | ], |
| 192 | ] as const)("checks a fork out over the transport of %s", ([, remoteUrl, expected]) => { |
| 193 | const remotes: Array<string> = []; |
| 194 | const client = HttpClient.make((request) => |
| 195 | Effect.succeed( |
| 196 | HttpClientResponse.fromWeb( |
| 197 | request, |
| 198 | new Response( |
| 199 | encodeJson({ |
| 200 | number: 7, |
| 201 | title: "Change", |
| 202 | state: "open", |
| 203 | draft: false, |
| 204 | sourceBranch: "feature", |
| 205 | targetBranch: "main", |
| 206 | headOid: null, |
| 207 | updatedAt: null, |
| 208 | sourceRepo: { owner: "fork", name: "project" }, |
| 209 | closedAt: null, |
| 210 | mergedAt: null, |
| 211 | }), |
| 212 | ), |
| 213 | ), |
| 214 | ), |
| 215 | ); |
| 216 | const context = |
| 217 | remoteUrl === undefined |
| 218 | ? undefined |
| 219 | : { |
| 220 | provider: { |
| 221 | kind: SourceControlProviderKind.make("gitcafe"), |
| 222 | name: "GitCafe", |
| 223 | baseUrl: "https://git.cafe", |
| 224 | }, |
| 225 | remoteName: "origin", |
| 226 | remoteUrl, |
| 227 | }; |
| 228 | return Effect.gen(function* () { |
| 229 | const provider = yield* GitCafeSourceControlProvider.make; |
| 230 | yield* provider.checkoutChangeRequest({ |
| 231 | cwd: "/repo", |
| 232 | reference: "https://git.cafe/team/project/pulls/7", |
| 233 | ...(context === undefined ? {} : { context }), |
| 234 | }); |
| 235 | assert.deepStrictEqual(remotes, [expected]); |
| 236 | }).pipe( |
| 237 | Effect.provide( |
| 238 | GitCafeApi.layer.pipe( |
| 239 | Layer.provideMerge(GitCafeCredentials.layer), |
| 240 | Layer.provideMerge( |
| 241 | TestSourceControlHost.layer({ |
| 242 | git: { |
| 243 | ensureRemote: (input) => { |
| 244 | remotes.push(input.url); |
| 245 | return Effect.succeed("gitcafe"); |
| 246 | }, |
| 247 | listLocalBranchNames: () => Effect.succeed([]), |
| 248 | fetchRemoteBranch: () => Effect.void, |
| 249 | setBranchUpstream: () => Effect.void, |
| 250 | switchRef: () => Effect.succeed({ refName: null }), |
| 251 | }, |
| 252 | }), |
| 253 | ), |
| 254 | Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), |
| 255 | Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), |
| 256 | Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), |
| 257 | ), |
| 258 | ), |
| 259 | ); |
| 260 | }); |
| 261 | |
| 262 | it.effect("waits for a created repository's admission before handing out its URLs", () => { |
| 263 | const urls: Array<string> = []; |
| 264 | const states = ["running", "complete"]; |
| 265 | const client = HttpClient.make((request) => { |
| 266 | urls.push(request.url); |
| 267 | return Effect.succeed( |
| 268 | HttpClientResponse.fromWeb( |
| 269 | request, |
| 270 | new Response( |
| 271 | encodeJson({ |
| 272 | repoId: "repo_1", |
| 273 | state: states.shift(), |
| 274 | owner: "team", |
| 275 | name: "project", |
| 276 | }), |
| 277 | ), |
| 278 | ), |
| 279 | ); |
| 280 | }); |
| 281 | return Effect.gen(function* () { |
| 282 | const provider = yield* GitCafeSourceControlProvider.make; |
| 283 | const created = yield* provider |
| 284 | .createRepository({ cwd: "/repo", repository: "team/project", visibility: "private" }) |
| 285 | .pipe(Effect.forkChild); |
| 286 | yield* TestClock.adjust("1 second"); |
| 287 | assert.deepStrictEqual(yield* Fiber.join(created), { |
| 288 | nameWithOwner: "team/project", |
| 289 | url: "https://git.cafe/team/project", |
| 290 | sshUrl: "ssh@git.cafe:team/project.git", |
| 291 | }); |
| 292 | assert.deepStrictEqual(urls, [ |
| 293 | "https://git.cafe/api/orgs/team/admissions/repo_1", |
| 294 | "https://git.cafe/api/orgs/team/admissions/repo_1", |
| 295 | ]); |
| 296 | }).pipe( |
| 297 | Effect.provide( |
| 298 | GitCafeApi.layer.pipe( |
| 299 | Layer.provideMerge(GitCafeCredentials.layer), |
| 300 | Layer.provideMerge( |
| 301 | TestSourceControlHost.layer({ |
| 302 | process: { |
| 303 | run: () => |
| 304 | Effect.succeed( |
| 305 | TestSourceControlHost.processOutput( |
| 306 | encodeJson({ |
| 307 | schemaVersion: 1, |
| 308 | data: { |
| 309 | resource: { |
| 310 | repoId: "repo_1", |
| 311 | state: "running", |
| 312 | owner: "team", |
| 313 | name: "project", |
| 314 | }, |
| 315 | }, |
| 316 | }), |
| 317 | ), |
| 318 | ), |
| 319 | }, |
| 320 | git: { |
| 321 | resolvePrimaryRemoteName: () => |
| 322 | Effect.fail( |
| 323 | new GitCommandError({ |
| 324 | operation: "resolvePrimaryRemoteName", |
| 325 | command: "git remote", |
| 326 | cwd: "/repo", |
| 327 | detail: "No git remote is configured.", |
| 328 | }), |
| 329 | ), |
| 330 | }, |
| 331 | }), |
| 332 | ), |
| 333 | Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), |
| 334 | Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), |
| 335 | Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), |
| 336 | ), |
| 337 | ), |
| 338 | ); |
| 339 | }); |
| 340 | }); |