Files
zopu-code/packages/primitives/src/project-issue.ts

272 lines
7.5 KiB
TypeScript

/* 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>()(
"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>()(
"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>()(
"ProjectIssueTransitionError",
{
message: Schema.String,
reason: ProjectIssueTransitionReason,
}
) {}
export const validateProjectIssueDraft = (
input: unknown
): Effect.Effect<ProjectIssueDraft, ProjectIssueValidationError> =>
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;
});