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

@@ -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.

View File

@@ -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' })

View File

@@ -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']

View File

@@ -51,6 +51,8 @@ export type {
AccountCredentialsRefreshedEvent,
AccountCredentialsExpiredEvent,
AccountCredentialsValidatedEvent,
InitiativePendingReviewEvent,
InitiativeReviewApprovedEvent,
DomainEventMap,
DomainEventType,
} from './types.js';

View File

@@ -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

View File

@@ -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');
}
}

View File

@@ -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>;
}

View File

@@ -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');
}
}

View File

@@ -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' };
}

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 };
}),
};
}

View File

@@ -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);
}),
};
}

View File

@@ -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;

View 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>
);
}

View File

@@ -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">