/** * Phase Router — create, list, get, update, dependencies, bulk create */ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { simpleGit } from 'simple-git'; import type { Phase } from '../../db/schema.js'; import type { ProcedureBuilder } from '../trpc.js'; import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator, requireReviewCommentRepository, requireChangeSetRepository } from './_helpers.js'; import { phaseBranchName } from '../../git/branch-naming.js'; import { ensureProjectClone } from '../../git/project-clones.js'; import type { FileStatEntry } from '../../git/types.js'; import { phaseMetaCache, fileDiffCache } from '../../review/diff-cache.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); // Reconcile any applied changesets that created this phase. // If all created phases in a changeset are now deleted, mark it reverted. if (ctx.changeSetRepository) { try { const csRepo = requireChangeSetRepository(ctx); const affectedChangeSets = await csRepo.findAppliedByCreatedEntity('phase', input.id); for (const cs of affectedChangeSets) { const createdPhaseIds = cs.entries .filter(e => e.entityType === 'phase' && e.action === 'create') .map(e => e.entityId); const survivingPhases = await Promise.all( createdPhaseIds.map(id => repo.findById(id)), ); if (survivingPhases.every(p => p === null)) { await csRepo.markReverted(cs.id); } } } catch { // Best-effort reconciliation — don't fail the delete } } 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' && phase.status !== 'completed') { throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not reviewable (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 diffBase = (phase.status === 'completed' && phase.mergeBase) ? phase.mergeBase : initBranch; const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId); if (projects.length === 0) { return { phaseName: phase.name, sourceBranch: phBranch, targetBranch: initBranch, files: [], totalAdditions: 0, totalDeletions: 0, }; } const firstClone = await ensureProjectClone(projects[0], ctx.workspaceRoot!); const headHash = await branchManager.getHeadCommitHash(firstClone, phBranch); const cacheKey = `${input.phaseId}:${headHash}`; type PhaseReviewDiffResult = { phaseName: string; sourceBranch: string; targetBranch: string; files: FileStatEntry[]; totalAdditions: number; totalDeletions: number }; const cached = phaseMetaCache.get(cacheKey) as PhaseReviewDiffResult | undefined; if (cached) return cached; const files: FileStatEntry[] = []; for (const project of projects) { const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); const entries = await branchManager.diffBranchesStat(clonePath, diffBase, phBranch); for (const entry of entries) { const tagged: FileStatEntry = { ...entry, projectId: project.id }; if (projects.length > 1) { tagged.path = `${project.name}/${entry.path}`; if (entry.oldPath) { tagged.oldPath = `${project.name}/${entry.oldPath}`; } } files.push(tagged); } } const totalAdditions = files.reduce((sum, f) => sum + f.additions, 0); const totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0); const result = { phaseName: phase.name, sourceBranch: phBranch, targetBranch: initBranch, files, totalAdditions, totalDeletions, }; phaseMetaCache.set(cacheKey, result); return result; }), getFileDiff: publicProcedure .input(z.object({ phaseId: z.string().min(1), filePath: z.string().min(1), projectId: z.string().optional(), })) .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' && phase.status !== 'completed') { throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not reviewable (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 diffBase = (phase.status === 'completed' && phase.mergeBase) ? phase.mergeBase : initBranch; const decodedPath = decodeURIComponent(input.filePath); const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId); const firstClone = await ensureProjectClone(projects[0], ctx.workspaceRoot!); const headHash = await branchManager.getHeadCommitHash(firstClone, phBranch); const cacheKey = `${input.phaseId}:${headHash}:${input.filePath}`; const cached = fileDiffCache.get(cacheKey); if (cached) return cached; let clonePath: string; if (input.projectId) { const project = projects.find((p) => p.id === input.projectId); if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: `Project '${input.projectId}' not found for this phase` }); } clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); } else { clonePath = firstClone; } const git = simpleGit(clonePath); // Binary files appear as "-\t-\t" in --numstat output const numstatOut = await git.raw(['diff', '--numstat', `${diffBase}...${phBranch}`, '--', decodedPath]); if (numstatOut.trim() && numstatOut.startsWith('-\t-\t')) { const binaryResult = { binary: true, rawDiff: '' }; fileDiffCache.set(cacheKey, binaryResult); return binaryResult; } const rawDiff = await branchManager.diffFileSingle(clonePath, diffBase, phBranch, decodedPath); const result = { binary: false, rawDiff }; fileDiffCache.set(cacheKey, result); return result; }), 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' && phase.status !== 'completed') { throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not reviewable (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 diffBase = (phase.status === 'completed' && phase.mergeBase) ? phase.mergeBase : initBranch; 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, diffBase, 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); }), updateReviewComment: publicProcedure .input(z.object({ id: z.string().min(1), body: z.string().trim().min(1), })) .mutation(async ({ ctx, input }) => { const repo = requireReviewCommentRepository(ctx); const comment = await repo.update(input.id, input.body); if (!comment) { throw new TRPCError({ code: 'NOT_FOUND', message: `Review comment '${input.id}' not found` }); } return comment; }), 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; }), replyToReviewComment: publicProcedure .input(z.object({ parentCommentId: z.string().min(1), body: z.string().trim().min(1), author: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const repo = requireReviewCommentRepository(ctx); return repo.createReply(input.parentCommentId, input.body, input.author); }), requestPhaseChanges: publicProcedure .input(z.object({ phaseId: z.string().min(1), summary: z.string().trim().min(1).optional(), })) .mutation(async ({ ctx, input }) => { const orchestrator = requireExecutionOrchestrator(ctx); const reviewCommentRepo = requireReviewCommentRepository(ctx); const allComments = await reviewCommentRepo.findByPhaseId(input.phaseId); // Build threaded structure: unresolved root comments with their replies const rootComments = allComments.filter((c) => !c.parentCommentId); const repliesByParent = new Map(); for (const c of allComments) { if (c.parentCommentId) { const arr = repliesByParent.get(c.parentCommentId) ?? []; arr.push(c); repliesByParent.set(c.parentCommentId, arr); } } const unresolvedThreads = rootComments .filter((c) => !c.resolved) .map((c) => ({ id: c.id, filePath: c.filePath, lineNumber: c.lineNumber, body: c.body, author: c.author, replies: (repliesByParent.get(c.id) ?? []).map((r) => ({ id: r.id, body: r.body, author: r.author, })), })); if (unresolvedThreads.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, unresolvedThreads, input.summary, ); return { success: true, taskId: result.taskId }; }), }; }