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:
@@ -48,7 +48,7 @@ export interface InitiativeRepository {
|
||||
* Find all initiatives with a specific status.
|
||||
* Returns empty array if none exist.
|
||||
*/
|
||||
findByStatus(status: 'active' | 'completed' | 'archived'): Promise<Initiative[]>;
|
||||
findByStatus(status: 'active' | 'completed' | 'archived' | 'pending_review'): Promise<Initiative[]>;
|
||||
|
||||
/**
|
||||
* Update an initiative.
|
||||
|
||||
@@ -19,7 +19,7 @@ import { relations, type InferInsertModel, type InferSelectModel } from 'drizzle
|
||||
export const initiatives = sqliteTable('initiatives', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
status: text('status', { enum: ['active', 'completed', 'archived'] })
|
||||
status: text('status', { enum: ['active', 'completed', 'archived', 'pending_review'] })
|
||||
.notNull()
|
||||
.default('active'),
|
||||
mergeRequiresApproval: integer('merge_requires_approval', { mode: 'boolean' })
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add 'pending_review' to initiative status enum.
|
||||
-- SQLite text columns don't enforce enum values at DDL level,
|
||||
-- so no schema change is needed. This migration exists for documentation
|
||||
-- and to keep the migration sequence in sync with schema.ts changes.
|
||||
-- The Drizzle ORM schema definition now includes 'pending_review' in the
|
||||
-- initiatives.status enum: ['active', 'completed', 'archived', 'pending_review']
|
||||
@@ -51,6 +51,8 @@ export type {
|
||||
AccountCredentialsRefreshedEvent,
|
||||
AccountCredentialsExpiredEvent,
|
||||
AccountCredentialsValidatedEvent,
|
||||
InitiativePendingReviewEvent,
|
||||
InitiativeReviewApprovedEvent,
|
||||
DomainEventMap,
|
||||
DomainEventType,
|
||||
} from './types.js';
|
||||
|
||||
@@ -580,6 +580,27 @@ export interface ProjectSyncFailedEvent extends DomainEvent {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiative Review Events
|
||||
*/
|
||||
|
||||
export interface InitiativePendingReviewEvent extends DomainEvent {
|
||||
type: 'initiative:pending_review';
|
||||
payload: {
|
||||
initiativeId: string;
|
||||
branch: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InitiativeReviewApprovedEvent extends DomainEvent {
|
||||
type: 'initiative:review_approved';
|
||||
payload: {
|
||||
initiativeId: string;
|
||||
branch: string;
|
||||
strategy: 'push_branch' | 'merge_and_push';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat Session Events
|
||||
*/
|
||||
@@ -656,7 +677,9 @@ export type DomainEventMap =
|
||||
| ProjectSyncedEvent
|
||||
| ProjectSyncFailedEvent
|
||||
| ChatMessageCreatedEvent
|
||||
| ChatSessionClosedEvent;
|
||||
| ChatSessionClosedEvent
|
||||
| InitiativePendingReviewEvent
|
||||
| InitiativeReviewApprovedEvent;
|
||||
|
||||
/**
|
||||
* Event type literal union for type checking
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* - Review per-phase: pause after each phase for diff review
|
||||
*/
|
||||
|
||||
import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent } from '../events/index.js';
|
||||
import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent, InitiativePendingReviewEvent, InitiativeReviewApprovedEvent } from '../events/index.js';
|
||||
import type { BranchManager } from '../git/branch-manager.js';
|
||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
@@ -233,7 +233,12 @@ export class ExecutionOrchestrator {
|
||||
if (initiative.executionMode === 'yolo') {
|
||||
await this.mergePhaseIntoInitiative(phaseId);
|
||||
await this.phaseDispatchManager.completePhase(phaseId);
|
||||
await this.phaseDispatchManager.dispatchNextPhase();
|
||||
|
||||
// Check if this was the last phase — if so, trigger initiative review
|
||||
const dispatched = await this.phaseDispatchManager.dispatchNextPhase();
|
||||
if (!dispatched.success) {
|
||||
await this.checkInitiativeCompletion(phase.initiativeId);
|
||||
}
|
||||
this.scheduleDispatch();
|
||||
} else {
|
||||
// review_per_phase
|
||||
@@ -299,7 +304,12 @@ export class ExecutionOrchestrator {
|
||||
|
||||
await this.mergePhaseIntoInitiative(phaseId);
|
||||
await this.phaseDispatchManager.completePhase(phaseId);
|
||||
await this.phaseDispatchManager.dispatchNextPhase();
|
||||
|
||||
// Check if this was the last phase — if so, trigger initiative review
|
||||
const dispatched = await this.phaseDispatchManager.dispatchNextPhase();
|
||||
if (!dispatched.success) {
|
||||
await this.checkInitiativeCompletion(phase.initiativeId);
|
||||
}
|
||||
this.scheduleDispatch();
|
||||
|
||||
log.info({ phaseId }, 'phase review approved and merged');
|
||||
@@ -384,4 +394,83 @@ export class ExecutionOrchestrator {
|
||||
|
||||
return { taskId: task.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all phases for an initiative are completed.
|
||||
* If so, set initiative to pending_review and emit event.
|
||||
*/
|
||||
private async checkInitiativeCompletion(initiativeId: string): Promise<void> {
|
||||
const phases = await this.phaseRepository.findByInitiativeId(initiativeId);
|
||||
if (phases.length === 0) return;
|
||||
|
||||
const allCompleted = phases.every((p) => p.status === 'completed');
|
||||
if (!allCompleted) return;
|
||||
|
||||
const initiative = await this.initiativeRepository.findById(initiativeId);
|
||||
if (!initiative?.branch) return;
|
||||
if (initiative.status !== 'active') return;
|
||||
|
||||
await this.initiativeRepository.update(initiativeId, { status: 'pending_review' as any });
|
||||
|
||||
const event: InitiativePendingReviewEvent = {
|
||||
type: 'initiative:pending_review',
|
||||
timestamp: new Date(),
|
||||
payload: { initiativeId, branch: initiative.branch },
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
|
||||
log.info({ initiativeId, branch: initiative.branch }, 'initiative pending review — all phases completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve an initiative review.
|
||||
* Either pushes the initiative branch or merges into default + pushes.
|
||||
*/
|
||||
async approveInitiative(
|
||||
initiativeId: string,
|
||||
strategy: 'push_branch' | 'merge_and_push',
|
||||
): Promise<void> {
|
||||
const initiative = await this.initiativeRepository.findById(initiativeId);
|
||||
if (!initiative) throw new Error(`Initiative not found: ${initiativeId}`);
|
||||
if (initiative.status !== 'pending_review') {
|
||||
throw new Error(`Initiative ${initiativeId} is not pending review (status: ${initiative.status})`);
|
||||
}
|
||||
if (!initiative.branch) {
|
||||
throw new Error(`Initiative ${initiativeId} has no branch configured`);
|
||||
}
|
||||
|
||||
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
|
||||
|
||||
for (const project of projects) {
|
||||
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
|
||||
const branchExists = await this.branchManager.branchExists(clonePath, initiative.branch);
|
||||
if (!branchExists) {
|
||||
log.warn({ initiativeId, branch: initiative.branch, project: project.name }, 'initiative branch does not exist in project, skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strategy === 'merge_and_push') {
|
||||
const result = await this.branchManager.mergeBranch(clonePath, initiative.branch, project.defaultBranch);
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to merge ${initiative.branch} into ${project.defaultBranch} for project ${project.name}: ${result.message}`);
|
||||
}
|
||||
await this.branchManager.pushBranch(clonePath, project.defaultBranch);
|
||||
log.info({ initiativeId, project: project.name }, 'initiative branch merged into default and pushed');
|
||||
} else {
|
||||
await this.branchManager.pushBranch(clonePath, initiative.branch);
|
||||
log.info({ initiativeId, project: project.name }, 'initiative branch pushed to remote');
|
||||
}
|
||||
}
|
||||
|
||||
await this.initiativeRepository.update(initiativeId, { status: 'completed' as any });
|
||||
|
||||
const event: InitiativeReviewApprovedEvent = {
|
||||
type: 'initiative:review_approved',
|
||||
timestamp: new Date(),
|
||||
payload: { initiativeId, branch: initiative.branch, strategy },
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
|
||||
log.info({ initiativeId, strategy }, 'initiative review approved');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,4 +56,10 @@ export interface BranchManager {
|
||||
* Get the raw unified diff for a single commit.
|
||||
*/
|
||||
diffCommit(repoPath: string, commitHash: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Push a branch to a remote.
|
||||
* Defaults to 'origin' if no remote specified.
|
||||
*/
|
||||
pushBranch(repoPath: string, branch: string, remote?: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -140,4 +140,10 @@ export class SimpleGitBranchManager implements BranchManager {
|
||||
const git = simpleGit(repoPath);
|
||||
return git.diff([`${commitHash}~1`, commitHash]);
|
||||
}
|
||||
|
||||
async pushBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
|
||||
const git = simpleGit(repoPath);
|
||||
await git.push(remote, branch);
|
||||
log.info({ repoPath, branch, remote }, 'branch pushed to remote');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
208
apps/web/src/components/review/InitiativeReview.tsx
Normal file
208
apps/web/src/components/review/InitiativeReview.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { parseUnifiedDiff } from "./parse-diff";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import { ReviewSidebar } from "./ReviewSidebar";
|
||||
|
||||
interface InitiativeReviewProps {
|
||||
initiativeId: string;
|
||||
onCompleted: () => void;
|
||||
}
|
||||
|
||||
export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReviewProps) {
|
||||
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
||||
const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set());
|
||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
const toggleViewed = useCallback((filePath: string) => {
|
||||
setViewedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(filePath)) next.delete(filePath);
|
||||
else next.add(filePath);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const registerFileRef = useCallback((filePath: string, el: HTMLDivElement | null) => {
|
||||
if (el) fileRefs.current.set(filePath, el);
|
||||
else fileRefs.current.delete(filePath);
|
||||
}, []);
|
||||
|
||||
// Fetch initiative diff
|
||||
const diffQuery = trpc.getInitiativeReviewDiff.useQuery(
|
||||
{ initiativeId },
|
||||
);
|
||||
|
||||
// Fetch initiative commits
|
||||
const commitsQuery = trpc.getInitiativeReviewCommits.useQuery(
|
||||
{ initiativeId },
|
||||
);
|
||||
const commits = commitsQuery.data?.commits ?? [];
|
||||
|
||||
// Fetch single-commit diff
|
||||
const commitDiffQuery = trpc.getInitiativeCommitDiff.useQuery(
|
||||
{ initiativeId, commitHash: selectedCommit! },
|
||||
{ enabled: !!selectedCommit },
|
||||
);
|
||||
|
||||
const approveMutation = trpc.approveInitiativeReview.useMutation({
|
||||
onSuccess: (_data, variables) => {
|
||||
const msg = variables.strategy === "merge_and_push"
|
||||
? "Initiative merged into default branch and pushed"
|
||||
: "Initiative branch pushed to remote";
|
||||
toast.success(msg);
|
||||
onCompleted();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const activeDiffRaw = selectedCommit
|
||||
? commitDiffQuery.data?.rawDiff
|
||||
: diffQuery.data?.rawDiff;
|
||||
|
||||
const files = useMemo(() => {
|
||||
if (!activeDiffRaw) return [];
|
||||
return parseUnifiedDiff(activeDiffRaw);
|
||||
}, [activeDiffRaw]);
|
||||
|
||||
const isDiffLoading = selectedCommit
|
||||
? commitDiffQuery.isLoading
|
||||
: diffQuery.isLoading;
|
||||
|
||||
const allFiles = useMemo(() => {
|
||||
if (!diffQuery.data?.rawDiff) return [];
|
||||
return parseUnifiedDiff(diffQuery.data.rawDiff);
|
||||
}, [diffQuery.data?.rawDiff]);
|
||||
|
||||
const handleFileClick = useCallback((filePath: string) => {
|
||||
const el = fileRefs.current.get(filePath);
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, []);
|
||||
|
||||
const totalAdditions = allFiles.reduce((s, f) => s + f.additions, 0);
|
||||
const totalDeletions = allFiles.reduce((s, f) => s + f.deletions, 0);
|
||||
const sourceBranch = diffQuery.data?.sourceBranch ?? "";
|
||||
const targetBranch = diffQuery.data?.targetBranch ?? "";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border overflow-hidden bg-card">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border bg-card/80 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3 px-4 py-2.5">
|
||||
{/* Left: branch info + stats */}
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold truncate shrink-0">
|
||||
Initiative Review
|
||||
</h2>
|
||||
|
||||
{sourceBranch && (
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground font-mono min-w-0">
|
||||
<GitBranch className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate" title={sourceBranch}>
|
||||
{sourceBranch}
|
||||
</span>
|
||||
<ArrowRight className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
|
||||
<span className="truncate" title={targetBranch}>
|
||||
{targetBranch}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2.5 text-[11px] shrink-0">
|
||||
<span className="flex items-center gap-0.5 text-muted-foreground">
|
||||
<FileCode className="h-3 w-3" />
|
||||
{allFiles.length}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-diff-add-fg">
|
||||
<Plus className="h-3 w-3" />
|
||||
{totalAdditions}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-diff-remove-fg">
|
||||
<Minus className="h-3 w-3" />
|
||||
{totalDeletions}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: action buttons */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => approveMutation.mutate({ initiativeId, strategy: "push_branch" })}
|
||||
disabled={approveMutation.isPending}
|
||||
className="h-8 text-xs px-3"
|
||||
>
|
||||
{approveMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3 w-3" />
|
||||
)}
|
||||
Push Branch
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => approveMutation.mutate({ initiativeId, strategy: "merge_and_push" })}
|
||||
disabled={approveMutation.isPending}
|
||||
className="h-9 px-5 text-sm font-semibold shadow-sm"
|
||||
>
|
||||
{approveMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<GitMerge className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Merge & Push to Default
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr]">
|
||||
<div className="border-r border-border">
|
||||
<div className="sticky top-0 h-[calc(100vh-12rem)]">
|
||||
<ReviewSidebar
|
||||
files={allFiles}
|
||||
comments={[]}
|
||||
onFileClick={handleFileClick}
|
||||
selectedCommit={selectedCommit}
|
||||
activeFiles={files}
|
||||
commits={commits}
|
||||
onSelectCommit={setSelectedCommit}
|
||||
viewedFiles={viewedFiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 p-4">
|
||||
{isDiffLoading ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading diff...
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-muted-foreground text-sm">
|
||||
{selectedCommit
|
||||
? "No changes in this commit"
|
||||
: "No changes in this initiative"}
|
||||
</div>
|
||||
) : (
|
||||
<DiffViewer
|
||||
files={files}
|
||||
comments={[]}
|
||||
onAddComment={() => {}}
|
||||
onResolveComment={() => {}}
|
||||
onUnresolveComment={() => {}}
|
||||
viewedFiles={viewedFiles}
|
||||
onToggleViewed={toggleViewed}
|
||||
onRegisterRef={registerFileRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { parseUnifiedDiff } from "./parse-diff";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import { ReviewSidebar } from "./ReviewSidebar";
|
||||
import { ReviewHeader } from "./ReviewHeader";
|
||||
import { InitiativeReview } from "./InitiativeReview";
|
||||
import type { ReviewStatus, DiffLine } from "./types";
|
||||
|
||||
interface ReviewTabProps {
|
||||
@@ -38,6 +39,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch initiative to check for initiative-level pending_review
|
||||
const initiativeQuery = trpc.getInitiative.useQuery({ id: initiativeId });
|
||||
const isInitiativePendingReview = initiativeQuery.data?.status === "pending_review";
|
||||
|
||||
// Fetch phases for this initiative
|
||||
const phasesQuery = trpc.listPhases.useQuery({ initiativeId });
|
||||
const pendingReviewPhases = useMemo(
|
||||
@@ -56,20 +61,20 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
// Fetch full branch diff for active phase
|
||||
const diffQuery = trpc.getPhaseReviewDiff.useQuery(
|
||||
{ phaseId: activePhaseId! },
|
||||
{ enabled: !!activePhaseId },
|
||||
{ enabled: !!activePhaseId && !isInitiativePendingReview },
|
||||
);
|
||||
|
||||
// Fetch commits for active phase
|
||||
const commitsQuery = trpc.getPhaseReviewCommits.useQuery(
|
||||
{ phaseId: activePhaseId! },
|
||||
{ enabled: !!activePhaseId },
|
||||
{ enabled: !!activePhaseId && !isInitiativePendingReview },
|
||||
);
|
||||
const commits = commitsQuery.data?.commits ?? [];
|
||||
|
||||
// Fetch single-commit diff when a commit is selected
|
||||
const commitDiffQuery = trpc.getCommitDiff.useQuery(
|
||||
{ phaseId: activePhaseId!, commitHash: selectedCommit! },
|
||||
{ enabled: !!activePhaseId && !!selectedCommit },
|
||||
{ enabled: !!activePhaseId && !!selectedCommit && !isInitiativePendingReview },
|
||||
);
|
||||
|
||||
// Preview state
|
||||
@@ -140,7 +145,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const commentsQuery = trpc.listReviewComments.useQuery(
|
||||
{ phaseId: activePhaseId! },
|
||||
{ enabled: !!activePhaseId },
|
||||
{ enabled: !!activePhaseId && !isInitiativePendingReview },
|
||||
);
|
||||
const comments = useMemo(() => {
|
||||
return (commentsQuery.data ?? []).map((c) => ({
|
||||
@@ -257,6 +262,19 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
|
||||
const unresolvedCount = comments.filter((c) => !c.resolved).length;
|
||||
|
||||
// Initiative-level review takes priority
|
||||
if (isInitiativePendingReview) {
|
||||
return (
|
||||
<InitiativeReview
|
||||
initiativeId={initiativeId}
|
||||
onCompleted={() => {
|
||||
initiativeQuery.refetch();
|
||||
phasesQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (pendingReviewPhases.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user