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);
|
const phase = await this.phaseRepository.findById(task.phaseId);
|
||||||
if (phase) {
|
if (phase) {
|
||||||
if (task.category === 'merge') {
|
if (task.category === 'merge' || task.category === 'review') {
|
||||||
// Merge tasks work directly on the phase branch
|
// Merge/review tasks work directly on the phase branch
|
||||||
baseBranch = initBranch;
|
baseBranch = initBranch;
|
||||||
branchName = phaseBranchName(initBranch, phase.name);
|
branchName = phaseBranchName(initBranch, phase.name);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type {
|
|||||||
PhaseCompletedEvent,
|
PhaseCompletedEvent,
|
||||||
PhaseBlockedEvent,
|
PhaseBlockedEvent,
|
||||||
PhasePendingReviewEvent,
|
PhasePendingReviewEvent,
|
||||||
|
PhaseChangesRequestedEvent,
|
||||||
PhaseMergedEvent,
|
PhaseMergedEvent,
|
||||||
TaskMergedEvent,
|
TaskMergedEvent,
|
||||||
MergeQueuedEvent,
|
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 {
|
export interface PhaseMergedEvent extends DomainEvent {
|
||||||
type: 'phase:merged';
|
type: 'phase:merged';
|
||||||
payload: {
|
payload: {
|
||||||
@@ -595,6 +605,7 @@ export type DomainEventMap =
|
|||||||
| PhaseCompletedEvent
|
| PhaseCompletedEvent
|
||||||
| PhaseBlockedEvent
|
| PhaseBlockedEvent
|
||||||
| PhasePendingReviewEvent
|
| PhasePendingReviewEvent
|
||||||
|
| PhaseChangesRequestedEvent
|
||||||
| PhaseMergedEvent
|
| PhaseMergedEvent
|
||||||
| TaskMergedEvent
|
| TaskMergedEvent
|
||||||
| MergeQueuedEvent
|
| MergeQueuedEvent
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* - Review per-phase: pause after each phase for diff review
|
* - 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 { BranchManager } from '../git/branch-manager.js';
|
||||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||||
import type { TaskRepository } from '../db/repositories/task-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);
|
const phase = await this.phaseRepository.findById(task.phaseId);
|
||||||
if (!phase) return;
|
if (!phase) return;
|
||||||
|
|
||||||
// Skip merge tasks — they already work on the phase branch directly
|
// Skip merge/review tasks — they already work on the phase branch directly
|
||||||
if (task.category === 'merge') return;
|
if (task.category === 'merge' || task.category === 'review') return;
|
||||||
|
|
||||||
const initBranch = initiative.branch;
|
const initBranch = initiative.branch;
|
||||||
const phBranch = phaseBranchName(initBranch, phase.name);
|
const phBranch = phaseBranchName(initBranch, phase.name);
|
||||||
@@ -304,4 +304,84 @@ export class ExecutionOrchestrator {
|
|||||||
|
|
||||||
log.info({ phaseId }, 'phase review approved and merged');
|
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;
|
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;
|
unresolvedCount: number;
|
||||||
onApprove: () => void;
|
onApprove: () => void;
|
||||||
onRequestChanges: () => void;
|
onRequestChanges: () => void;
|
||||||
|
isRequestingChanges?: boolean;
|
||||||
preview: PreviewState | null;
|
preview: PreviewState | null;
|
||||||
viewedCount?: number;
|
viewedCount?: number;
|
||||||
totalCount?: number;
|
totalCount?: number;
|
||||||
@@ -63,6 +64,7 @@ export function ReviewHeader({
|
|||||||
unresolvedCount,
|
unresolvedCount,
|
||||||
onApprove,
|
onApprove,
|
||||||
onRequestChanges,
|
onRequestChanges,
|
||||||
|
isRequestingChanges,
|
||||||
preview,
|
preview,
|
||||||
viewedCount,
|
viewedCount,
|
||||||
totalCount,
|
totalCount,
|
||||||
@@ -186,9 +188,14 @@ export function ReviewHeader({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onRequestChanges}
|
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"
|
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
|
Request Changes
|
||||||
</Button>
|
</Button>
|
||||||
<div className="relative" ref={confirmRef}>
|
<div className="relative" ref={confirmRef}>
|
||||||
|
|||||||
@@ -225,12 +225,21 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
approveMutation.mutate({ phaseId: activePhaseId });
|
approveMutation.mutate({ phaseId: activePhaseId });
|
||||||
}, [activePhaseId, approveMutation]);
|
}, [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(() => {
|
const handleRequestChanges = useCallback(() => {
|
||||||
setStatus("changes_requested");
|
if (!activePhaseId) return;
|
||||||
toast("Changes requested", {
|
const summary = window.prompt("Optional: describe what needs to change (leave blank for comments only)");
|
||||||
description: "The agent will be notified about the requested changes.",
|
if (summary === null) return; // cancelled
|
||||||
});
|
requestChangesMutation.mutate({ phaseId: activePhaseId, summary: summary || undefined });
|
||||||
}, []);
|
}, [activePhaseId, requestChangesMutation]);
|
||||||
|
|
||||||
const handleFileClick = useCallback((filePath: string) => {
|
const handleFileClick = useCallback((filePath: string) => {
|
||||||
const el = fileRefs.current.get(filePath);
|
const el = fileRefs.current.get(filePath);
|
||||||
@@ -282,6 +291,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
unresolvedCount={unresolvedCount}
|
unresolvedCount={unresolvedCount}
|
||||||
onApprove={handleApprove}
|
onApprove={handleApprove}
|
||||||
onRequestChanges={handleRequestChanges}
|
onRequestChanges={handleRequestChanges}
|
||||||
|
isRequestingChanges={requestChangesMutation.isPending}
|
||||||
preview={previewState}
|
preview={previewState}
|
||||||
viewedCount={viewedFiles.size}
|
viewedCount={viewedFiles.size}
|
||||||
totalCount={allFiles.length}
|
totalCount={allFiles.length}
|
||||||
|
|||||||
Reference in New Issue
Block a user