/* eslint-disable max-classes-per-file -- the runtime service and its tagged error form one adapter contract. */ import { Context, Effect, Layer, Schema } from "effect"; const MeaningfulString = Schema.String.check( Schema.makeFilter((value) => value.trim().length > 0, { expected: "a non-empty string", }) ); // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- export const GitRemoteConfig = Schema.Struct({ baseUrl: Schema.String, owner: MeaningfulString, repo: MeaningfulString, token: MeaningfulString, }); export type GitRemoteConfig = typeof GitRemoteConfig.Type; // --------------------------------------------------------------------------- // Value objects // --------------------------------------------------------------------------- export const RemoteIssue = Schema.Struct({ body: Schema.String, number: Schema.Int.check(Schema.isGreaterThan(0)), state: Schema.Literals(["open", "closed"]), title: MeaningfulString, url: Schema.String, }); export type RemoteIssue = typeof RemoteIssue.Type; export const RemoteBranch = Schema.Struct({ name: MeaningfulString, }); export type RemoteBranch = typeof RemoteBranch.Type; export const RemotePullRequest = Schema.Struct({ baseBranch: MeaningfulString, branch: MeaningfulString, number: Schema.Int.check(Schema.isGreaterThan(0)), state: Schema.Literals(["open", "closed", "merged"]), title: MeaningfulString, url: Schema.String, }); export type RemotePullRequest = typeof RemotePullRequest.Type; // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- export const GitRemoteErrorReason = Schema.Literals([ "InvalidInput", "Unauthorized", "NotFound", "Conflict", "Unreachable", ]); export type GitRemoteErrorReason = typeof GitRemoteErrorReason.Type; export class GitRemoteError extends Schema.TaggedErrorClass()( "GitRemoteError", { message: Schema.String, reason: GitRemoteErrorReason, } ) {} // --------------------------------------------------------------------------- // Low-level transport: a tiny fetch wrapper that maps HTTP failures to the // tagged error. Kept as an injectable dependency so tests can stub the wire. // --------------------------------------------------------------------------- export interface GitRemoteTransport { readonly request: (input: { readonly body?: unknown; readonly headers?: Readonly>; readonly method: string; readonly url: string; }) => Effect.Effect< { readonly status: number; readonly json: () => Promise }, GitRemoteError >; } const authHeaders = (token: string): Record => ({ Authorization: `token ${token}`, "Content-Type": "application/json", }); const endpoint = (config: GitRemoteConfig, path: string): string => `${config.baseUrl.replace(/\/+$/u, "")}/api/v1/repos/${config.owner}/${config.repo}${path}`; const fromStatus = (status: number, fallback: string): GitRemoteError => { if (status === 401 || status === 403) { return new GitRemoteError({ message: "Unauthorized", reason: "Unauthorized", }); } if (status === 404) { return new GitRemoteError({ message: "Not found", reason: "NotFound" }); } if (status === 409) { return new GitRemoteError({ message: "Conflict", reason: "Conflict" }); } if (status >= 500) { return new GitRemoteError({ message: fallback, reason: "Unreachable" }); } return new GitRemoteError({ message: fallback, reason: "InvalidInput" }); }; const request = ( transport: GitRemoteTransport, config: GitRemoteConfig, method: string, path: string, body?: unknown ): Effect.Effect => transport .request({ body, headers: authHeaders(config.token), method, url: endpoint(config, path), }) .pipe( Effect.flatMap((res) => Effect.gen(function* handleResponse() { if (res.status >= 400) { const detail = yield* Effect.tryPromise({ catch: () => null, try: () => res.json(), }).pipe(Effect.orElseSucceed(() => null)); const message = detail && typeof detail === "object" && "message" in detail ? String((detail as { message: unknown }).message) : `Request ${method} ${path} failed with ${res.status}`; return yield* Effect.fail(fromStatus(res.status, message)); } return yield* Effect.tryPromise({ catch: (cause) => new GitRemoteError({ message: cause instanceof Error ? cause.message : "Bad JSON", reason: "InvalidInput", }), try: () => res.json(), }); }) ) ); // --------------------------------------------------------------------------- // Operations // --------------------------------------------------------------------------- export interface CreateIssueInput { readonly body: string; readonly title: string; } export const createIssue = ( transport: GitRemoteTransport, config: GitRemoteConfig, input: CreateIssueInput ): Effect.Effect => Effect.gen(function* run() { const raw = yield* request(transport, config, "POST", "/issues", { body: input.body, title: input.title, }); return yield* Schema.decodeUnknownEffect(RemoteIssue)({ body: (raw as { body?: string }).body ?? "", number: (raw as { number: number }).number, state: (raw as { state: string }).state ?? "open", title: (raw as { title: string }).title, url: (raw as { html_url?: string }).html_url ?? "", }).pipe( Effect.mapError( (cause) => new GitRemoteError({ message: cause.message, reason: "InvalidInput" }) ) ); }); export interface CreateBranchInput { readonly from?: string; readonly name: string; } export const createBranch = ( transport: GitRemoteTransport, config: GitRemoteConfig, input: CreateBranchInput ): Effect.Effect => Effect.gen(function* run() { const raw = yield* request(transport, config, "POST", "/branches", { new_branch_name: input.name, old_ref_name: input.from ?? "main", }); return yield* Schema.decodeUnknownEffect(RemoteBranch)({ name: (raw as { name: string }).name, }).pipe( Effect.mapError( (cause) => new GitRemoteError({ message: cause.message, reason: "InvalidInput" }) ) ); }); export interface CreatePullRequestInput { readonly baseBranch: string; readonly body: string; readonly branch: string; readonly title: string; } export const createPullRequest = ( transport: GitRemoteTransport, config: GitRemoteConfig, input: CreatePullRequestInput ): Effect.Effect => Effect.gen(function* run() { const raw = yield* request(transport, config, "POST", "/pulls", { base: input.baseBranch, body: input.body, head: input.branch, title: input.title, }); const merged = Boolean((raw as { merged?: boolean }).merged); return yield* Schema.decodeUnknownEffect(RemotePullRequest)({ baseBranch: (raw as { base?: { ref?: string } }).base?.ref ?? input.baseBranch, branch: (raw as { head?: { ref?: string } }).head?.ref ?? input.branch, number: (raw as { number: number }).number, state: merged ? "merged" : ((raw as { state?: string }).state ?? "open"), title: (raw as { title: string }).title, url: (raw as { html_url?: string }).html_url ?? "", }).pipe( Effect.mapError( (cause) => new GitRemoteError({ message: cause.message, reason: "InvalidInput" }) ) ); }); export const listIssues = ( transport: GitRemoteTransport, config: GitRemoteConfig ): Effect.Effect => Effect.gen(function* run() { const raw = yield* request( transport, config, "GET", "/issues?state=open&type=issues" ); const list = (raw as Record[]) ?? []; const decoded = list.map((entry) => Schema.decodeUnknownEffect(RemoteIssue)({ body: (entry.body as string) ?? "", number: entry.number as number, state: (entry.state as string) ?? "open", title: entry.title as string, url: (entry.html_url as string) ?? "", }).pipe( Effect.mapError( (cause) => new GitRemoteError({ message: cause.message, reason: "InvalidInput", }) ) ) ); return yield* Effect.all(decoded); }); export const getIssue = ( transport: GitRemoteTransport, config: GitRemoteConfig, number: number ): Effect.Effect => Effect.gen(function* run() { const raw = yield* request(transport, config, "GET", `/issues/${number}`); return yield* Schema.decodeUnknownEffect(RemoteIssue)({ body: (raw as { body?: string }).body ?? "", number: (raw as { number: number }).number, state: (raw as { state: string }).state ?? "open", title: (raw as { title: string }).title, url: (raw as { html_url?: string }).html_url ?? "", }).pipe( Effect.mapError( (cause) => new GitRemoteError({ message: cause.message, reason: "InvalidInput" }) ) ); }); // --------------------------------------------------------------------------- // Fetch-backed transport (default) + service // --------------------------------------------------------------------------- export const fetchTransport: GitRemoteTransport = { request: ({ body, headers, method, url }) => Effect.tryPromise({ catch: (cause) => new GitRemoteError({ message: cause instanceof Error ? cause.message : "Network error", reason: "Unreachable", }), try: async () => { const init: RequestInit = { headers: headers ?? {}, method }; if (body !== undefined && method !== "GET" && method !== "HEAD") { init.body = JSON.stringify(body); } const res = await fetch(url, init); return { json: () => res.json() as Promise, status: res.status, }; }, }), }; interface GitRemoteRuntimeShape { readonly config: GitRemoteConfig; readonly transport: GitRemoteTransport; readonly createIssue: ( input: CreateIssueInput ) => Effect.Effect; readonly createBranch: ( input: CreateBranchInput ) => Effect.Effect; readonly createPullRequest: ( input: CreatePullRequestInput ) => Effect.Effect; readonly listIssues: () => Effect.Effect< readonly RemoteIssue[], GitRemoteError >; readonly getIssue: ( number: number ) => Effect.Effect; } export class GitRemoteRuntime extends Context.Service< GitRemoteRuntime, GitRemoteRuntimeShape >()("@code/primitives/git-remote-runtime/GitRemoteRuntime") {} export const makeGitRemoteRuntimeLayer = (config: GitRemoteConfig) => Layer.succeed( GitRemoteRuntime, GitRemoteRuntime.of({ config, createBranch: (input) => createBranch(fetchTransport, config, input), createIssue: (input) => createIssue(fetchTransport, config, input), createPullRequest: (input) => createPullRequest(fetchTransport, config, input), getIssue: (number) => getIssue(fetchTransport, config, number), listIssues: () => listIssues(fetchTransport, config), transport: fetchTransport, }) );