| 1 | import * as Context from "effect/Context"; |
| 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 9 | import * as HttpClient from "effect/http/HttpClient"; |
| 10 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 11 | |
| 12 | import * as GitCafeCredentials from "./GitCafeCredentials.ts"; |
| 13 | import * as GitCafeHosts from "./gitCafeHosts.ts"; |
| 14 | |
| 15 | const NO_REDIRECT: RequestInit = { redirect: "manual" }; |
| 16 | const 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 | */ |
| 22 | export const GitCafeApiFailure = Schema.Literals([ |
| 23 | "missing-tool", |
| 24 | "unauthenticated", |
| 25 | "rate-limited", |
| 26 | "not-found", |
| 27 | "failed", |
| 28 | ]); |
| 29 | |
| 30 | export 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 | |
| 44 | export 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. */ |
| 54 | export 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",...}`. */ |
| 63 | const 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 | |
| 72 | const 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 | |
| 81 | const MAX_PROBLEM_TEXT = 300; |
| 82 | const 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 | |
| 88 | const 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. */ |
| 98 | export 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 | |
| 176 | export const layer = Layer.effect(GitCafeApi, make); |