1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";
6import * as Schedule from "effect/Schedule";
7import {
8 PositiveInt,
9 SourceControlProviderError,
10 TrimmedNonEmptyString,
11 type ChangeRequest,
12} from "@t3tools/contracts";
13import {
14 probeSourceControlProvider,
15 providerAuth,
16 type SourceControlCliDiscoverySpec,
17 type SourceControlManagedCliDiscoverySpec,
18} from "@t3tools/source-control-core/server/discovery";
19import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
20import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
21
22import * as GitCafeApi from "./GitCafeApi.ts";
23import * as GitCafeCredentials from "./GitCafeCredentials.ts";
24import * as GitCafeHosts from "./gitCafeHosts.ts";
25
26/** Discovery reports the production account; repository operations pick their own host. */
27const AUTH_HOST = "git.cafe";
28/** `cafe repo create --clone` waits this long, polling at this pace, for admission to settle. */
29const ADMISSION_TIMEOUT = "30 seconds";
30const ADMISSION_POLL_INTERVAL = "500 millis";
31const cliArgs = (host: string) => [
32 "--host",
33 `https://${host}/api`,
34 "--no-input",
35 "--no-update-check",
36];
37
38const decodeAuth = Schema.decodeUnknownResult(
39 Schema.fromJsonString(
40 Schema.Struct({
41 schemaVersion: Schema.Literal(1),
42 data: Schema.Struct({ host: Schema.String, username: TrimmedNonEmptyString }),
43 }),
44 ),
45);
46/** `cafe`'s failure envelope, which it prints on its own line after any raw problem body. */
47const decodeFailure = Schema.decodeUnknownResult(
48 Schema.fromJsonString(
49 Schema.Struct({
50 schemaVersion: Schema.Literal(1),
51 error: Schema.Struct({
52 code: Schema.String,
53 message: Schema.String,
54 status: Schema.NullOr(Schema.Finite),
55 }),
56 }),
57 ),
58);
59const cliFailure = (output: string) =>
60 [output.trim(), ...output.split(/\r?\n/u).toReversed()]
61 .map((line) => decodeFailure(line.trim()))
62 .find(Result.isSuccess)?.success.error;
63
64const cliDiscovery = {
65 type: "cli",
66 kind: SourceControlProviderKind.make("gitcafe"),
67 label: "GitCafe",
68 executable: "cafe",
69 versionArgs: ["--version"],
70 authArgs: [...cliArgs(AUTH_HOST), "auth", "status", "--json"],
71 parseAuth: (input) => {
72 const signedIn = decodeAuth(input.stdout.trim());
73 if (input.exitCode === 0 && Result.isSuccess(signedIn))
74 return providerAuth({
75 status: "authenticated",
76 host: AUTH_HOST,
77 account: signedIn.success.data.username,
78 });
79 const failure = cliFailure(input.stderr) ?? cliFailure(input.stdout);
80 if (failure)
81 return providerAuth({
82 // Only a refusal says the login is gone; a forbidden or offline answer says nothing.
83 status:
84 failure.code === "AUTHENTICATION_REQUIRED" || failure.status === 401
85 ? "unauthenticated"
86 : "unknown",
87 host: AUTH_HOST,
88 detail: failure.message,
89 });
90 return providerAuth({
91 status: "unknown",
92 host: AUTH_HOST,
93 detail: `GitCafe authentication status could not be read. Run \`cafe auth login --host https://${AUTH_HOST}/api\`.`,
94 });
95 },
96 installHint: `Install the GitCafe CLI with \`bun install -g @gitcafe/cli\`, then run \`cafe auth login --host https://${AUTH_HOST}/api\`, save a token in Settings, or set CAFE_TOKEN on the server.`,
97} satisfies SourceControlCliDiscoverySpec;
98
99/** `cafe auth status`'s reading, exposed for tests. */
100export const parseAuth = cliDiscovery.parseAuth;
101
102/** Probes `cafe` as the CLI spec does, with a token saved in Settings handed to it. */
103export const makeDiscovery = Effect.gen(function* () {
104 const { process } = yield* SourceControlHost.SourceControlHost;
105 const credentials = yield* GitCafeCredentials.GitCafeCredentials;
106 return {
107 type: "managed-cli",
108 kind: cliDiscovery.kind,
109 label: cliDiscovery.label,
110 installHint: cliDiscovery.installHint,
111 probe: (cwd: string) =>
112 credentials.cliEnv(AUTH_HOST).pipe(
113 Effect.flatMap((env) =>
114 probeSourceControlProvider({
115 cwd,
116 spec: cliDiscovery,
117 process: { run: (input) => process.run({ ...input, env: { ...input.env, ...env } }) },
118 }),
119 ),
120 ),
121 refineUnknownRemote: () => Effect.succeed(null),
122 } satisfies SourceControlManagedCliDiscoverySpec;
123});
124
125const Repository = Schema.Struct({
126 name: TrimmedNonEmptyString,
127 defaultBranch: Schema.NullOr(TrimmedNonEmptyString),
128});
129const Pull = Schema.Struct({
130 number: PositiveInt,
131 title: TrimmedNonEmptyString,
132 state: Schema.Literals(["open", "closed", "merged"]),
133 draft: Schema.Boolean,
134 sourceBranch: TrimmedNonEmptyString,
135 targetBranch: TrimmedNonEmptyString,
136 headOid: Schema.optional(Schema.NullOr(Schema.String)),
137 updatedAt: Schema.OptionFromNullOr(Schema.DateTimeUtcFromString),
138});
139const PullDetail = Schema.Struct({
140 ...Pull.fields,
141 sourceRepo: Schema.NullOr(
142 Schema.Struct({ owner: TrimmedNonEmptyString, name: TrimmedNonEmptyString }),
143 ),
144 closedAt: Schema.NullOr(Schema.String),
145 mergedAt: Schema.NullOr(Schema.String),
146});
147const Pulls = Schema.Struct({ items: Schema.Array(Pull), next: Schema.NullOr(Schema.String) });
148const RepositoryAdmission = Schema.Struct({
149 repoId: TrimmedNonEmptyString,
150 state: Schema.String,
151 owner: TrimmedNonEmptyString,
152 name: TrimmedNonEmptyString,
153});
154const CreatedRepository = Schema.Struct({
155 data: Schema.Struct({ resource: RepositoryAdmission }),
156});
157const decodeCreatedRepository = Schema.decodeEffect(Schema.fromJsonString(CreatedRepository));
158const encodeBranches = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String)));
159
160interface Target {
161 readonly host: GitCafeHosts.GitCafeHost;
162 readonly repository: string;
163}
164
165const cloneUrls = (target: Target) => ({
166 nameWithOwner: target.repository,
167 url: `https://${target.host}/${target.repository}`,
168 sshUrl: `ssh@${target.host}:${target.repository}.git`,
169});
170
171/** An scp-like `user@host:path` or `ssh://` remote; HTTPS remotes may carry userinfo too. */
172const isSshRemote = (remoteUrl: string | undefined) =>
173 remoteUrl !== undefined && (/^ssh:\/\//iu.test(remoteUrl) || /^[^/:]+@[^/:]+:/u.test(remoteUrl));
174
175const pullUrlPattern = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pulls\/(\d+)\/?(?:[?#].*)?$/u;
176
177export const make = Effect.gen(function* () {
178 const api = yield* GitCafeApi.GitCafeApi;
179 const host = yield* SourceControlHost.SourceControlHost;
180 const credentials = yield* GitCafeCredentials.GitCafeCredentials;
181
182 const error = (operation: string, cwd: string, detail: string, cause?: unknown) =>
183 new SourceControlProviderError({
184 provider: SourceControlProviderKind.make("gitcafe"),
185 operation,
186 cwd,
187 detail,
188 ...(cause === undefined ? {} : { cause }),
189 });
190 const read = <S extends Schema.Codec<unknown, unknown, never, never>>(
191 operation: string,
192 cwd: string,
193 request: Omit<GitCafeApi.GitCafeApiRequest, "operation">,
194 schema: S,
195 ) =>
196 api.request({ ...request, operation }).pipe(
197 Effect.mapError((cause) => error(operation, cwd, cause.detail, cause)),
198 Effect.flatMap((raw) =>
199 Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
200 Effect.mapError((cause) =>
201 error(operation, cwd, "GitCafe returned an unreadable response.", cause),
202 ),
203 ),
204 ),
205 );
206 const cafe = (operation: string, cwd: string, target: Target, args: ReadonlyArray<string>) =>
207 credentials
208 .cliEnv(target.host)
209 .pipe(
210 Effect.flatMap((env) =>
211 host.process.run({
212 operation: `GitCafeSourceControlProvider.${operation}`,
213 command: "cafe",
214 cwd,
215 args: [...cliArgs(target.host), ...args],
216 env: { CAFE_OUTPUT: "json", ...env },
217 timeoutMs: 30_000,
218 }),
219 ),
220 )
221 .pipe(
222 Effect.mapError((cause) =>
223 error(
224 operation,
225 cwd,
226 (cause._tag === "VcsProcessExitError" && cliFailure(cause.detail)?.message) ||
227 "GitCafe CLI command failed.",
228 cause,
229 ),
230 ),
231 );
232
233 /** The repository a call names, falling back to the checkout's own GitCafe remote. */
234 const resolveTarget = Effect.fn("GitCafeSourceControlProvider.resolveTarget")(function* (
235 operation: string,
236 input: {
237 readonly cwd: string;
238 readonly context?: SourceControlProvider.SourceControlProviderContext;
239 readonly repository?: string;
240 },
241 ) {
242 const named =
243 input.repository === undefined ? null : GitCafeHosts.parseGitCafeRemote(input.repository);
244 if (named) return named;
245 // A bare owner/name only asks the checkout which GitCafe host it is on, so a checkout with no
246 // remote (a repository being created) still resolves, on git.cafe.
247 const readRemote = host.git
248 .resolvePrimaryRemoteName(input.cwd)
249 .pipe(
250 Effect.flatMap((remote) => host.git.readConfigValue(input.cwd, `remote.${remote}.url`)),
251 );
252 const remoteUrl =
253 input.context?.remoteUrl ??
254 (input.repository === undefined
255 ? yield* readRemote.pipe(
256 Effect.mapError((cause) =>
257 error(operation, input.cwd, "Could not read the GitCafe remote.", cause),
258 ),
259 )
260 : yield* readRemote.pipe(Effect.orElseSucceed(() => null)));
261 const remote = remoteUrl ? GitCafeHosts.parseGitCafeRemote(remoteUrl) : null;
262 if (input.repository === undefined) {
263 if (remote) return remote;
264 return yield* error(operation, input.cwd, "No GitCafe repository remote was found.");
265 }
266 // A bare owner/name lives on the checkout's own GitCafe host.
267 const repository = GitCafeHosts.gitCafeRepositoryPath(input.repository);
268 if (repository !== null)
269 return { host: remote?.host ?? AUTH_HOST, repository } satisfies Target;
270 return yield* error(
271 operation,
272 input.cwd,
273 "A GitCafe repository is named owner/name or by its git.cafe URL.",
274 );
275 });
276
277 const getPull = Effect.fn("GitCafeSourceControlProvider.getPull")(function* (input: {
278 readonly cwd: string;
279 readonly context?: SourceControlProvider.SourceControlProviderContext;
280 readonly reference: string;
281 }) {
282 const match = pullUrlPattern.exec(input.reference.trim());
283 const urlHost = match?.[1] ? GitCafeHosts.gitCafeHost(match[1]) : null;
284 const target: Target =
285 urlHost && match?.[2]
286 ? { host: urlHost, repository: match[2] }
287 : yield* resolveTarget("getChangeRequest", input);
288 // A pull request URL on another host is not a GitCafe pull request number.
289 const number = match
290 ? urlHost
291 ? match[3]
292 : undefined
293 : /^#?([1-9]\d*)$/u.exec(input.reference.trim())?.[1];
294 if (number === undefined)
295 return yield* error(
296 "getChangeRequest",
297 input.cwd,
298 "A GitCafe pull request is a number or a git.cafe pull request URL.",
299 );
300 const pull = yield* read(
301 "getChangeRequest",
302 input.cwd,
303 { host: target.host, path: `/repos/${target.repository}/pulls/${number}` },
304 PullDetail,
305 );
306 const owner = target.repository.split("/")[0] ?? null;
307 return {
308 provider: SourceControlProviderKind.make("gitcafe"),
309 number: pull.number,
310 title: pull.title,
311 url: `https://${target.host}/${target.repository}/pulls/${pull.number}`,
312 baseRefName: pull.targetBranch,
313 headRefName: pull.sourceBranch,
314 ...(pull.headOid ? { headSha: pull.headOid } : {}),
315 state: pull.state,
316 ...(pull.draft ? { isDraft: true } : {}),
317 closedAt: pull.closedAt,
318 mergedAt: pull.mergedAt,
319 updatedAt: pull.updatedAt,
320 isCrossRepository: pull.sourceRepo !== null,
321 headRepositoryNameWithOwner: pull.sourceRepo
322 ? `${pull.sourceRepo.owner}/${pull.sourceRepo.name}`
323 : target.repository,
324 headRepositoryOwnerLogin: pull.sourceRepo?.owner ?? owner,
325 } satisfies ChangeRequest;
326 });
327
328 return SourceControlProvider.SourceControlProvider.of({
329 kind: SourceControlProviderKind.make("gitcafe"),
330 repositoryNameFromRemoteUrl: (url) => GitCafeHosts.parseGitCafeRemote(url)?.repository ?? null,
331 getChangeRequest: getPull,
332 listChangeRequests: Effect.fn("GitCafeSourceControlProvider.listChangeRequests")(
333 function* (input) {
334 const target = yield* resolveTarget("listChangeRequests", input);
335 const source = SourceControlProvider.sourceControlRefFromInput(input);
336 const branch = SourceControlProvider.sourceBranch(input);
337 const limit = input.limit ?? 20;
338 const items: Array<ChangeRequest> = [];
339 let after: string | null = null;
340 do {
341 const query = new URLSearchParams({
342 sourceBranches: encodeBranches([branch]),
343 limit: String(Math.min(limit, 100)),
344 sort: "newest",
345 });
346 if (input.state !== "all") query.set("state", input.state);
347 if (after !== null) query.set("after", after);
348 const page: typeof Pulls.Type = yield* read(
349 "listChangeRequests",
350 input.cwd,
351 { host: target.host, path: `/repos/${target.repository}/pulls?${query}` },
352 Pulls,
353 );
354 for (const pull of page.items) {
355 if (pull.sourceBranch !== branch || items.length >= limit) continue;
356 // List rows omit fork identity, so only the branch's own rows are read in full.
357 const detail = yield* getPull({
358 cwd: input.cwd,
359 reference: `https://${target.host}/${target.repository}/pulls/${pull.number}`,
360 });
361 if (
362 (!source?.repository || detail.headRepositoryNameWithOwner === source.repository) &&
363 (!source?.owner || detail.headRepositoryOwnerLogin === source.owner)
364 )
365 items.push(detail);
366 }
367 after = page.items.length === 0 || page.next === after ? null : page.next;
368 } while (after !== null && items.length < limit);
369 return items;
370 },
371 ),
372 createChangeRequest: Effect.fn("GitCafeSourceControlProvider.createChangeRequest")(
373 function* (input) {
374 const target = yield* resolveTarget("createChangeRequest", {
375 ...input,
376 ...(input.target?.repository ? { repository: input.target.repository } : {}),
377 });
378 const source = SourceControlProvider.sourceControlRefFromInput(input);
379 const sourceRepository =
380 source?.repository ??
381 (source?.owner
382 ? `${source.owner}/${target.repository.split("/")[1]}`
383 : target.repository);
384 const branch = SourceControlProvider.sourceBranch(input);
385 yield* cafe("createChangeRequest", input.cwd, target, [
386 "pr",
387 "create",
388 "--json",
389 "--repo",
390 target.repository,
391 "--head",
392 sourceRepository === target.repository ? branch : `${sourceRepository}:${branch}`,
393 "--base",
394 input.target?.refName ?? input.baseRefName,
395 "--title",
396 input.title,
397 "--body-file",
398 input.bodyFile,
399 ]);
400 },
401 ),
402 getRepositoryCloneUrls: Effect.fn("GitCafeSourceControlProvider.getRepositoryCloneUrls")(
403 function* (input) {
404 const target = yield* resolveTarget("getRepositoryCloneUrls", input);
405 yield* read(
406 "getRepositoryCloneUrls",
407 input.cwd,
408 { host: target.host, path: `/repos/${target.repository}` },
409 Repository,
410 );
411 return cloneUrls(target);
412 },
413 ),
414 createRepository: Effect.fn("GitCafeSourceControlProvider.createRepository")(function* (input) {
415 const target = yield* resolveTarget("createRepository", input);
416 const [owner = "", name = ""] = target.repository.split("/");
417 const output = yield* cafe("createRepository", input.cwd, target, [
418 "repo",
419 "create",
420 name,
421 "--org",
422 owner,
423 "--visibility",
424 input.visibility,
425 "--json",
426 ]);
427 const created = yield* decodeCreatedRepository(output.stdout).pipe(
428 Effect.mapError((cause) =>
429 error("createRepository", input.cwd, "GitCafe returned an unreadable response.", cause),
430 ),
431 );
432 // GitCafe admits a repository asynchronously: `cafe repo create` answers while admission is
433 // still running, so wait for it to settle the way `cafe repo create --clone` does.
434 const settled = (state: string) => state === "complete" || state === "blocked";
435 const pending = created.data.resource;
436 const resource = settled(pending.state)
437 ? pending
438 : yield* read(
439 "createRepository",
440 input.cwd,
441 {
442 host: target.host,
443 path: `/orgs/${pending.owner}/admissions/${pending.repoId}`,
444 },
445 RepositoryAdmission,
446 ).pipe(
447 Effect.repeat({
448 until: (admission) => settled(admission.state),
449 schedule: Schedule.spaced(ADMISSION_POLL_INTERVAL),
450 }),
451 Effect.timeoutOption(ADMISSION_TIMEOUT),
452 Effect.map(Option.getOrElse(() => pending)),
453 );
454 // Anything short of complete is not yet usable.
455 if (resource.state !== "complete")
456 return yield* error(
457 "createRepository",
458 input.cwd,
459 `GitCafe repository ${target.repository} is ${resource.state}. Check it on GitCafe before retrying.`,
460 );
461 return cloneUrls({ host: target.host, repository: `${resource.owner}/${resource.name}` });
462 }),
463 getDefaultBranch: Effect.fn("GitCafeSourceControlProvider.getDefaultBranch")(function* (input) {
464 const target = yield* resolveTarget("getDefaultBranch", input);
465 const repository = yield* read(
466 "getDefaultBranch",
467 input.cwd,
468 { host: target.host, path: `/repos/${target.repository}` },
469 Repository,
470 );
471 return repository.defaultBranch;
472 }),
473 // `cafe` has no forced checkout, so the branch is fetched and switched to with git.
474 checkoutChangeRequest: Effect.fn("GitCafeSourceControlProvider.checkoutChangeRequest")(
475 function* (input) {
476 const pull = yield* getPull(input);
477 const pullHost = new URL(pull.url).host;
478 const sourceRepository = pull.headRepositoryNameWithOwner;
479 if (!sourceRepository)
480 return yield* error(
481 "checkoutChangeRequest",
482 input.cwd,
483 "GitCafe did not name the pull request's source repository.",
484 );
485 const contextRemote = input.context
486 ? GitCafeHosts.parseGitCafeRemote(input.context.remoteUrl)
487 : null;
488 const git = host.git;
489 yield* Effect.gen(function* () {
490 const remoteName =
491 input.context &&
492 contextRemote?.repository === sourceRepository &&
493 contextRemote.host === pullHost
494 ? input.context.remoteName
495 : yield* git.ensureRemote({
496 cwd: input.cwd,
497 preferredName: "gitcafe",
498 // SSH only when the checkout already reaches GitCafe over SSH; HTTPS otherwise.
499 url: isSshRemote(input.context?.remoteUrl)
500 ? `ssh@${pullHost}:${sourceRepository}.git`
501 : `https://${pullHost}/${sourceRepository}.git`,
502 });
503 const localBranch = pull.isCrossRepository
504 ? `pr-${pull.number}/${pull.headRefName}`
505 : pull.headRefName;
506 const branches = yield* git.listLocalBranchNames(input.cwd);
507 const remoteBranch = { cwd: input.cwd, remoteName, remoteBranch: pull.headRefName };
508 if (input.force !== true && !branches.includes(localBranch))
509 yield* git.fetchRemoteBranch({ ...remoteBranch, localBranch });
510 else yield* git.fetchRemoteTrackingBranch(remoteBranch);
511 // `checkout -B` moves even the checked-out branch, and refuses to overwrite local edits.
512 if (input.force === true)
513 yield* git.execute({
514 operation: "GitCafeSourceControlProvider.checkoutChangeRequest",
515 cwd: input.cwd,
516 args: [
517 "checkout",
518 "-B",
519 localBranch,
520 `refs/remotes/${remoteName}/${pull.headRefName}`,
521 "--",
522 ],
523 });
524 yield* git.setBranchUpstream({ ...remoteBranch, branch: localBranch });
525 yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch }));
526 }).pipe(
527 Effect.mapError((cause) =>
528 error(
529 "checkoutChangeRequest",
530 input.cwd,
531 "Could not check out the GitCafe pull request.",
532 cause,
533 ),
534 ),
535 );
536 },
537 ),
538 });
539});