1import * as Context from "effect/Context";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Redacted from "effect/Redacted";
7import * as Schema from "effect/Schema";
8import * as FetchHttpClient from "effect/http/FetchHttpClient";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11
12import * as GitCafeCredentials from "./GitCafeCredentials.ts";
13import * as GitCafeHosts from "./gitCafeHosts.ts";
14
15const NO_REDIRECT: RequestInit = { redirect: "manual" };
16const TIMEOUT = Duration.seconds(30);
17
18/**
19 * Why a request failed, as far as a caller needs to act on it: a missing CLI or refused token
20 * disables the provider, a limit pauses the host, and the rest belong to the one request.
21 */
22export const GitCafeApiFailure = Schema.Literals([
23 "missing-tool",
24 "unauthenticated",
25 "rate-limited",
26 "not-found",
27 "failed",
28]);
29
30export class GitCafeApiError extends Schema.TaggedError<GitCafeApiError>()("GitCafeApiError", {
31 host: Schema.String,
32 operation: Schema.String,
33 /** GitCafe's HTTP status; null when no answer arrived. */
34 status: Schema.NullOr(Schema.Int),
35 reason: GitCafeApiFailure,
36 detail: Schema.String,
37 cause: Schema.optional(Schema.Defect()),
38}) {
39 override get message(): string {
40 return `GitCafe ${this.operation} failed on ${this.host}: ${this.detail}`;
41 }
42}
43
44export interface GitCafeApiRequest {
45 readonly host: string;
46 readonly operation: string;
47 readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
48 /** Below `/api`, query string included, e.g. `/repos/owner/repo/pulls/7`. */
49 readonly path: string;
50 readonly body?: unknown;
51}
52
53/** GitCafe's REST API on its own hosts, authenticated with the `GitCafeCredentials` token. */
54export class GitCafeApi extends Context.Service<
55 GitCafeApi,
56 {
57 /** The body of a successful answer. */
58 readonly request: (input: GitCafeApiRequest) => Effect.Effect<string, GitCafeApiError>;
59 }
60>()("@t3tools/source-control-gitcafe/server/GitCafeApi") {}
61
62/** GitCafe's RFC 7807 error body, e.g. `{"type":"https://cafe.sh/errors/not-found",...}`. */
63const decodeProblem = Schema.decodeUnknownOption(
64 Schema.fromJsonString(
65 Schema.Struct({
66 title: Schema.optional(Schema.String),
67 detail: Schema.optional(Schema.String),
68 }),
69 ),
70);
71
72const reasonForStatus = (status: number): typeof GitCafeApiFailure.Type =>
73 status === 401
74 ? "unauthenticated"
75 : status === 429
76 ? "rate-limited"
77 : status === 404
78 ? "not-found"
79 : "failed";
80
81const MAX_PROBLEM_TEXT = 300;
82const boundedProblemText = (text: string | undefined) => {
83 const line = text?.replace(/\s+/gu, " ").trim();
84 if (!line) return undefined;
85 return line.length > MAX_PROBLEM_TEXT ? `${line.slice(0, MAX_PROBLEM_TEXT)}…` : line;
86};
87
88const credentialFailure = (
89 error: GitCafeCredentials.GitCafeCredentialUnavailableError,
90): typeof GitCafeApiFailure.Type =>
91 error._tag === "GitCafeCliMissingError"
92 ? "missing-tool"
93 : error._tag === "GitCafeNotSignedInError"
94 ? "unauthenticated"
95 : "failed";
96
97/** @public Service construction is part of the canonical Effect module API. */
98export const make = Effect.gen(function* () {
99 const httpClient = yield* HttpClient.HttpClient;
100 const credentials = yield* GitCafeCredentials.GitCafeCredentials;
101
102 const request = Effect.fn("GitCafeApi.request")(function* (input: GitCafeApiRequest) {
103 const fail = (
104 fields: Pick<GitCafeApiError, "status" | "reason" | "detail"> & { cause?: unknown },
105 ) =>
106 new GitCafeApiError({
107 host: input.host,
108 operation: input.operation,
109 status: fields.status,
110 reason: fields.reason,
111 detail: fields.detail,
112 ...(fields.cause === undefined ? {} : { cause: fields.cause }),
113 });
114 // The host comes from a remote URL, so a token never leaves for one GitCafe does not own.
115 const host = GitCafeHosts.gitCafeHost(input.host);
116 if (host === null)
117 return yield* fail({
118 status: null,
119 reason: "failed",
120 detail: `GitCafe lives on ${GitCafeHosts.GITCAFE_HOSTS.join(" and ")}, not ${input.host}.`,
121 });
122 yield* Effect.annotateCurrentSpan({
123 "gitcafe.host": host,
124 "gitcafe.operation": input.operation,
125 "http.request.method": input.method ?? "GET",
126 });
127 const credential = yield* credentials
128 .get(host)
129 .pipe(
130 Effect.mapError((cause) =>
131 fail({ status: null, reason: credentialFailure(cause), detail: cause.message, cause }),
132 ),
133 );
134 const base = HttpClientRequest.make(input.method ?? "GET")(
135 `https://${host}/api${input.path}`,
136 ).pipe(
137 HttpClientRequest.bearerToken(Redacted.value(credential.token)),
138 HttpClientRequest.setHeaders({ accept: "application/json", "user-agent": "t3code" }),
139 );
140 const { status, text } = yield* Effect.gen(function* () {
141 const response = yield* httpClient
142 .execute(
143 input.body === undefined ? base : base.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)),
144 )
145 .pipe(
146 // The client's own span would record the query string, which can carry branch names.
147 Effect.provideService(HttpClient.TracerDisabledWhen, () => true),
148 // fetch would follow a redirect itself and keep the bearer token on a same-origin hop;
149 // GitCafe's API never redirects a request it serves, so a 3xx is answered as a failure.
150 Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT),
151 );
152 return { status: response.status, text: yield* response.text };
153 }).pipe(
154 Effect.timeout(TIMEOUT),
155 Effect.mapError((cause) =>
156 fail({ status: null, reason: "failed", detail: "Could not reach GitCafe.", cause }),
157 ),
158 );
159 yield* Effect.annotateCurrentSpan({ "http.response.status_code": status });
160 if (status >= 200 && status < 300) return text;
161 // The source may hold a newer token than the one that was refused.
162 if (status === 401) yield* credentials.invalidate(host);
163 const problem = Option.getOrUndefined(decodeProblem(text));
164 return yield* fail({
165 status,
166 reason: reasonForStatus(status),
167 // GitCafe's own explanation is what the user acts on; it is kept short and single-line.
168 detail:
169 boundedProblemText(problem?.detail ?? problem?.title) ?? `GitCafe answered HTTP ${status}.`,
170 });
171 });
172
173 return GitCafeApi.of({ request });
174});
175
176export const layer = Layer.effect(GitCafeApi, make);