/** * Phase Router — create, list, get, update, dependencies, bulk create */ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import type { Phase } from '../../db/schema.js'; import type { ProcedureBuilder } from '../trpc.js'; import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator, requireReviewCommentRepository } from './_helpers.js'; import { phaseBranchName } from '../../git/branch-naming.js'; import { ensureProjectClone } from '../../git/project-clones.js'; export function phaseProcedures(publicProcedure: ProcedureBuilder) { return { createPhase: publicProcedure .input(z.object({ initiativeId: z.string().min(1), name: z.string().min(1), })) .mutation(async ({ ctx, input }) => { const repo = requirePhaseRepository(ctx); return repo.create({ initiativeId: input.initiativeId, name: input.name, status: 'pending', }); }), listPhases: publicProcedure .input(z.object({ initiativeId: z.string().min(1) })) .query(async ({ ctx, input }) => { const repo = requirePhaseRepository(ctx); return repo.findByInitiativeId(input.initiativeId); }), getPhase: publicProcedure .input(z.object({ id: z.string().min(1) })) .query(async ({ ctx, input }) => { const repo = requirePhaseRepository(ctx); const phase = await repo.findById(input.id); if (!phase) { throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.id}' not found`, }); } return phase; }), updatePhase: publicProcedure .input(z.object({ id: z.string().min(1), name: z.string().min(1).optional(), content: z.string().nullable().optional(), status: z.enum(['pending', 'approved', 'in_progress', 'completed', 'blocked', 'pending_review']).optional(), })) .mutation(async ({ ctx, input }) => { const repo = requirePhaseRepository(ctx); const { id, ...data } = input; return repo.update(id, data); }), approvePhase: publicProcedure .input(z.object({ phaseId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const repo = requirePhaseRepository(ctx); const taskRepo = requireTaskRepository(ctx); const phase = await repo.findById(input.phaseId); if (!phase) { throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found`, }); } if (phase.status !== 'pending') { throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase must be pending to approve (current status: ${phase.status})`, }); } // Validate phase has work tasks (filter out detail tasks) const phaseTasks = await taskRepo.findByPhaseId(input.phaseId); const workTasks = phaseTasks.filter((t) => t.category !== 'detail'); if (workTasks.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Phase must have tasks before it can be approved', }); } return repo.update(input.phaseId, { status: 'approved' }); }), deletePhase: publicProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const repo = requirePhaseRepository(ctx); await repo.delete(input.id); return { success: true }; }), createPhasesFromPlan: publicProcedure .input(z.object({ initiativeId: z.string().min(1), phases: z.array(z.object({ name: z.string().min(1), })), })) .mutation(async ({ ctx, input }) => { const repo = requirePhaseRepository(ctx); const created: Phase[] = []; for (const p of input.phases) { const phase = await repo.create({ initiativeId: input.initiativeId, name: p.name, status: 'pending', }); created.push(phase); } return created; }), listInitiativePhaseDependencies: publicProcedure .input(z.object({ initiativeId: z.string().min(1) })) .query(async ({ ctx, input }) => { const repo = requirePhaseRepository(ctx); return repo.findDependenciesByInitiativeId(input.initiativeId); }), createPhaseDependency: publicProcedure .input(z.object({ phaseId: z.string().min(1), dependsOnPhaseId: z.string().min(1), })) .mutation(async ({ ctx, input }) => { const repo = requirePhaseRepository(ctx); const phase = await repo.findById(input.phaseId); if (!phase) { throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found`, }); } const dependsOnPhase = await repo.findById(input.dependsOnPhaseId); if (!dependsOnPhase) { throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.dependsOnPhaseId}' not found`, }); } await repo.createDependency(input.phaseId, input.dependsOnPhaseId); return { success: true }; }), getPhaseDependencies: publicProcedure .input(z.object({ phaseId: z.string().min(1) })) .query(async ({ ctx, input }) => { const repo = requirePhaseRepository(ctx); const dependencies = await repo.getDependencies(input.phaseId); return { dependencies }; }), getPhaseDependents: publicProcedure .input(z.object({ phaseId: z.string().min(1) })) .query(async ({ ctx, input }) => { const repo = requirePhaseRepository(ctx); const dependents = await repo.getDependents(input.phaseId); return { dependents }; }), removePhaseDependency: publicProcedure .input(z.object({ phaseId: z.string().min(1), dependsOnPhaseId: z.string().min(1), })) .mutation(async ({ ctx, input }) => { const repo = requirePhaseRepository(ctx); await repo.removeDependency(input.phaseId, input.dependsOnPhaseId); return { success: true }; }), getPhaseReviewDiff: publicProcedure .input(z.object({ phaseId: z.string().min(1) })) .query(async ({ ctx, input }) => { const phaseRepo = requirePhaseRepository(ctx); const initiativeRepo = requireInitiativeRepository(ctx); const projectRepo = requireProjectRepository(ctx); const branchManager = requireBranchManager(ctx); const phase = await phaseRepo.findById(input.phaseId); if (!phase) { throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` }); } if (phase.status !== 'pending_review') { throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not pending review (status: ${phase.status})` }); } const initiative = await initiativeRepo.findById(phase.initiativeId); if (!initiative?.branch) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' }); } const initBranch = initiative.branch; const phBranch = phaseBranchName(initBranch, phase.name); const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId); let rawDiff = ''; for (const project of projects) { const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); const diff = await branchManager.diffBranches(clonePath, initBranch, phBranch); if (diff) { rawDiff += diff + '\n'; } } return { phaseName: phase.name, sourceBranch: phBranch, targetBranch: initBranch, rawDiff, }; }), approvePhaseReview: publicProcedure .input(z.object({ phaseId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const orchestrator = requireExecutionOrchestrator(ctx); await orchestrator.approveAndMergePhase(input.phaseId); return { success: true }; }), getPhaseReviewCommits: publicProcedure .input(z.object({ phaseId: z.string().min(1) })) .query(async ({ ctx, input }) => { const phaseRepo = requirePhaseRepository(ctx); const initiativeRepo = requireInitiativeRepository(ctx); const projectRepo = requireProjectRepository(ctx); const branchManager = requireBranchManager(ctx); const phase = await phaseRepo.findById(input.phaseId); if (!phase) { throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` }); } if (phase.status !== 'pending_review') { throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not pending review (status: ${phase.status})` }); } const initiative = await initiativeRepo.findById(phase.initiativeId); if (!initiative?.branch) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' }); } const initBranch = initiative.branch; const phBranch = phaseBranchName(initBranch, phase.name); const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId); const allCommits: Array<{ hash: string; shortHash: string; message: string; author: string; date: string; filesChanged: number; insertions: number; deletions: number }> = []; for (const project of projects) { const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); const commits = await branchManager.listCommits(clonePath, initBranch, phBranch); allCommits.push(...commits); } return { commits: allCommits, sourceBranch: phBranch, targetBranch: initBranch }; }), getCommitDiff: publicProcedure .input(z.object({ phaseId: z.string().min(1), commitHash: z.string().min(1) })) .query(async ({ ctx, input }) => { const phaseRepo = requirePhaseRepository(ctx); const projectRepo = requireProjectRepository(ctx); const branchManager = requireBranchManager(ctx); const phase = await phaseRepo.findById(input.phaseId); if (!phase) { throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` }); } const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId); let rawDiff = ''; for (const project of projects) { const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); try { const diff = await branchManager.diffCommit(clonePath, input.commitHash); if (diff) rawDiff += diff + '\n'; } catch { // commit not in this project clone } } return { rawDiff }; }), listReviewComments: publicProcedure .input(z.object({ phaseId: z.string().min(1) })) .query(async ({ ctx, input }) => { const repo = requireReviewCommentRepository(ctx); return repo.findByPhaseId(input.phaseId); }), createReviewComment: publicProcedure .input(z.object({ phaseId: z.string().min(1), filePath: z.string().min(1), lineNumber: z.number().int(), lineType: z.enum(['added', 'removed', 'context']), body: z.string().min(1), author: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const repo = requireReviewCommentRepository(ctx); return repo.create(input); }), resolveReviewComment: publicProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const repo = requireReviewCommentRepository(ctx); const comment = await repo.resolve(input.id); if (!comment) { throw new TRPCError({ code: 'NOT_FOUND', message: `Review comment '${input.id}' not found` }); } return comment; }), unresolveReviewComment: publicProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const repo = requireReviewCommentRepository(ctx); const comment = await repo.unresolve(input.id); if (!comment) { throw new TRPCError({ code: 'NOT_FOUND', message: `Review comment '${input.id}' not found` }); } return comment; }), requestPhaseChanges: publicProcedure .input(z.object({ phaseId: z.string().min(1), summary: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const orchestrator = requireExecutionOrchestrator(ctx); const reviewCommentRepo = requireReviewCommentRepository(ctx); const allComments = await reviewCommentRepo.findByPhaseId(input.phaseId); const unresolved = allComments .filter((c: { resolved: boolean }) => !c.resolved) .map((c: { filePath: string; lineNumber: number; body: string }) => ({ filePath: c.filePath, lineNumber: c.lineNumber, body: c.body, })); if (unresolved.length === 0 && !input.summary) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Add comments or a summary before requesting changes', }); } const result = await orchestrator.requestChangesOnPhase( input.phaseId, unresolved, input.summary, ); return { success: true, taskId: result.taskId }; }), }; }