/* eslint-disable max-classes-per-file -- each domain failure has a distinct tagged reason. */ import { Effect, Schema } from "effect"; import { ProblemStatement, ProjectId, SignalId } from "./signal.js"; const MeaningfulString = Schema.String.check( Schema.makeFilter((value) => value.trim().length > 0, { expected: "a non-empty string", }) ); export const ProjectIssueId = MeaningfulString.pipe( Schema.brand("ProjectIssueId") ); export type ProjectIssueId = typeof ProjectIssueId.Type; export const ProjectIssueStatus = Schema.Literals([ "open", "queued", "working", "needs-input", "completed", "failed", ]); export type ProjectIssueStatus = typeof ProjectIssueStatus.Type; const ProjectIssueDraftInput = Schema.Struct({ body: Schema.String, title: Schema.String, }); export const ProjectIssueDraft = Schema.Struct({ body: MeaningfulString, title: MeaningfulString, }); export type ProjectIssueDraft = typeof ProjectIssueDraft.Type; export const ExistingProjectIssueRequest = Schema.Struct({ issueId: ProjectIssueId, kind: Schema.Literal("existing"), }); export const ExplicitProjectIssueRequest = Schema.Struct({ body: Schema.String, kind: Schema.Literal("explicit"), projectId: ProjectId, title: Schema.String, }); export const SignalProjectIssueRequest = Schema.Struct({ kind: Schema.Literal("signal"), signalId: SignalId, }); export const ProjectIssueRequest = Schema.Union([ ExistingProjectIssueRequest, ExplicitProjectIssueRequest, SignalProjectIssueRequest, ]); export type ProjectIssueRequest = typeof ProjectIssueRequest.Type; export const ProjectIssueDispatchInput = Schema.Struct({ issueId: ProjectIssueId, kind: Schema.Literal("project.issue.started"), projectId: ProjectId, }); export type ProjectIssueDispatchInput = typeof ProjectIssueDispatchInput.Type; export const ProjectIssueRequestResult = Schema.Struct({ acceptedAt: MeaningfulString, dispatchId: MeaningfulString, issueId: ProjectIssueId, projectId: ProjectId, status: Schema.Literals(["queued", "working"]), }); export type ProjectIssueRequestResult = typeof ProjectIssueRequestResult.Type; export const ProjectIssueValidationReason = Schema.Literals([ "InvalidInput", "TitleTooShort", "TitleTooLong", "BodyTooShort", "BodyTooLong", ]); export type ProjectIssueValidationReason = typeof ProjectIssueValidationReason.Type; export class ProjectIssueValidationError extends Schema.TaggedErrorClass()( "ProjectIssueValidationError", { message: Schema.String, reason: ProjectIssueValidationReason, } ) {} export const ProjectIssueRequestErrorReason = Schema.Literals([ "InvalidInput", "InvalidProjectId", "InvalidSignalId", ]); export type ProjectIssueRequestErrorReason = typeof ProjectIssueRequestErrorReason.Type; export class ProjectIssueRequestError extends Schema.TaggedErrorClass()( "ProjectIssueRequestError", { message: Schema.String, reason: ProjectIssueRequestErrorReason, } ) {} export const ProjectIssueTransitionReason = Schema.Literals([ "InvalidStatus", "CompletedIssue", ]); export type ProjectIssueTransitionReason = typeof ProjectIssueTransitionReason.Type; export class ProjectIssueTransitionError extends Schema.TaggedErrorClass()( "ProjectIssueTransitionError", { message: Schema.String, reason: ProjectIssueTransitionReason, } ) {} export const validateProjectIssueDraft = ( input: unknown ): Effect.Effect => Effect.gen(function* validateDraft() { const decoded = yield* Schema.decodeUnknownEffect(ProjectIssueDraftInput)( input ).pipe( Effect.mapError( () => new ProjectIssueValidationError({ message: "Issue title and body are required", reason: "InvalidInput", }) ) ); const title = decoded.title.trim(); const body = decoded.body.trim(); if (title.length < 3) { return yield* Effect.fail( new ProjectIssueValidationError({ message: "Issue title must be between 3 and 160 characters", reason: "TitleTooShort", }) ); } if (title.length > 160) { return yield* Effect.fail( new ProjectIssueValidationError({ message: "Issue title must be between 3 and 160 characters", reason: "TitleTooLong", }) ); } if (body.length < 10) { return yield* Effect.fail( new ProjectIssueValidationError({ message: "Issue description must be between 10 and 10000 characters", reason: "BodyTooShort", }) ); } if (body.length > 10_000) { return yield* Effect.fail( new ProjectIssueValidationError({ message: "Issue description must be between 10 and 10000 characters", reason: "BodyTooLong", }) ); } return { body, title }; }); export const decodeProjectIssueRequest = ( input: unknown ): Effect.Effect< ProjectIssueRequest, ProjectIssueRequestError | ProjectIssueValidationError > => Effect.gen(function* decodeRequest() { const request = yield* Schema.decodeUnknownEffect(ProjectIssueRequest)( input ).pipe( Effect.mapError( () => new ProjectIssueRequestError({ message: "Use an existing issue, signal request, or explicit request with projectId, title, and body", reason: "InvalidInput", }) ) ); if (request.kind === "explicit") { const draft = yield* validateProjectIssueDraft(request); return { ...request, ...draft }; } return request; }); export const projectIssueDraftFromSignal = (input: unknown) => Effect.gen(function* draftFromSignal() { const source = yield* Schema.decodeUnknownEffect( Schema.Struct({ problemStatement: ProblemStatement, signalId: SignalId, }) )(input).pipe( Effect.mapError( () => new ProjectIssueRequestError({ message: "Project Signal is missing a valid problem statement", reason: "InvalidSignalId", }) ) ); const constraints = source.problemStatement.constraints.length === 0 ? "None" : source.problemStatement.constraints .map((constraint) => `- ${constraint}`) .join("\n"); return yield* validateProjectIssueDraft({ body: [ source.problemStatement.summary, `Desired outcome: ${source.problemStatement.desiredOutcome}`, `Constraints:\n${constraints}`, `Source Signal: ${source.signalId}`, ].join("\n\n"), title: source.problemStatement.title, }); }); export const queueProjectIssue = ( input: unknown ): Effect.Effect<"queued" | "working", ProjectIssueTransitionError> => Effect.gen(function* queueIssue() { const status = yield* Schema.decodeUnknownEffect(ProjectIssueStatus)( input ).pipe( Effect.mapError( () => new ProjectIssueTransitionError({ message: "Issue has an invalid status", reason: "InvalidStatus", }) ) ); if (status === "queued" || status === "working") { return status; } if (status === "completed") { return yield* Effect.fail( new ProjectIssueTransitionError({ message: "Completed issues cannot be queued again", reason: "CompletedIssue", }) ); } return "queued" as const; });