From 865e8bffa0f658ab7f668e433de9422c46936e70 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 17:02:17 +0100 Subject: [PATCH] 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/ 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 --- .../db/repositories/initiative-repository.ts | 2 +- apps/server/db/schema.ts | 2 +- .../0030_add_initiative_pending_review.sql | 6 + apps/server/events/index.ts | 2 + apps/server/events/types.ts | 25 ++- apps/server/execution/orchestrator.ts | 95 +++++++- apps/server/git/branch-manager.ts | 6 + apps/server/git/simple-git-branch-manager.ts | 6 + .../trpc/routers/initiative-activity.ts | 3 + apps/server/trpc/routers/initiative.ts | 109 ++++++++- apps/server/trpc/routers/subscription.ts | 16 ++ apps/server/trpc/subscriptions.ts | 26 +++ .../components/review/InitiativeReview.tsx | 208 ++++++++++++++++++ apps/web/src/components/review/ReviewTab.tsx | 26 ++- 14 files changed, 519 insertions(+), 13 deletions(-) create mode 100644 apps/server/drizzle/0030_add_initiative_pending_review.sql create mode 100644 apps/web/src/components/review/InitiativeReview.tsx diff --git a/apps/server/db/repositories/initiative-repository.ts b/apps/server/db/repositories/initiative-repository.ts index dfca891..0a35598 100644 --- a/apps/server/db/repositories/initiative-repository.ts +++ b/apps/server/db/repositories/initiative-repository.ts @@ -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; + findByStatus(status: 'active' | 'completed' | 'archived' | 'pending_review'): Promise; /** * Update an initiative. diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index def2870..1b98e44 100644 --- a/apps/server/db/schema.ts +++ b/apps/server/db/schema.ts @@ -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' }) diff --git a/apps/server/drizzle/0030_add_initiative_pending_review.sql b/apps/server/drizzle/0030_add_initiative_pending_review.sql new file mode 100644 index 0000000..5a31259 --- /dev/null +++ b/apps/server/drizzle/0030_add_initiative_pending_review.sql @@ -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'] diff --git a/apps/server/events/index.ts b/apps/server/events/index.ts index a8a691a..2cd7ce9 100644 --- a/apps/server/events/index.ts +++ b/apps/server/events/index.ts @@ -51,6 +51,8 @@ export type { AccountCredentialsRefreshedEvent, AccountCredentialsExpiredEvent, AccountCredentialsValidatedEvent, + InitiativePendingReviewEvent, + InitiativeReviewApprovedEvent, DomainEventMap, DomainEventType, } from './types.js'; diff --git a/apps/server/events/types.ts b/apps/server/events/types.ts index 52d68a5..6d0a7b9 100644 --- a/apps/server/events/types.ts +++ b/apps/server/events/types.ts @@ -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 diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index 9079f7e..dfa0483 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -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 { + 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 { + 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'); + } } diff --git a/apps/server/git/branch-manager.ts b/apps/server/git/branch-manager.ts index 477e593..f5d9b54 100644 --- a/apps/server/git/branch-manager.ts +++ b/apps/server/git/branch-manager.ts @@ -56,4 +56,10 @@ export interface BranchManager { * Get the raw unified diff for a single commit. */ diffCommit(repoPath: string, commitHash: string): Promise; + + /** + * Push a branch to a remote. + * Defaults to 'origin' if no remote specified. + */ + pushBranch(repoPath: string, branch: string, remote?: string): Promise; } diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index ce39b54..bee747a 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -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 { + const git = simpleGit(repoPath); + await git.push(remote, branch); + log.info({ repoPath, branch, remote }, 'branch pushed to remote'); + } } diff --git a/apps/server/trpc/routers/initiative-activity.ts b/apps/server/trpc/routers/initiative-activity.ts index 7a55a74..fc16b35 100644 --- a/apps/server/trpc/routers/initiative-activity.ts +++ b/apps/server/trpc/routers/initiative-activity.ts @@ -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' }; } diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index b9d5e1b..3161822 100644 --- a/apps/server/trpc/routers/initiative.ts +++ b/apps/server/trpc/routers/initiative.ts @@ -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 }; + }), }; } diff --git a/apps/server/trpc/routers/subscription.ts b/apps/server/trpc/routers/subscription.ts index ce95953..949a011 100644 --- a/apps/server/trpc/routers/subscription.ts +++ b/apps/server/trpc/routers/subscription.ts @@ -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); + }), }; } diff --git a/apps/server/trpc/subscriptions.ts b/apps/server/trpc/subscriptions.ts index b056800..a5f9795 100644 --- a/apps/server/trpc/subscriptions.ts +++ b/apps/server/trpc/subscriptions.ts @@ -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; diff --git a/apps/web/src/components/review/InitiativeReview.tsx b/apps/web/src/components/review/InitiativeReview.tsx new file mode 100644 index 0000000..5bb0b66 --- /dev/null +++ b/apps/web/src/components/review/InitiativeReview.tsx @@ -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(null); + const [viewedFiles, setViewedFiles] = useState>(new Set()); + const fileRefs = useRef>(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 ( +
+ {/* Header */} +
+
+ {/* Left: branch info + stats */} +
+

+ Initiative Review +

+ + {sourceBranch && ( +
+ + + {sourceBranch} + + + + {targetBranch} + +
+ )} + +
+ + + {allFiles.length} + + + + {totalAdditions} + + + + {totalDeletions} + +
+
+ + {/* Right: action buttons */} +
+ + +
+
+
+ + {/* Main content */} +
+
+
+ +
+
+ +
+ {isDiffLoading ? ( +
+ + Loading diff... +
+ ) : files.length === 0 ? ( +
+ {selectedCommit + ? "No changes in this commit" + : "No changes in this initiative"} +
+ ) : ( + {}} + onResolveComment={() => {}} + onUnresolveComment={() => {}} + viewedFiles={viewedFiles} + onToggleViewed={toggleViewed} + onRegisterRef={registerFileRef} + /> + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index d2c7bd7..9945224 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -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 ( + { + initiativeQuery.refetch(); + phasesQuery.refetch(); + }} + /> + ); + } + if (pendingReviewPhases.length === 0) { return (