feat: Add initiative review gate before push

When all phases complete, the initiative now transitions to
pending_review status instead of silently stopping. The user
reviews the full initiative diff and chooses:
- Push Branch: push cw/<name> to remote for PR workflows
- Merge & Push: merge into default branch and push

Changes:
- Schema: Add pending_review to initiative status enum
- BranchManager: Add pushBranch port + SimpleGit adapter
- Events: initiative:pending_review, initiative:review_approved
- Orchestrator: checkInitiativeCompletion + approveInitiative
- tRPC: getInitiativeReviewDiff, getInitiativeReviewCommits,
  getInitiativeCommitDiff, approveInitiativeReview
- Frontend: InitiativeReview component in ReviewTab
- Subscriptions: Add initiative events + missing preview/conversation
  event types and subscription procedures
This commit is contained in:
Lukas May
2026-03-05 17:02:17 +01:00
parent dab1a2ab13
commit 865e8bffa0
14 changed files with 519 additions and 13 deletions

View File

@@ -5,10 +5,11 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import { requireAgentManager, requireInitiativeRepository, requireProjectRepository, requireTaskRepository } from './_helpers.js';
import { requireAgentManager, requireInitiativeRepository, requireProjectRepository, requireTaskRepository, requireBranchManager, requireExecutionOrchestrator } from './_helpers.js';
import { deriveInitiativeActivity } from './initiative-activity.js';
import { buildRefinePrompt } from '../../agent/prompts/index.js';
import type { PageForSerialization } from '../../agent/content-serializer.js';
import { ensureProjectClone } from '../../git/project-clones.js';
export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
return {
@@ -110,7 +111,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
listInitiatives: publicProcedure
.input(z.object({
status: z.enum(['active', 'completed', 'archived']).optional(),
status: z.enum(['active', 'completed', 'archived', 'pending_review']).optional(),
projectId: z.string().min(1).optional(),
}).optional())
.query(async ({ ctx, input }) => {
@@ -184,7 +185,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
.input(z.object({
id: z.string().min(1),
name: z.string().min(1).optional(),
status: z.enum(['active', 'completed', 'archived']).optional(),
status: z.enum(['active', 'completed', 'archived', 'pending_review']).optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireInitiativeRepository(ctx);
@@ -233,5 +234,107 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
return repo.update(initiativeId, data);
}),
getInitiativeReviewDiff: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const initiativeRepo = requireInitiativeRepository(ctx);
const projectRepo = requireProjectRepository(ctx);
const branchManager = requireBranchManager(ctx);
const initiative = await initiativeRepo.findById(input.initiativeId);
if (!initiative) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` });
}
if (initiative.status !== 'pending_review') {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Initiative is not pending review (status: ${initiative.status})` });
}
if (!initiative.branch) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' });
}
const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId);
let rawDiff = '';
for (const project of projects) {
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
const diff = await branchManager.diffBranches(clonePath, project.defaultBranch, initiative.branch);
if (diff) rawDiff += diff + '\n';
}
return {
initiativeName: initiative.name,
sourceBranch: initiative.branch,
targetBranch: projects[0]?.defaultBranch ?? 'main',
rawDiff,
};
}),
getInitiativeReviewCommits: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const initiativeRepo = requireInitiativeRepository(ctx);
const projectRepo = requireProjectRepository(ctx);
const branchManager = requireBranchManager(ctx);
const initiative = await initiativeRepo.findById(input.initiativeId);
if (!initiative) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` });
}
if (initiative.status !== 'pending_review') {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Initiative is not pending review (status: ${initiative.status})` });
}
if (!initiative.branch) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' });
}
const projects = await projectRepo.findProjectsByInitiativeId(input.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, project.defaultBranch, initiative.branch);
allCommits.push(...commits);
}
return {
commits: allCommits,
sourceBranch: initiative.branch,
targetBranch: projects[0]?.defaultBranch ?? 'main',
};
}),
getInitiativeCommitDiff: publicProcedure
.input(z.object({ initiativeId: z.string().min(1), commitHash: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const projectRepo = requireProjectRepository(ctx);
const branchManager = requireBranchManager(ctx);
const projects = await projectRepo.findProjectsByInitiativeId(input.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 };
}),
approveInitiativeReview: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
strategy: z.enum(['push_branch', 'merge_and_push']),
}))
.mutation(async ({ ctx, input }) => {
const orchestrator = requireExecutionOrchestrator(ctx);
await orchestrator.approveInitiative(input.initiativeId, input.strategy);
return { success: true };
}),
};
}