1import { 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 */
14import { assert, describe, it } from "@effect/vitest";
15import { GitCommandError } from "@t3tools/contracts";
16import * as Effect from "effect/Effect";
17import * as Fiber from "effect/Fiber";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as Schema from "effect/Schema";
21import * as TestClock from "effect/testing/TestClock";
22import * as HttpClient from "effect/http/HttpClient";
23import * as HttpClientResponse from "effect/http/HttpClientResponse";
24import { ChildProcessSpawner } from "effect/process";
25import * as HostProcess from "@t3tools/shared/HostProcess";
26import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
27
28import * as GitCafeApi from "./GitCafeApi.ts";
29import * as GitCafeCredentials from "./GitCafeCredentials.ts";
30import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts";
31
32const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
33
34const 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});
39const failure = (code: string, status: number | null) => ({
40 schemaVersion: 1,
41 error: { code, status, message: `Failure ${code}` },
42});
43
44describe("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});