feat: dispatch authenticated project issue requests
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
".": "./src/index.ts",
|
||||
"./agent-os": "./src/agent-os.ts",
|
||||
"./project": "./src/project.ts",
|
||||
"./project-issue": "./src/project-issue.ts",
|
||||
"./signal": "./src/signal.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
|
||||
export * from "./agent-os";
|
||||
export * from "./project";
|
||||
export * from "./project-issue";
|
||||
export * from "./signal";
|
||||
|
||||
77
packages/primitives/src/project-issue.test.ts
Normal file
77
packages/primitives/src/project-issue.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
decodeProjectIssueRequest,
|
||||
ProjectIssueTransitionError,
|
||||
ProjectIssueValidationError,
|
||||
projectIssueDraftFromSignal,
|
||||
queueProjectIssue,
|
||||
validateProjectIssueDraft,
|
||||
} from "./project-issue";
|
||||
|
||||
describe("project issue primitives", () => {
|
||||
it("normalizes an explicit project request", () => {
|
||||
const request = Effect.runSync(
|
||||
decodeProjectIssueRequest({
|
||||
body: " Investigate the staging failure. ",
|
||||
kind: "explicit",
|
||||
projectId: "project-1",
|
||||
title: " Staging failure ",
|
||||
})
|
||||
);
|
||||
|
||||
expect(request).toEqual({
|
||||
body: "Investigate the staging failure.",
|
||||
kind: "explicit",
|
||||
projectId: "project-1",
|
||||
title: "Staging failure",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid issue drafts before persistence", () => {
|
||||
const error = Effect.runSync(
|
||||
Effect.flip(
|
||||
validateProjectIssueDraft({
|
||||
body: "too short",
|
||||
title: "No",
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(ProjectIssueValidationError);
|
||||
expect(error.reason).toBe("TitleTooShort");
|
||||
});
|
||||
|
||||
it("projects a project Signal into the existing issue shape", () => {
|
||||
const draft = Effect.runSync(
|
||||
projectIssueDraftFromSignal({
|
||||
problemStatement: {
|
||||
constraints: ["Do not change production"],
|
||||
desiredOutcome: "Staging deploys successfully",
|
||||
summary: "The deploy fails during DNS resolution",
|
||||
title: "Fix staging deploy DNS failure",
|
||||
},
|
||||
signalId: "signal-1",
|
||||
})
|
||||
);
|
||||
|
||||
expect(draft.title).toBe("Fix staging deploy DNS failure");
|
||||
expect(draft.body).toContain("The deploy fails during DNS resolution");
|
||||
expect(draft.body).toContain("Source Signal: signal-1");
|
||||
});
|
||||
|
||||
it("queues retryable issue states and preserves active states", () => {
|
||||
expect(Effect.runSync(queueProjectIssue("open"))).toBe("queued");
|
||||
expect(Effect.runSync(queueProjectIssue("failed"))).toBe("queued");
|
||||
expect(Effect.runSync(queueProjectIssue("needs-input"))).toBe("queued");
|
||||
expect(Effect.runSync(queueProjectIssue("working"))).toBe("working");
|
||||
});
|
||||
|
||||
it("does not reopen completed issues", () => {
|
||||
const error = Effect.runSync(Effect.flip(queueProjectIssue("completed")));
|
||||
|
||||
expect(error).toBeInstanceOf(ProjectIssueTransitionError);
|
||||
expect(error.reason).toBe("CompletedIssue");
|
||||
});
|
||||
});
|
||||
265
packages/primitives/src/project-issue.ts
Normal file
265
packages/primitives/src/project-issue.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/* 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 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([
|
||||
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 a signal request or an 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;
|
||||
});
|
||||
Reference in New Issue
Block a user