agentic-teams: epics, tasks, git forges, agent authority #139
@@ -2669,9 +2669,9 @@
|
||||
"dependencies": [
|
||||
"117"
|
||||
],
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
||||
"updatedAt": "2026-08-08T10:05:28.155Z"
|
||||
},
|
||||
{
|
||||
"id": "124",
|
||||
@@ -2683,9 +2683,9 @@
|
||||
"dependencies": [
|
||||
"123"
|
||||
],
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
||||
"updatedAt": "2026-08-08T10:05:28.165Z"
|
||||
},
|
||||
{
|
||||
"id": "125",
|
||||
@@ -2697,9 +2697,9 @@
|
||||
"dependencies": [
|
||||
"117"
|
||||
],
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
||||
"updatedAt": "2026-08-08T10:05:28.172Z"
|
||||
},
|
||||
{
|
||||
"id": "126",
|
||||
@@ -2712,9 +2712,9 @@
|
||||
"124",
|
||||
"125"
|
||||
],
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
||||
"updatedAt": "2026-08-08T10:05:28.178Z"
|
||||
},
|
||||
{
|
||||
"id": "127",
|
||||
@@ -3251,9 +3251,9 @@
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-08-08T09:36:21.139Z",
|
||||
"lastModified": "2026-08-08T10:05:28.178Z",
|
||||
"taskCount": 162,
|
||||
"completedCount": 107,
|
||||
"completedCount": 111,
|
||||
"tags": [
|
||||
"master"
|
||||
]
|
||||
|
||||
@@ -106,6 +106,7 @@ import {
|
||||
import { registerPromptRoutes } from './routes/prompts.js';
|
||||
import { registerRevisionRoutes } from './routes/revisions.js';
|
||||
import { registerProposalRoutes } from './routes/proposals.js';
|
||||
import { registerEpicRoutes } from './routes/epics.js';
|
||||
import { registerSkillRoutes } from './routes/skills.js';
|
||||
import { registerGitBackupRoutes } from './routes/git-backup.js';
|
||||
import { PromptService } from './services/prompt.service.js';
|
||||
@@ -113,7 +114,9 @@ import { ResourceRevisionRepository } from './repositories/resource-revision.rep
|
||||
import { ResourceProposalRepository } from './repositories/resource-proposal.repository.js';
|
||||
import { ResourceRevisionService } from './services/resource-revision.service.js';
|
||||
import { ResourceProposalService } from './services/resource-proposal.service.js';
|
||||
import { EpicRepository } from './repositories/epic.repository.js';
|
||||
import { SkillRepository } from './repositories/skill.repository.js';
|
||||
import { EpicService } from './services/epic.service.js';
|
||||
import { SkillService } from './services/skill.service.js';
|
||||
import { GitBackupService } from './services/backup/git-backup.service.js';
|
||||
import type { BackupKind } from './services/backup/yaml-serializer.js';
|
||||
@@ -221,6 +224,8 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
||||
// Personalities inherit the agent's RBAC: managing a personality
|
||||
// requires view/edit/create/delete on the `agents` resource.
|
||||
'personalities': 'agents',
|
||||
// ── Agentic teams ──
|
||||
'epics': 'epics',
|
||||
};
|
||||
|
||||
const resource = resourceMap[segment];
|
||||
@@ -427,6 +432,9 @@ async function main(): Promise<void> {
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const groupRepo = new GroupRepository(prisma);
|
||||
const mcpTokenRepo = new McpTokenRepository(prisma);
|
||||
// Declared here (not with the other agentic-teams wiring below) because
|
||||
// nameResolvers needs it for CUID→name RBAC resolution.
|
||||
const epicRepo = new EpicRepository(prisma);
|
||||
|
||||
// SecretBackend bootstrap: ensure a `plaintext` default row exists and any
|
||||
// pre-existing `Secret` rows are pointed at it. Idempotent per run.
|
||||
@@ -443,6 +451,7 @@ async function main(): Promise<void> {
|
||||
mcptokens: mcpTokenRepo,
|
||||
llms: llmRepo,
|
||||
agents: agentRepo,
|
||||
epics: epicRepo,
|
||||
// Resolve user CUID → email so name-scoped `edit:users:<email>` bindings
|
||||
// match on PUT /api/v1/users/:id/password (admin reset).
|
||||
users: { findById: async (id: string) => {
|
||||
@@ -545,6 +554,11 @@ async function main(): Promise<void> {
|
||||
const skillService = new SkillService(skillRepo, projectRepo, agentRepo);
|
||||
skillService.setRevisionService(resourceRevisionService);
|
||||
skillService.setProposalService(resourceProposalService);
|
||||
|
||||
// Agentic teams: the Epic is the versioned vision every task walks up to.
|
||||
// Revision-tracked so scope creep is as visible as under-delivery.
|
||||
const epicService = new EpicService(epicRepo, projectRepo);
|
||||
epicService.setRevisionService(resourceRevisionService);
|
||||
const personalityRepo = new PersonalityRepository(prisma);
|
||||
const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo);
|
||||
const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo);
|
||||
@@ -753,6 +767,7 @@ async function main(): Promise<void> {
|
||||
registerRevisionRoutes(app, { revisionService: resourceRevisionService, promptService });
|
||||
registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo });
|
||||
registerSkillRoutes(app, skillService, projectRepo, agentRepo);
|
||||
registerEpicRoutes(app, epicService, projectRepo);
|
||||
|
||||
// ── Git-based backup ──
|
||||
const gitBackup = new GitBackupService(prisma);
|
||||
|
||||
91
src/mcpd/src/repositories/epic.repository.ts
Normal file
91
src/mcpd/src/repositories/epic.repository.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { PrismaClient, Prisma, Epic, EpicStatus } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Epic repository. Unlike Prompt/Skill, an Epic is always project-scoped —
|
||||
* there is no global epic — so the nullable-FK compound-key workaround does not
|
||||
* apply here and `name_projectId` can be used directly.
|
||||
*/
|
||||
|
||||
export interface EpicCreateInput {
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string;
|
||||
doneCriteria?: Prisma.InputJsonValue;
|
||||
acceptedTradeoffs?: Prisma.InputJsonValue;
|
||||
projectId: string;
|
||||
ownerId: string;
|
||||
semver?: string;
|
||||
}
|
||||
|
||||
export interface EpicUpdateInput {
|
||||
content?: string;
|
||||
description?: string;
|
||||
doneCriteria?: Prisma.InputJsonValue;
|
||||
acceptedTradeoffs?: Prisma.InputJsonValue;
|
||||
status?: EpicStatus;
|
||||
semver?: string;
|
||||
currentRevisionId?: string | null;
|
||||
}
|
||||
|
||||
export interface IEpicRepository {
|
||||
findAll(projectId?: string): Promise<Epic[]>;
|
||||
findById(id: string): Promise<Epic | null>;
|
||||
findByName(name: string): Promise<Epic | null>;
|
||||
findByNameAndProject(name: string, projectId: string): Promise<Epic | null>;
|
||||
create(data: EpicCreateInput): Promise<Epic>;
|
||||
update(id: string, data: EpicUpdateInput): Promise<Epic>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
const EPIC_INCLUDE = { project: { select: { name: true } } } as const;
|
||||
|
||||
export class EpicRepository implements IEpicRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(projectId?: string): Promise<Epic[]> {
|
||||
return this.prisma.epic.findMany({
|
||||
...(projectId !== undefined ? { where: { projectId } } : {}),
|
||||
include: EPIC_INCLUDE,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Epic | null> {
|
||||
return this.prisma.epic.findUnique({ where: { id }, include: EPIC_INCLUDE });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve by bare name. Epic names are unique per project, not globally, so
|
||||
* this returns the first match by name — used by the RBAC CUID→name resolver,
|
||||
* which only needs *a* name for the binding comparison. Callers that care
|
||||
* about a specific project must use findByNameAndProject.
|
||||
*/
|
||||
async findByName(name: string): Promise<Epic | null> {
|
||||
return this.prisma.epic.findFirst({ where: { name }, include: EPIC_INCLUDE });
|
||||
}
|
||||
|
||||
async findByNameAndProject(name: string, projectId: string): Promise<Epic | null> {
|
||||
return this.prisma.epic.findUnique({
|
||||
where: { name_projectId: { name, projectId } },
|
||||
include: EPIC_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: EpicCreateInput): Promise<Epic> {
|
||||
return this.prisma.epic.create({ data, include: EPIC_INCLUDE });
|
||||
}
|
||||
|
||||
async update(id: string, data: EpicUpdateInput): Promise<Epic> {
|
||||
// Optimistic-concurrency counter is bumped in the repository, unconditionally
|
||||
// and on every write — same convention as AgentRepository.
|
||||
return this.prisma.epic.update({
|
||||
where: { id },
|
||||
data: { ...data, version: { increment: 1 } },
|
||||
include: EPIC_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.epic.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import type { PrismaClient, Prisma, ResourceRevision } from '@prisma/client';
|
||||
* service layer to keep them in sync with real IDs.
|
||||
*/
|
||||
|
||||
export type ResourceType = 'prompt' | 'skill';
|
||||
export type ResourceType = 'prompt' | 'skill' | 'epic';
|
||||
|
||||
export interface CreateRevisionInput {
|
||||
resourceType: ResourceType;
|
||||
|
||||
107
src/mcpd/src/routes/epics.ts
Normal file
107
src/mcpd/src/routes/epics.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { Epic } from '@prisma/client';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
import type { IProjectRepository } from '../repositories/project.repository.js';
|
||||
import type { EpicService } from '../services/epic.service.js';
|
||||
import { EpicService as EpicSvc } from '../services/epic.service.js';
|
||||
|
||||
/** Attach computed completeness so clients never re-derive the rule. */
|
||||
function withCompleteness(epic: Epic): Epic & { completeness: ReturnType<typeof EpicSvc.completeness> } {
|
||||
return { ...epic, completeness: EpicSvc.completeness(epic) };
|
||||
}
|
||||
|
||||
export function registerEpicRoutes(
|
||||
app: FastifyInstance,
|
||||
service: EpicService,
|
||||
projectRepo: IProjectRepository,
|
||||
): void {
|
||||
// ── List ──
|
||||
|
||||
app.get<{ Querystring: { project?: string; projectId?: string } }>(
|
||||
'/api/v1/epics',
|
||||
async (request) => {
|
||||
const { project, projectId } = request.query;
|
||||
let epics: Epic[];
|
||||
if (project !== undefined) {
|
||||
const proj = await projectRepo.findByName(project);
|
||||
if (proj === null) {
|
||||
throw Object.assign(new Error(`Project not found: ${project}`), { statusCode: 404 });
|
||||
}
|
||||
epics = await service.listEpics(proj.id);
|
||||
} else if (projectId !== undefined) {
|
||||
epics = await service.listEpics(projectId);
|
||||
} else {
|
||||
epics = await service.listEpics();
|
||||
}
|
||||
return epics.map(withCompleteness);
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/epics/:id', async (request) => {
|
||||
return withCompleteness(await service.getEpic(request.params.id));
|
||||
});
|
||||
|
||||
// ── Create / Update / Delete ──
|
||||
|
||||
app.post('/api/v1/epics', async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const resolved: Record<string, unknown> = { ...body };
|
||||
|
||||
// Accept `project` by name, same as skills/prompts do.
|
||||
if (typeof body['project'] === 'string') {
|
||||
const proj = await projectRepo.findByName(body['project']);
|
||||
if (proj === null) {
|
||||
throw Object.assign(new Error(`Project not found: ${body['project']}`), { statusCode: 404 });
|
||||
}
|
||||
resolved['projectId'] = proj.id;
|
||||
delete resolved['project'];
|
||||
}
|
||||
|
||||
const ownerId = request.userId ?? 'system';
|
||||
const epic = await service.createEpic(resolved, ownerId);
|
||||
reply.code(201);
|
||||
return withCompleteness(epic);
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/epics/:id', async (request) => {
|
||||
return withCompleteness(await service.updateEpic(request.params.id, request.body));
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/epics/:id', async (request, reply) => {
|
||||
await service.deleteEpic(request.params.id);
|
||||
reply.code(204);
|
||||
});
|
||||
|
||||
// ── The honest-done gate ──
|
||||
// Separate from PUT so the rule cannot be bypassed by a plain field write.
|
||||
|
||||
app.post<{ Params: { id: string } }>('/api/v1/epics/:id/status', async (request) => {
|
||||
return withCompleteness(await service.setStatus(request.params.id, request.body));
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>('/api/v1/epics/:id/tradeoffs', async (request, reply) => {
|
||||
const epic = await service.addTradeoff(request.params.id, request.body, request.userId);
|
||||
reply.code(201);
|
||||
return withCompleteness(epic);
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string; criterionId: string }; Body: { met?: boolean } }>(
|
||||
'/api/v1/epics/:id/criteria/:criterionId',
|
||||
async (request) => {
|
||||
const met = request.body?.met ?? true;
|
||||
return withCompleteness(
|
||||
await service.setCriterionMet(request.params.id, request.params.criterionId, met),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Project-scoped view ──
|
||||
|
||||
app.get<{ Params: { name: string } }>('/api/v1/projects/:name/epics', async (request) => {
|
||||
const proj = await projectRepo.findByName(request.params.name);
|
||||
if (proj === null) {
|
||||
throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 });
|
||||
}
|
||||
return (await service.listEpics(proj.id)).map(withCompleteness);
|
||||
});
|
||||
}
|
||||
279
src/mcpd/src/services/epic.service.ts
Normal file
279
src/mcpd/src/services/epic.service.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import type { Epic, EpicStatus, Prisma } from '@prisma/client';
|
||||
|
||||
import type { IEpicRepository } from '../repositories/epic.repository.js';
|
||||
import type { IProjectRepository } from '../repositories/project.repository.js';
|
||||
import {
|
||||
CreateEpicSchema,
|
||||
UpdateEpicSchema,
|
||||
SetEpicStatusSchema,
|
||||
AddTradeoffSchema,
|
||||
type AcceptedTradeoff,
|
||||
type DoneCriterion,
|
||||
} from '../validation/epic.schema.js';
|
||||
import { bumpSemver, type BumpKind } from '../utils/semver.js';
|
||||
|
||||
import { NotFoundError } from './mcp-server.service.js';
|
||||
import type { ResourceRevisionService } from './resource-revision.service.js';
|
||||
|
||||
export interface EpicCompleteness {
|
||||
total: number;
|
||||
met: number;
|
||||
/** 0-100, rounded. 100 with zero criteria means "nothing claimed yet". */
|
||||
percent: number;
|
||||
unmet: DoneCriterion[];
|
||||
}
|
||||
|
||||
function badRequest(message: string): Error {
|
||||
return Object.assign(new Error(message), { statusCode: 400 });
|
||||
}
|
||||
|
||||
function readCriteria(epic: Epic): DoneCriterion[] {
|
||||
return (epic.doneCriteria ?? []) as unknown as DoneCriterion[];
|
||||
}
|
||||
|
||||
function readTradeoffs(epic: Epic): AcceptedTradeoff[] {
|
||||
return (epic.acceptedTradeoffs ?? []) as unknown as AcceptedTradeoff[];
|
||||
}
|
||||
|
||||
export class EpicService {
|
||||
private revisionService: ResourceRevisionService | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly epicRepo: IEpicRepository,
|
||||
private readonly projectRepo: IProjectRepository,
|
||||
) {}
|
||||
|
||||
setRevisionService(service: ResourceRevisionService): void {
|
||||
this.revisionService = service;
|
||||
}
|
||||
|
||||
// ── Completeness ──
|
||||
|
||||
/**
|
||||
* How much of the vision is actually delivered. Both `outcome` and `quality`
|
||||
* criteria count — grading only the mechanical half is precisely how you end
|
||||
* up with every task done and no car.
|
||||
*/
|
||||
static completeness(epic: Epic): EpicCompleteness {
|
||||
const criteria = readCriteria(epic);
|
||||
const met = criteria.filter((c) => c.met).length;
|
||||
const total = criteria.length;
|
||||
return {
|
||||
total,
|
||||
met,
|
||||
percent: total === 0 ? 0 : Math.round((met / total) * 100),
|
||||
unmet: criteria.filter((c) => !c.met),
|
||||
};
|
||||
}
|
||||
|
||||
// ── CRUD ──
|
||||
|
||||
async listEpics(projectId?: string): Promise<Epic[]> {
|
||||
return this.epicRepo.findAll(projectId);
|
||||
}
|
||||
|
||||
async getEpic(id: string): Promise<Epic> {
|
||||
const epic = await this.epicRepo.findById(id);
|
||||
if (epic === null) throw new NotFoundError(`Epic not found: ${id}`);
|
||||
return epic;
|
||||
}
|
||||
|
||||
async createEpic(input: unknown, ownerId: string): Promise<Epic> {
|
||||
const data = CreateEpicSchema.parse(input);
|
||||
|
||||
const project = await this.projectRepo.findById(data.projectId);
|
||||
if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`);
|
||||
|
||||
const duplicate = await this.epicRepo.findByNameAndProject(data.name, data.projectId);
|
||||
if (duplicate !== null) {
|
||||
throw Object.assign(
|
||||
new Error(`Epic '${data.name}' already exists in project '${project.name}'`),
|
||||
{ statusCode: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
this.assertCriteriaIdsUnique(data.doneCriteria);
|
||||
|
||||
const epic = await this.epicRepo.create({
|
||||
name: data.name,
|
||||
content: data.content,
|
||||
projectId: data.projectId,
|
||||
ownerId,
|
||||
...(data.description !== undefined ? { description: data.description } : {}),
|
||||
...(data.doneCriteria !== undefined
|
||||
? { doneCriteria: data.doneCriteria as unknown as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
...(data.acceptedTradeoffs !== undefined
|
||||
? { acceptedTradeoffs: data.acceptedTradeoffs as unknown as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
...(data.semver !== undefined ? { semver: data.semver } : {}),
|
||||
});
|
||||
|
||||
if (this.revisionService) {
|
||||
this.recordRevision(epic, epic.semver, 'created').catch(() => {});
|
||||
}
|
||||
return epic;
|
||||
}
|
||||
|
||||
async updateEpic(id: string, input: unknown): Promise<Epic> {
|
||||
const data = UpdateEpicSchema.parse(input);
|
||||
if (data.semver !== undefined && data.bump !== undefined) {
|
||||
throw badRequest('Pass semver or bump, not both');
|
||||
}
|
||||
const existing = await this.getEpic(id);
|
||||
this.assertCriteriaIdsUnique(data.doneCriteria);
|
||||
|
||||
const contentChanged =
|
||||
data.content !== undefined || data.description !== undefined || data.doneCriteria !== undefined;
|
||||
|
||||
let newSemver = existing.semver;
|
||||
if (data.semver !== undefined) {
|
||||
newSemver = data.semver;
|
||||
} else if (data.bump !== undefined) {
|
||||
newSemver = bumpSemver(existing.semver, data.bump as BumpKind);
|
||||
} else if (contentChanged) {
|
||||
// Scope changes are versioned so creep is as visible as under-delivery.
|
||||
newSemver = bumpSemver(existing.semver, 'patch');
|
||||
}
|
||||
|
||||
const epic = await this.epicRepo.update(id, {
|
||||
...(data.content !== undefined ? { content: data.content } : {}),
|
||||
...(data.description !== undefined ? { description: data.description } : {}),
|
||||
...(data.doneCriteria !== undefined
|
||||
? { doneCriteria: data.doneCriteria as unknown as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
...(newSemver !== existing.semver ? { semver: newSemver } : {}),
|
||||
});
|
||||
|
||||
if (this.revisionService && (contentChanged || data.bump !== undefined || data.semver !== undefined)) {
|
||||
this.recordRevision(epic, newSemver, data.note ?? null).catch(() => {});
|
||||
}
|
||||
return epic;
|
||||
}
|
||||
|
||||
async deleteEpic(id: string): Promise<void> {
|
||||
await this.getEpic(id); // 404 if missing
|
||||
await this.epicRepo.delete(id);
|
||||
}
|
||||
|
||||
// ── The honest-done rule ──
|
||||
|
||||
/**
|
||||
* Move an Epic to a terminal status.
|
||||
*
|
||||
* An Epic is done ONLY when 100% of its criteria are met, OR the user
|
||||
* explicitly accepted the gap. Anything else is a stick with wheels being
|
||||
* called a car, so:
|
||||
*
|
||||
* - `delivered` requires every criterion met, and no recorded tradeoffs
|
||||
* (if you accepted tradeoffs, the honest status names them).
|
||||
* - `delivered_with_tradeoffs` requires at least one recorded tradeoff —
|
||||
* otherwise it is just `delivered` wearing a disguise.
|
||||
* - `cancelled` and `open` are always allowed; abandoning work needs no
|
||||
* ceremony, and reopening is how a gap gets fixed.
|
||||
*/
|
||||
async setStatus(id: string, input: unknown): Promise<Epic> {
|
||||
const { status, note } = SetEpicStatusSchema.parse(input);
|
||||
const epic = await this.getEpic(id);
|
||||
|
||||
if (status === 'delivered') {
|
||||
const { unmet, total, met } = EpicService.completeness(epic);
|
||||
if (total === 0) {
|
||||
throw badRequest(
|
||||
'Cannot mark an Epic delivered with no done-criteria — there is nothing to have delivered',
|
||||
);
|
||||
}
|
||||
if (unmet.length > 0) {
|
||||
const names = unmet.slice(0, 5).map((c) => c.id).join(', ');
|
||||
const more = unmet.length > 5 ? ` (+${String(unmet.length - 5)} more)` : '';
|
||||
throw badRequest(
|
||||
`Cannot mark delivered: ${String(met)}/${String(total)} criteria met. ` +
|
||||
`Unmet: ${names}${more}. Meet them, or record the accepted tradeoffs and use delivered_with_tradeoffs.`,
|
||||
);
|
||||
}
|
||||
if (readTradeoffs(epic).length > 0) {
|
||||
throw badRequest(
|
||||
'This Epic has accepted tradeoffs recorded — use delivered_with_tradeoffs so "done" stays honest',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'delivered_with_tradeoffs' && readTradeoffs(epic).length === 0) {
|
||||
throw badRequest(
|
||||
'Cannot mark delivered_with_tradeoffs without recording at least one accepted tradeoff',
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.epicRepo.update(id, { status: status as EpicStatus });
|
||||
|
||||
if (this.revisionService) {
|
||||
this.recordRevision(updated, updated.semver, note ?? `status → ${status}`).catch(() => {});
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Append an accepted tradeoff. Append-only — the record is the audit trail. */
|
||||
async addTradeoff(id: string, input: unknown, who?: string): Promise<Epic> {
|
||||
const parsed = AddTradeoffSchema.parse(input);
|
||||
const epic = await this.getEpic(id);
|
||||
|
||||
const entry: AcceptedTradeoff = {
|
||||
...parsed,
|
||||
...(parsed.who === undefined && who !== undefined ? { who } : {}),
|
||||
at: parsed.at ?? new Date().toISOString(),
|
||||
};
|
||||
const next = [...readTradeoffs(epic), entry];
|
||||
|
||||
const updated = await this.epicRepo.update(id, {
|
||||
acceptedTradeoffs: next as unknown as Prisma.InputJsonValue,
|
||||
});
|
||||
|
||||
if (this.revisionService) {
|
||||
this.recordRevision(updated, updated.semver, `accepted tradeoff: ${entry.what}`).catch(() => {});
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Flip one criterion's met flag without rewriting the whole array. */
|
||||
async setCriterionMet(id: string, criterionId: string, met: boolean): Promise<Epic> {
|
||||
const epic = await this.getEpic(id);
|
||||
const criteria = readCriteria(epic);
|
||||
const idx = criteria.findIndex((c) => c.id === criterionId);
|
||||
if (idx === -1) throw new NotFoundError(`Done-criterion not found on epic: ${criterionId}`);
|
||||
|
||||
const next = criteria.map((c, i) => (i === idx ? { ...c, met } : c));
|
||||
return this.epicRepo.update(id, { doneCriteria: next as unknown as Prisma.InputJsonValue });
|
||||
}
|
||||
|
||||
// ── Internals ──
|
||||
|
||||
private assertCriteriaIdsUnique(criteria?: DoneCriterion[]): void {
|
||||
if (criteria === undefined) return;
|
||||
const seen = new Set<string>();
|
||||
for (const c of criteria) {
|
||||
if (seen.has(c.id)) {
|
||||
throw badRequest(`Duplicate done-criterion id: ${c.id}`);
|
||||
}
|
||||
seen.add(c.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort revision write — same shape as PromptService / SkillService. */
|
||||
private async recordRevision(epic: Epic, semver: string, note: string | null): Promise<void> {
|
||||
if (this.revisionService === null) return;
|
||||
const { revision } = await this.revisionService.record({
|
||||
resourceType: 'epic',
|
||||
resourceId: epic.id,
|
||||
semver,
|
||||
body: {
|
||||
content: epic.content,
|
||||
description: epic.description,
|
||||
doneCriteria: epic.doneCriteria,
|
||||
acceptedTradeoffs: epic.acceptedTradeoffs,
|
||||
status: epic.status,
|
||||
},
|
||||
...(note !== null ? { note } : {}),
|
||||
});
|
||||
await this.epicRepo.update(epic.id, { currentRevisionId: revision.id });
|
||||
}
|
||||
}
|
||||
67
src/mcpd/src/validation/epic.schema.ts
Normal file
67
src/mcpd/src/validation/epic.schema.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
||||
const NAME_RE = /^[a-z0-9-]+$/;
|
||||
|
||||
/**
|
||||
* A single checkable outcome on the Epic. `kind` separates the two halves of
|
||||
* the two-tier Definition of Done:
|
||||
*
|
||||
* - `outcome` — mechanically checkable ("the CLI can create a task").
|
||||
* - `quality` — the intent/feel attributes that decide whether the delivered
|
||||
* whole is a car or a stick with wheels ("the roadmap reads at a glance").
|
||||
*
|
||||
* Both count toward completeness. Dropping `quality` from the count is exactly
|
||||
* the failure the Epic exists to prevent.
|
||||
*/
|
||||
export const DoneCriterionSchema = z.object({
|
||||
id: z.string().min(1).max(100),
|
||||
text: z.string().min(1).max(2000),
|
||||
kind: z.enum(['outcome', 'quality']).default('outcome'),
|
||||
met: z.boolean().default(false),
|
||||
});
|
||||
|
||||
/**
|
||||
* A tradeoff the user explicitly accepted. Recorded rather than inferred so
|
||||
* "done" stays honest and auditable: who de-scoped what, and why.
|
||||
*/
|
||||
export const AcceptedTradeoffSchema = z.object({
|
||||
what: z.string().min(1).max(2000),
|
||||
why: z.string().min(1).max(2000),
|
||||
who: z.string().min(1).max(200).optional(),
|
||||
deScopedBy: z.string().min(1).max(200).optional(),
|
||||
at: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export const CreateEpicSchema = z.object({
|
||||
name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'),
|
||||
content: z.string().min(1).max(200_000),
|
||||
description: z.string().max(500).optional(),
|
||||
doneCriteria: z.array(DoneCriterionSchema).max(200).optional(),
|
||||
acceptedTradeoffs: z.array(AcceptedTradeoffSchema).max(200).optional(),
|
||||
projectId: z.string().min(1),
|
||||
semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(),
|
||||
});
|
||||
|
||||
export const UpdateEpicSchema = z.object({
|
||||
content: z.string().min(1).max(200_000).optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
doneCriteria: z.array(DoneCriterionSchema).max(200).optional(),
|
||||
// Status is deliberately NOT settable here — reaching a terminal status goes
|
||||
// through EpicService.setStatus, which enforces the honest-done rule.
|
||||
semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(),
|
||||
bump: z.enum(['major', 'minor', 'patch']).optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export const SetEpicStatusSchema = z.object({
|
||||
status: z.enum(['open', 'delivered', 'delivered_with_tradeoffs', 'cancelled']),
|
||||
note: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export const AddTradeoffSchema = AcceptedTradeoffSchema;
|
||||
|
||||
export type CreateEpicInput = z.infer<typeof CreateEpicSchema>;
|
||||
export type UpdateEpicInput = z.infer<typeof UpdateEpicSchema>;
|
||||
export type DoneCriterion = z.infer<typeof DoneCriterionSchema>;
|
||||
export type AcceptedTradeoff = z.infer<typeof AcceptedTradeoffSchema>;
|
||||
222
src/mcpd/tests/epic-routes.test.ts
Normal file
222
src/mcpd/tests/epic-routes.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import type { Epic, Project } from '@prisma/client';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||
import type { IEpicRepository } from '../src/repositories/epic.repository.js';
|
||||
import type { IProjectRepository } from '../src/repositories/project.repository.js';
|
||||
import { registerEpicRoutes } from '../src/routes/epics.js';
|
||||
import { EpicService } from '../src/services/epic.service.js';
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
function makeEpic(overrides: Partial<Epic> = {}): Epic {
|
||||
return {
|
||||
id: 'epic-1',
|
||||
name: 'the-car',
|
||||
description: '',
|
||||
content: 'build a car',
|
||||
doneCriteria: [],
|
||||
acceptedTradeoffs: [],
|
||||
projectId: 'proj-1',
|
||||
status: 'open',
|
||||
semver: '0.1.0',
|
||||
currentRevisionId: null,
|
||||
ownerId: 'user-1',
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
} as Epic;
|
||||
}
|
||||
|
||||
const makeProject = (o: Partial<Project> = {}): Project =>
|
||||
({ id: 'proj-1', name: 'mcpctl', ...o } as Project);
|
||||
|
||||
const criterion = (id: string, met: boolean) =>
|
||||
({ id, text: `criterion ${id}`, kind: 'outcome' as const, met });
|
||||
|
||||
async function buildApp(epic: Epic | null = makeEpic(), userId?: string): Promise<{
|
||||
app: FastifyInstance;
|
||||
epicRepo: IEpicRepository & { update: ReturnType<typeof vi.fn> };
|
||||
}> {
|
||||
let current = epic;
|
||||
const update = vi.fn(async (_id: string, data: Record<string, unknown>) => {
|
||||
current = { ...(current as Epic), ...data } as Epic;
|
||||
return current;
|
||||
});
|
||||
const epicRepo = {
|
||||
findAll: vi.fn(async () => (current ? [current] : [])),
|
||||
findById: vi.fn(async () => current),
|
||||
findByName: vi.fn(async () => current),
|
||||
findByNameAndProject: vi.fn(async () => null),
|
||||
create: vi.fn(async (data: Record<string, unknown>) => makeEpic(data as Partial<Epic>)),
|
||||
update,
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as IEpicRepository & { update: ReturnType<typeof vi.fn> };
|
||||
|
||||
const projectRepo = {
|
||||
findById: vi.fn(async () => makeProject()),
|
||||
findByName: vi.fn(async (n: string) => (n === 'mcpctl' ? makeProject() : null)),
|
||||
} as unknown as IProjectRepository;
|
||||
|
||||
const instance = Fastify();
|
||||
instance.setErrorHandler(errorHandler);
|
||||
// Stand in for the auth middleware, which normally sets request.userId.
|
||||
if (userId !== undefined) {
|
||||
instance.addHook('onRequest', async (req) => { req.userId = userId; });
|
||||
}
|
||||
registerEpicRoutes(instance, new EpicService(epicRepo, projectRepo), projectRepo);
|
||||
await instance.ready();
|
||||
return { app: instance, epicRepo };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe('epic routes', () => {
|
||||
it('attaches computed completeness to list and get', async () => {
|
||||
const built = await buildApp(
|
||||
makeEpic({ doneCriteria: [criterion('a', true), criterion('b', false)] as never }),
|
||||
);
|
||||
app = built.app;
|
||||
|
||||
const list = await app.inject({ method: 'GET', url: '/api/v1/epics' });
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(list.json()[0].completeness).toMatchObject({ total: 2, met: 1, percent: 50 });
|
||||
|
||||
const one = await app.inject({ method: 'GET', url: '/api/v1/epics/epic-1' });
|
||||
expect(one.json().completeness.unmet).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates from a project name and stamps the caller as owner', async () => {
|
||||
const built = await buildApp(null, 'user-42');
|
||||
app = built.app;
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/epics',
|
||||
payload: { name: 'the-car', content: 'build a car', project: 'mcpctl' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(built.epicRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectId: 'proj-1', ownerId: 'user-42' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('404s on an unknown project name', async () => {
|
||||
const built = await buildApp(null);
|
||||
app = built.app;
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/epics',
|
||||
payload: { name: 'x', content: 'c', project: 'ghost' },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('400s a bad payload rather than 500ing', async () => {
|
||||
const built = await buildApp(null);
|
||||
app = built.app;
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/epics',
|
||||
payload: { name: 'Not Kebab', content: 'c', project: 'mcpctl' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('400s an unhonest delivered, and explains what is missing', async () => {
|
||||
const built = await buildApp(
|
||||
makeEpic({ doneCriteria: [criterion('a', true), criterion('b', false)] as never }),
|
||||
);
|
||||
app = built.app;
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/epics/epic-1/status',
|
||||
payload: { status: 'delivered' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error ?? res.json().message).toMatch(/1\/2 criteria met/);
|
||||
});
|
||||
|
||||
it('accepts delivered once everything is met', async () => {
|
||||
const built = await buildApp(makeEpic({ doneCriteria: [criterion('a', true)] as never }));
|
||||
app = built.app;
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/epics/epic-1/status',
|
||||
payload: { status: 'delivered' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().status).toBe('delivered');
|
||||
expect(res.json().completeness.percent).toBe(100);
|
||||
});
|
||||
|
||||
it('records a tradeoff and returns 201', async () => {
|
||||
const built = await buildApp(makeEpic(), 'user-42');
|
||||
app = built.app;
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/epics/epic-1/tradeoffs',
|
||||
payload: { what: 'no dark mode', why: 'out of budget' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const written = built.epicRepo.update.mock.calls[0]?.[1] as {
|
||||
acceptedTradeoffs: Array<{ what: string; who?: string }>;
|
||||
};
|
||||
expect(written.acceptedTradeoffs[0]).toMatchObject({ what: 'no dark mode', who: 'user-42' });
|
||||
});
|
||||
|
||||
it('flips a single criterion, defaulting met to true', async () => {
|
||||
const built = await buildApp(
|
||||
makeEpic({ doneCriteria: [criterion('a', false), criterion('b', false)] as never }),
|
||||
);
|
||||
app = built.app;
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/epics/epic-1/criteria/b',
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().completeness).toMatchObject({ met: 1, total: 2 });
|
||||
});
|
||||
|
||||
it('404s an unknown criterion', async () => {
|
||||
const built = await buildApp(makeEpic({ doneCriteria: [criterion('a', false)] as never }));
|
||||
app = built.app;
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/epics/epic-1/criteria/ghost',
|
||||
payload: { met: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('deletes with 204', async () => {
|
||||
const built = await buildApp();
|
||||
app = built.app;
|
||||
const res = await app.inject({ method: 'DELETE', url: '/api/v1/epics/epic-1' });
|
||||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
|
||||
it('serves the project-scoped view', async () => {
|
||||
const built = await buildApp();
|
||||
app = built.app;
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/mcpctl/epics' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toHaveLength(1);
|
||||
|
||||
const missing = await app.inject({ method: 'GET', url: '/api/v1/projects/ghost/epics' });
|
||||
expect(missing.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
283
src/mcpd/tests/services/epic-service.test.ts
Normal file
283
src/mcpd/tests/services/epic-service.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import type { Epic, Project } from '@prisma/client';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import type { IEpicRepository } from '../../src/repositories/epic.repository.js';
|
||||
import type { IProjectRepository } from '../../src/repositories/project.repository.js';
|
||||
import { EpicService } from '../../src/services/epic.service.js';
|
||||
|
||||
function makeEpic(overrides: Partial<Epic> = {}): Epic {
|
||||
return {
|
||||
id: 'epic-1',
|
||||
name: 'the-car',
|
||||
description: '',
|
||||
content: 'build a car',
|
||||
doneCriteria: [],
|
||||
acceptedTradeoffs: [],
|
||||
projectId: 'proj-1',
|
||||
status: 'open',
|
||||
semver: '0.1.0',
|
||||
currentRevisionId: null,
|
||||
ownerId: 'user-1',
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
} as Epic;
|
||||
}
|
||||
|
||||
const criterion = (id: string, met: boolean, kind: 'outcome' | 'quality' = 'outcome') =>
|
||||
({ id, text: `criterion ${id}`, kind, met });
|
||||
|
||||
function mockRepos(epic: Epic | null = makeEpic()) {
|
||||
let current = epic;
|
||||
const update = vi.fn(async (_id: string, data: Record<string, unknown>) => {
|
||||
current = { ...(current as Epic), ...data } as Epic;
|
||||
return current;
|
||||
});
|
||||
const epicRepo = {
|
||||
findAll: vi.fn(async () => (current ? [current] : [])),
|
||||
findById: vi.fn(async () => current),
|
||||
findByName: vi.fn(async () => current),
|
||||
findByNameAndProject: vi.fn(async () => null),
|
||||
create: vi.fn(async (data: Record<string, unknown>) => makeEpic(data as Partial<Epic>)),
|
||||
update,
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as IEpicRepository & { update: typeof update };
|
||||
|
||||
const projectRepo = {
|
||||
findById: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)),
|
||||
findByName: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)),
|
||||
} as unknown as IProjectRepository;
|
||||
|
||||
return { epicRepo, projectRepo, service: new EpicService(epicRepo, projectRepo) };
|
||||
}
|
||||
|
||||
describe('EpicService.completeness', () => {
|
||||
it('is 0% when nothing is claimed yet', () => {
|
||||
expect(EpicService.completeness(makeEpic()).percent).toBe(0);
|
||||
});
|
||||
|
||||
it('counts quality criteria, not just mechanical outcomes', () => {
|
||||
// The whole point of the Epic: grading only the checkable half is how you
|
||||
// end up with every task done and no car.
|
||||
const epic = makeEpic({
|
||||
doneCriteria: [criterion('a', true), criterion('feels-finished', false, 'quality')] as never,
|
||||
});
|
||||
const c = EpicService.completeness(epic);
|
||||
expect(c.total).toBe(2);
|
||||
expect(c.met).toBe(1);
|
||||
expect(c.percent).toBe(50);
|
||||
expect(c.unmet.map((u) => u.id)).toEqual(['feels-finished']);
|
||||
});
|
||||
|
||||
it('rounds to whole percent', () => {
|
||||
const epic = makeEpic({
|
||||
doneCriteria: [criterion('a', true), criterion('b', false), criterion('c', false)] as never,
|
||||
});
|
||||
expect(EpicService.completeness(epic).percent).toBe(33);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EpicService.setStatus — the honest-done rule', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it('refuses delivered while criteria are unmet, and names them', async () => {
|
||||
const { service } = mockRepos(
|
||||
makeEpic({ doneCriteria: [criterion('a', true), criterion('b', false)] as never }),
|
||||
);
|
||||
await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow(
|
||||
/1\/2 criteria met.*Unmet: b/s,
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates a long unmet list rather than dumping all of it', async () => {
|
||||
const many = Array.from({ length: 9 }, (_, i) => criterion(`c${String(i)}`, false));
|
||||
const { service } = mockRepos(makeEpic({ doneCriteria: many as never }));
|
||||
await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow(/\+4 more/);
|
||||
});
|
||||
|
||||
it('refuses delivered when no criteria exist at all', async () => {
|
||||
const { service } = mockRepos(makeEpic({ doneCriteria: [] as never }));
|
||||
await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow(
|
||||
/nothing to have delivered/,
|
||||
);
|
||||
});
|
||||
|
||||
it('allows delivered when every criterion is met', async () => {
|
||||
const { service, epicRepo } = mockRepos(
|
||||
makeEpic({ doneCriteria: [criterion('a', true), criterion('b', true)] as never }),
|
||||
);
|
||||
const result = await service.setStatus('epic-1', { status: 'delivered' });
|
||||
expect(result.status).toBe('delivered');
|
||||
expect(epicRepo.update).toHaveBeenCalledWith('epic-1', { status: 'delivered' });
|
||||
});
|
||||
|
||||
it('refuses plain delivered when tradeoffs were accepted — the status must name them', async () => {
|
||||
const { service } = mockRepos(
|
||||
makeEpic({
|
||||
doneCriteria: [criterion('a', true)] as never,
|
||||
acceptedTradeoffs: [{ what: 'no dark mode', why: 'budget' }] as never,
|
||||
}),
|
||||
);
|
||||
await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow(
|
||||
/use delivered_with_tradeoffs/,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses delivered_with_tradeoffs when none were recorded', async () => {
|
||||
const { service } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never }));
|
||||
await expect(
|
||||
service.setStatus('epic-1', { status: 'delivered_with_tradeoffs' }),
|
||||
).rejects.toThrow(/without recording at least one accepted tradeoff/);
|
||||
});
|
||||
|
||||
it('allows delivered_with_tradeoffs even with unmet criteria, once the gap is recorded', async () => {
|
||||
const { service } = mockRepos(
|
||||
makeEpic({
|
||||
doneCriteria: [criterion('a', true), criterion('b', false)] as never,
|
||||
acceptedTradeoffs: [{ what: 'b', why: 'deferred to next quarter' }] as never,
|
||||
}),
|
||||
);
|
||||
const result = await service.setStatus('epic-1', { status: 'delivered_with_tradeoffs' });
|
||||
expect(result.status).toBe('delivered_with_tradeoffs');
|
||||
});
|
||||
|
||||
it('always allows cancelled and reopening', async () => {
|
||||
const { service } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never }));
|
||||
await expect(service.setStatus('epic-1', { status: 'cancelled' })).resolves.toBeDefined();
|
||||
await expect(service.setStatus('epic-1', { status: 'open' })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects an unknown status', async () => {
|
||||
const { service } = mockRepos();
|
||||
await expect(service.setStatus('epic-1', { status: 'shipped' })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EpicService.addTradeoff', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it('appends rather than replacing — the record is the audit trail', async () => {
|
||||
const { service, epicRepo } = mockRepos(
|
||||
makeEpic({ acceptedTradeoffs: [{ what: 'first', why: 'x', at: '2026-01-01T00:00:00.000Z' }] as never }),
|
||||
);
|
||||
await service.addTradeoff('epic-1', { what: 'second', why: 'y' });
|
||||
|
||||
const written = epicRepo.update.mock.calls[0]?.[1] as { acceptedTradeoffs: Array<{ what: string }> };
|
||||
expect(written.acceptedTradeoffs.map((t) => t.what)).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
it('stamps who from the caller and when from the clock', async () => {
|
||||
const { service, epicRepo } = mockRepos();
|
||||
await service.addTradeoff('epic-1', { what: 'no dark mode', why: 'budget' }, 'user-42');
|
||||
|
||||
const written = epicRepo.update.mock.calls[0]?.[1] as {
|
||||
acceptedTradeoffs: Array<{ who?: string; at?: string }>;
|
||||
};
|
||||
expect(written.acceptedTradeoffs[0]?.who).toBe('user-42');
|
||||
expect(written.acceptedTradeoffs[0]?.at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('does not overwrite an explicitly supplied who', async () => {
|
||||
const { service, epicRepo } = mockRepos();
|
||||
await service.addTradeoff('epic-1', { what: 'x', why: 'y', who: 'the-cto' }, 'user-42');
|
||||
const written = epicRepo.update.mock.calls[0]?.[1] as { acceptedTradeoffs: Array<{ who?: string }> };
|
||||
expect(written.acceptedTradeoffs[0]?.who).toBe('the-cto');
|
||||
});
|
||||
|
||||
it('requires both what and why', async () => {
|
||||
const { service } = mockRepos();
|
||||
await expect(service.addTradeoff('epic-1', { what: 'x' })).rejects.toThrow();
|
||||
await expect(service.addTradeoff('epic-1', { why: 'y' })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EpicService.setCriterionMet', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it('flips one flag and leaves the rest alone', async () => {
|
||||
const { service, epicRepo } = mockRepos(
|
||||
makeEpic({ doneCriteria: [criterion('a', false), criterion('b', false)] as never }),
|
||||
);
|
||||
await service.setCriterionMet('epic-1', 'b', true);
|
||||
|
||||
const written = epicRepo.update.mock.calls[0]?.[1] as {
|
||||
doneCriteria: Array<{ id: string; met: boolean }>;
|
||||
};
|
||||
expect(written.doneCriteria).toEqual([
|
||||
expect.objectContaining({ id: 'a', met: false }),
|
||||
expect.objectContaining({ id: 'b', met: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('404s on an unknown criterion', async () => {
|
||||
const { service } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never }));
|
||||
await expect(service.setCriterionMet('epic-1', 'nope', true)).rejects.toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EpicService create/update', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it('rejects duplicate criterion ids', async () => {
|
||||
const { service } = mockRepos(null);
|
||||
await expect(
|
||||
service.createEpic(
|
||||
{
|
||||
name: 'x', content: 'c', projectId: 'proj-1',
|
||||
doneCriteria: [criterion('dup', false), criterion('dup', true)],
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(/Duplicate done-criterion id: dup/);
|
||||
});
|
||||
|
||||
it('409s on a duplicate epic name within the project', async () => {
|
||||
const { service, epicRepo } = mockRepos(null);
|
||||
(epicRepo.findByNameAndProject as ReturnType<typeof vi.fn>).mockResolvedValueOnce(makeEpic());
|
||||
await expect(
|
||||
service.createEpic({ name: 'the-car', content: 'c', projectId: 'proj-1' }, 'user-1'),
|
||||
).rejects.toThrow(/already exists in project 'mcpctl'/);
|
||||
});
|
||||
|
||||
it('404s when the project does not exist', async () => {
|
||||
const { service, projectRepo } = mockRepos(null);
|
||||
(projectRepo.findById as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||
await expect(
|
||||
service.createEpic({ name: 'x', content: 'c', projectId: 'ghost' }, 'user-1'),
|
||||
).rejects.toThrow(/Project not found/);
|
||||
});
|
||||
|
||||
it('rejects a name that is not lowercase-kebab', async () => {
|
||||
const { service } = mockRepos(null);
|
||||
await expect(
|
||||
service.createEpic({ name: 'The Car', content: 'c', projectId: 'proj-1' }, 'user-1'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('patch-bumps semver when scope changes, so creep is versioned', async () => {
|
||||
const { service, epicRepo } = mockRepos(makeEpic({ semver: '1.2.3' }));
|
||||
await service.updateEpic('epic-1', { doneCriteria: [criterion('new', false)] });
|
||||
expect(epicRepo.update.mock.calls[0]?.[1]).toMatchObject({ semver: '1.2.4' });
|
||||
});
|
||||
|
||||
it('honours an explicit bump', async () => {
|
||||
const { service, epicRepo } = mockRepos(makeEpic({ semver: '1.2.3' }));
|
||||
await service.updateEpic('epic-1', { content: 'rewritten', bump: 'minor' });
|
||||
expect(epicRepo.update.mock.calls[0]?.[1]).toMatchObject({ semver: '1.3.0' });
|
||||
});
|
||||
|
||||
it('refuses semver and bump together', async () => {
|
||||
const { service } = mockRepos();
|
||||
await expect(
|
||||
service.updateEpic('epic-1', { content: 'x', semver: '2.0.0', bump: 'major' }),
|
||||
).rejects.toThrow(/not both/);
|
||||
});
|
||||
|
||||
it('does not let a plain update set status — that gate is setStatus only', async () => {
|
||||
const { service, epicRepo } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never }));
|
||||
await service.updateEpic('epic-1', { content: 'x', status: 'delivered' } as never);
|
||||
const written = epicRepo.update.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(written['status']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user