272 lines
7.5 KiB
TypeScript
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;
|
|
});
|