feat: Wire full request-changes flow for phase review
- Add PhaseChangesRequestedEvent to event bus - Add requestChangesOnPhase() to ExecutionOrchestrator: reads unresolved comments, creates revision task (category='review'), resets phase to in_progress, queues task for dispatch - Expand merge-skip and branch routing to include 'review' category so revision tasks work directly on the phase branch - Add requestPhaseChanges tRPC procedure (reads comments from DB) - Wire frontend: mutation replaces stub handler, window.prompt for optional summary, loading state on button
This commit is contained in:
@@ -359,8 +359,8 @@ export class DefaultDispatchManager implements DispatchManager {
|
||||
|
||||
const phase = await this.phaseRepository.findById(task.phaseId);
|
||||
if (phase) {
|
||||
if (task.category === 'merge') {
|
||||
// Merge tasks work directly on the phase branch
|
||||
if (task.category === 'merge' || task.category === 'review') {
|
||||
// Merge/review tasks work directly on the phase branch
|
||||
baseBranch = initBranch;
|
||||
branchName = phaseBranchName(initBranch, phase.name);
|
||||
} else {
|
||||
|
||||
@@ -38,6 +38,7 @@ export type {
|
||||
PhaseCompletedEvent,
|
||||
PhaseBlockedEvent,
|
||||
PhasePendingReviewEvent,
|
||||
PhaseChangesRequestedEvent,
|
||||
PhaseMergedEvent,
|
||||
TaskMergedEvent,
|
||||
MergeQueuedEvent,
|
||||
|
||||
@@ -329,6 +329,16 @@ export interface PhasePendingReviewEvent extends DomainEvent {
|
||||
};
|
||||
}
|
||||
|
||||
export interface PhaseChangesRequestedEvent extends DomainEvent {
|
||||
type: 'phase:changes_requested';
|
||||
payload: {
|
||||
phaseId: string;
|
||||
initiativeId: string;
|
||||
taskId: string;
|
||||
commentCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PhaseMergedEvent extends DomainEvent {
|
||||
type: 'phase:merged';
|
||||
payload: {
|
||||
@@ -595,6 +605,7 @@ export type DomainEventMap =
|
||||
| PhaseCompletedEvent
|
||||
| PhaseBlockedEvent
|
||||
| PhasePendingReviewEvent
|
||||
| PhaseChangesRequestedEvent
|
||||
| PhaseMergedEvent
|
||||
| TaskMergedEvent
|
||||
| MergeQueuedEvent
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* - Review per-phase: pause after each phase for diff review
|
||||
*/
|
||||
|
||||
import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent } from '../events/index.js';
|
||||
import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent } 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';
|
||||
@@ -145,8 +145,8 @@ export class ExecutionOrchestrator {
|
||||
const phase = await this.phaseRepository.findById(task.phaseId);
|
||||
if (!phase) return;
|
||||
|
||||
// Skip merge tasks — they already work on the phase branch directly
|
||||
if (task.category === 'merge') return;
|
||||
// Skip merge/review tasks — they already work on the phase branch directly
|
||||
if (task.category === 'merge' || task.category === 'review') return;
|
||||
|
||||
const initBranch = initiative.branch;
|
||||
const phBranch = phaseBranchName(initBranch, phase.name);
|
||||
@@ -304,4 +304,84 @@ export class ExecutionOrchestrator {
|
||||
|
||||
log.info({ phaseId }, 'phase review approved and merged');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request changes on a phase that's pending review.
|
||||
* Creates a revision task, resets the phase to in_progress, and dispatches.
|
||||
*/
|
||||
async requestChangesOnPhase(
|
||||
phaseId: string,
|
||||
unresolvedComments: Array<{ filePath: string; lineNumber: number; body: string }>,
|
||||
summary?: string,
|
||||
): Promise<{ taskId: string }> {
|
||||
const phase = await this.phaseRepository.findById(phaseId);
|
||||
if (!phase) throw new Error(`Phase not found: ${phaseId}`);
|
||||
if (phase.status !== 'pending_review') {
|
||||
throw new Error(`Phase ${phaseId} is not pending review (status: ${phase.status})`);
|
||||
}
|
||||
|
||||
const initiative = await this.initiativeRepository.findById(phase.initiativeId);
|
||||
if (!initiative) throw new Error(`Initiative not found: ${phase.initiativeId}`);
|
||||
|
||||
// Build revision task description from comments + summary
|
||||
const lines: string[] = [];
|
||||
if (summary) {
|
||||
lines.push(`## Summary\n\n${summary}\n`);
|
||||
}
|
||||
if (unresolvedComments.length > 0) {
|
||||
lines.push('## Review Comments\n');
|
||||
// Group comments by file
|
||||
const byFile = new Map<string, typeof unresolvedComments>();
|
||||
for (const c of unresolvedComments) {
|
||||
const arr = byFile.get(c.filePath) ?? [];
|
||||
arr.push(c);
|
||||
byFile.set(c.filePath, arr);
|
||||
}
|
||||
for (const [filePath, fileComments] of byFile) {
|
||||
lines.push(`### ${filePath}\n`);
|
||||
for (const c of fileComments) {
|
||||
lines.push(`- **Line ${c.lineNumber}**: ${c.body}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
const description = lines.join('\n') || 'Address review feedback.';
|
||||
|
||||
// Create revision task
|
||||
const task = await this.taskRepository.create({
|
||||
phaseId,
|
||||
initiativeId: phase.initiativeId,
|
||||
name: `Address review feedback: ${phase.name}`,
|
||||
description,
|
||||
category: 'review',
|
||||
priority: 'high',
|
||||
});
|
||||
|
||||
// Reset phase status back to in_progress
|
||||
await this.phaseRepository.update(phaseId, { status: 'in_progress' as any });
|
||||
|
||||
// Queue task for dispatch
|
||||
await this.dispatchManager.queue(task.id);
|
||||
|
||||
// Emit event
|
||||
const event: PhaseChangesRequestedEvent = {
|
||||
type: 'phase:changes_requested',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
phaseId,
|
||||
initiativeId: phase.initiativeId,
|
||||
taskId: task.id,
|
||||
commentCount: unresolvedComments.length,
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
|
||||
log.info({ phaseId, taskId: task.id, commentCount: unresolvedComments.length }, 'changes requested on phase');
|
||||
|
||||
// Kick off dispatch
|
||||
this.scheduleDispatch();
|
||||
|
||||
return { taskId: task.id };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,5 +341,38 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
|
||||
}
|
||||
return comment;
|
||||
}),
|
||||
|
||||
requestPhaseChanges: publicProcedure
|
||||
.input(z.object({
|
||||
phaseId: z.string().min(1),
|
||||
summary: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const orchestrator = requireExecutionOrchestrator(ctx);
|
||||
const reviewCommentRepo = requireReviewCommentRepository(ctx);
|
||||
|
||||
const allComments = await reviewCommentRepo.findByPhaseId(input.phaseId);
|
||||
const unresolved = allComments
|
||||
.filter((c: { resolved: boolean }) => !c.resolved)
|
||||
.map((c: { filePath: string; lineNumber: number; body: string }) => ({
|
||||
filePath: c.filePath,
|
||||
lineNumber: c.lineNumber,
|
||||
body: c.body,
|
||||
}));
|
||||
|
||||
if (unresolved.length === 0 && !input.summary) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Add comments or a summary before requesting changes',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await orchestrator.requestChangesOnPhase(
|
||||
input.phaseId,
|
||||
unresolved,
|
||||
input.summary,
|
||||
);
|
||||
return { success: true, taskId: result.taskId };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ interface ReviewHeaderProps {
|
||||
unresolvedCount: number;
|
||||
onApprove: () => void;
|
||||
onRequestChanges: () => void;
|
||||
isRequestingChanges?: boolean;
|
||||
preview: PreviewState | null;
|
||||
viewedCount?: number;
|
||||
totalCount?: number;
|
||||
@@ -63,6 +64,7 @@ export function ReviewHeader({
|
||||
unresolvedCount,
|
||||
onApprove,
|
||||
onRequestChanges,
|
||||
isRequestingChanges,
|
||||
preview,
|
||||
viewedCount,
|
||||
totalCount,
|
||||
@@ -186,9 +188,14 @@ export function ReviewHeader({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRequestChanges}
|
||||
disabled={isRequestingChanges}
|
||||
className="h-8 text-xs px-3 border-status-error-border/50 text-status-error-fg hover:bg-status-error-bg/50 hover:border-status-error-border"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
{isRequestingChanges ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<X className="h-3 w-3" />
|
||||
)}
|
||||
Request Changes
|
||||
</Button>
|
||||
<div className="relative" ref={confirmRef}>
|
||||
|
||||
@@ -225,12 +225,21 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
approveMutation.mutate({ phaseId: activePhaseId });
|
||||
}, [activePhaseId, approveMutation]);
|
||||
|
||||
const requestChangesMutation = trpc.requestPhaseChanges.useMutation({
|
||||
onSuccess: () => {
|
||||
setStatus("changes_requested");
|
||||
toast.success("Changes requested — revision task dispatched");
|
||||
phasesQuery.refetch();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const handleRequestChanges = useCallback(() => {
|
||||
setStatus("changes_requested");
|
||||
toast("Changes requested", {
|
||||
description: "The agent will be notified about the requested changes.",
|
||||
});
|
||||
}, []);
|
||||
if (!activePhaseId) return;
|
||||
const summary = window.prompt("Optional: describe what needs to change (leave blank for comments only)");
|
||||
if (summary === null) return; // cancelled
|
||||
requestChangesMutation.mutate({ phaseId: activePhaseId, summary: summary || undefined });
|
||||
}, [activePhaseId, requestChangesMutation]);
|
||||
|
||||
const handleFileClick = useCallback((filePath: string) => {
|
||||
const el = fileRefs.current.get(filePath);
|
||||
@@ -282,6 +291,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
unresolvedCount={unresolvedCount}
|
||||
onApprove={handleApprove}
|
||||
onRequestChanges={handleRequestChanges}
|
||||
isRequestingChanges={requestChangesMutation.isPending}
|
||||
preview={previewState}
|
||||
viewedCount={viewedFiles.size}
|
||||
totalCount={allFiles.length}
|
||||
|
||||
Reference in New Issue
Block a user