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:
@@ -30,6 +30,9 @@ export function deriveInitiativeActivity(
|
||||
if (initiative.status === 'archived') {
|
||||
return { ...base, state: 'archived' };
|
||||
}
|
||||
if (initiative.status === 'pending_review') {
|
||||
return { ...base, state: 'pending_review' };
|
||||
}
|
||||
if (initiative.status === 'completed') {
|
||||
return { ...base, state: 'complete' };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
AGENT_EVENT_TYPES,
|
||||
TASK_EVENT_TYPES,
|
||||
PAGE_EVENT_TYPES,
|
||||
PREVIEW_EVENT_TYPES,
|
||||
CONVERSATION_EVENT_TYPES,
|
||||
} from '../subscriptions.js';
|
||||
|
||||
export function subscriptionProcedures(publicProcedure: ProcedureBuilder) {
|
||||
@@ -41,5 +43,19 @@ export function subscriptionProcedures(publicProcedure: ProcedureBuilder) {
|
||||
const signal = opts.signal ?? new AbortController().signal;
|
||||
yield* eventBusIterable(opts.ctx.eventBus, PAGE_EVENT_TYPES, signal);
|
||||
}),
|
||||
|
||||
onPreviewUpdate: publicProcedure
|
||||
.input(z.object({ lastEventId: z.string().nullish() }).optional())
|
||||
.subscription(async function* (opts) {
|
||||
const signal = opts.signal ?? new AbortController().signal;
|
||||
yield* eventBusIterable(opts.ctx.eventBus, PREVIEW_EVENT_TYPES, signal);
|
||||
}),
|
||||
|
||||
onConversationUpdate: publicProcedure
|
||||
.input(z.object({ lastEventId: z.string().nullish() }).optional())
|
||||
.subscription(async function* (opts) {
|
||||
const signal = opts.signal ?? new AbortController().signal;
|
||||
yield* eventBusIterable(opts.ctx.eventBus, CONVERSATION_EVENT_TYPES, signal);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,10 +61,16 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [
|
||||
'page:deleted',
|
||||
'changeset:created',
|
||||
'changeset:reverted',
|
||||
'preview:building',
|
||||
'preview:ready',
|
||||
'preview:stopped',
|
||||
'preview:failed',
|
||||
'conversation:created',
|
||||
'conversation:answered',
|
||||
'chat:message_created',
|
||||
'chat:session_closed',
|
||||
'initiative:pending_review',
|
||||
'initiative:review_approved',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -96,6 +102,8 @@ export const TASK_EVENT_TYPES: DomainEventType[] = [
|
||||
'phase:blocked',
|
||||
'phase:pending_review',
|
||||
'phase:merged',
|
||||
'initiative:pending_review',
|
||||
'initiative:review_approved',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -107,6 +115,24 @@ export const PAGE_EVENT_TYPES: DomainEventType[] = [
|
||||
'page:deleted',
|
||||
];
|
||||
|
||||
/**
|
||||
* Preview deployment event types.
|
||||
*/
|
||||
export const PREVIEW_EVENT_TYPES: DomainEventType[] = [
|
||||
'preview:building',
|
||||
'preview:ready',
|
||||
'preview:stopped',
|
||||
'preview:failed',
|
||||
];
|
||||
|
||||
/**
|
||||
* Inter-agent conversation event types.
|
||||
*/
|
||||
export const CONVERSATION_EVENT_TYPES: DomainEventType[] = [
|
||||
'conversation:created',
|
||||
'conversation:answered',
|
||||
];
|
||||
|
||||
/** Counter for generating unique event IDs */
|
||||
let eventCounter = 0;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user