agentic-teams: epics, tasks, git forges, agent authority #139
@@ -2669,9 +2669,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"117"
|
"117"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T10:05:28.155Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "124",
|
"id": "124",
|
||||||
@@ -2683,9 +2683,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"123"
|
"123"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T10:05:28.165Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "125",
|
"id": "125",
|
||||||
@@ -2697,9 +2697,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"117"
|
"117"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T10:05:28.172Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "126",
|
"id": "126",
|
||||||
@@ -2712,9 +2712,9 @@
|
|||||||
"124",
|
"124",
|
||||||
"125"
|
"125"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T10:05:28.178Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "127",
|
"id": "127",
|
||||||
@@ -3251,9 +3251,9 @@
|
|||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-08-08T09:36:21.139Z",
|
"lastModified": "2026-08-08T10:05:28.178Z",
|
||||||
"taskCount": 162,
|
"taskCount": 162,
|
||||||
"completedCount": 107,
|
"completedCount": 111,
|
||||||
"tags": [
|
"tags": [
|
||||||
"master"
|
"master"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ import {
|
|||||||
import { registerPromptRoutes } from './routes/prompts.js';
|
import { registerPromptRoutes } from './routes/prompts.js';
|
||||||
import { registerRevisionRoutes } from './routes/revisions.js';
|
import { registerRevisionRoutes } from './routes/revisions.js';
|
||||||
import { registerProposalRoutes } from './routes/proposals.js';
|
import { registerProposalRoutes } from './routes/proposals.js';
|
||||||
|
import { registerEpicRoutes } from './routes/epics.js';
|
||||||
import { registerSkillRoutes } from './routes/skills.js';
|
import { registerSkillRoutes } from './routes/skills.js';
|
||||||
import { registerGitBackupRoutes } from './routes/git-backup.js';
|
import { registerGitBackupRoutes } from './routes/git-backup.js';
|
||||||
import { PromptService } from './services/prompt.service.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 { ResourceProposalRepository } from './repositories/resource-proposal.repository.js';
|
||||||
import { ResourceRevisionService } from './services/resource-revision.service.js';
|
import { ResourceRevisionService } from './services/resource-revision.service.js';
|
||||||
import { ResourceProposalService } from './services/resource-proposal.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 { SkillRepository } from './repositories/skill.repository.js';
|
||||||
|
import { EpicService } from './services/epic.service.js';
|
||||||
import { SkillService } from './services/skill.service.js';
|
import { SkillService } from './services/skill.service.js';
|
||||||
import { GitBackupService } from './services/backup/git-backup.service.js';
|
import { GitBackupService } from './services/backup/git-backup.service.js';
|
||||||
import type { BackupKind } from './services/backup/yaml-serializer.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
|
// Personalities inherit the agent's RBAC: managing a personality
|
||||||
// requires view/edit/create/delete on the `agents` resource.
|
// requires view/edit/create/delete on the `agents` resource.
|
||||||
'personalities': 'agents',
|
'personalities': 'agents',
|
||||||
|
// ── Agentic teams ──
|
||||||
|
'epics': 'epics',
|
||||||
};
|
};
|
||||||
|
|
||||||
const resource = resourceMap[segment];
|
const resource = resourceMap[segment];
|
||||||
@@ -427,6 +432,9 @@ async function main(): Promise<void> {
|
|||||||
const userRepo = new UserRepository(prisma);
|
const userRepo = new UserRepository(prisma);
|
||||||
const groupRepo = new GroupRepository(prisma);
|
const groupRepo = new GroupRepository(prisma);
|
||||||
const mcpTokenRepo = new McpTokenRepository(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
|
// SecretBackend bootstrap: ensure a `plaintext` default row exists and any
|
||||||
// pre-existing `Secret` rows are pointed at it. Idempotent per run.
|
// pre-existing `Secret` rows are pointed at it. Idempotent per run.
|
||||||
@@ -443,6 +451,7 @@ async function main(): Promise<void> {
|
|||||||
mcptokens: mcpTokenRepo,
|
mcptokens: mcpTokenRepo,
|
||||||
llms: llmRepo,
|
llms: llmRepo,
|
||||||
agents: agentRepo,
|
agents: agentRepo,
|
||||||
|
epics: epicRepo,
|
||||||
// Resolve user CUID → email so name-scoped `edit:users:<email>` bindings
|
// Resolve user CUID → email so name-scoped `edit:users:<email>` bindings
|
||||||
// match on PUT /api/v1/users/:id/password (admin reset).
|
// match on PUT /api/v1/users/:id/password (admin reset).
|
||||||
users: { findById: async (id: string) => {
|
users: { findById: async (id: string) => {
|
||||||
@@ -545,6 +554,11 @@ async function main(): Promise<void> {
|
|||||||
const skillService = new SkillService(skillRepo, projectRepo, agentRepo);
|
const skillService = new SkillService(skillRepo, projectRepo, agentRepo);
|
||||||
skillService.setRevisionService(resourceRevisionService);
|
skillService.setRevisionService(resourceRevisionService);
|
||||||
skillService.setProposalService(resourceProposalService);
|
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 personalityRepo = new PersonalityRepository(prisma);
|
||||||
const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo);
|
const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo);
|
||||||
const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo);
|
const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo);
|
||||||
@@ -753,6 +767,7 @@ async function main(): Promise<void> {
|
|||||||
registerRevisionRoutes(app, { revisionService: resourceRevisionService, promptService });
|
registerRevisionRoutes(app, { revisionService: resourceRevisionService, promptService });
|
||||||
registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo });
|
registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo });
|
||||||
registerSkillRoutes(app, skillService, projectRepo, agentRepo);
|
registerSkillRoutes(app, skillService, projectRepo, agentRepo);
|
||||||
|
registerEpicRoutes(app, epicService, projectRepo);
|
||||||
|
|
||||||
// ── Git-based backup ──
|
// ── Git-based backup ──
|
||||||
const gitBackup = new GitBackupService(prisma);
|
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.
|
* service layer to keep them in sync with real IDs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ResourceType = 'prompt' | 'skill';
|
export type ResourceType = 'prompt' | 'skill' | 'epic';
|
||||||
|
|
||||||
export interface CreateRevisionInput {
|
export interface CreateRevisionInput {
|
||||||
resourceType: ResourceType;
|
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