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:
@@ -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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user