feat(FN-3276): add task review tab with metadata persistence and desktop la
This merge lands three major features and a significant dashboard enhancement. FN-3276 adds a full Review tab to the task detail modal with multi-step lifecycle: review metadata persistence in the task store, new task workflow routes for refresh and same-task revision, and the review tab UI surface Fusion-Task-Id: FN-3276
This commit is contained in:
@@ -4088,6 +4088,76 @@ describe("TaskStore", () => {
|
||||
expect(restored.sourceIssue).toEqual(sourceIssue);
|
||||
});
|
||||
|
||||
it("persists review metadata on create, update, and reload", async () => {
|
||||
const review: NonNullable<Task["review"]> = {
|
||||
mode: "direct",
|
||||
source: "reviewer-agent",
|
||||
decision: "changes-requested",
|
||||
summary: "Address reviewer findings",
|
||||
latestRefreshAt: new Date().toISOString(),
|
||||
selectedItemIds: ["rvw-1"],
|
||||
items: [
|
||||
{
|
||||
id: "rvw-1",
|
||||
source: "reviewer-agent",
|
||||
status: "queued",
|
||||
summary: "Fix failing assertion",
|
||||
body: "Assertion in task detail modal test is stale.",
|
||||
reviewer: "reviewer",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const created = await store.createTask({ description: "Task with review metadata" });
|
||||
const updated = await store.updateTask(created.id, { review });
|
||||
expect(updated.review).toEqual(review);
|
||||
|
||||
const reloaded = await store.getTask(created.id);
|
||||
expect(reloaded.review).toEqual(review);
|
||||
|
||||
const cleared = await store.updateTask(created.id, { review: null });
|
||||
expect(cleared.review).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves review metadata through archive and unarchive", async () => {
|
||||
const review: NonNullable<Task["review"]> = {
|
||||
mode: "pull-request",
|
||||
source: "github-pr",
|
||||
decision: "pending",
|
||||
summary: "PR review feedback",
|
||||
latestRefreshAt: new Date().toISOString(),
|
||||
selectedItemIds: ["gh-1"],
|
||||
items: [
|
||||
{
|
||||
id: "gh-1",
|
||||
source: "github-pr",
|
||||
status: "in-progress",
|
||||
summary: "Address thread in src/file.ts",
|
||||
filePath: "src/file.ts",
|
||||
line: 42,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
const task = await store.createTask({ description: "Archive review persistence" });
|
||||
await store.updateTask(task.id, { review });
|
||||
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id, false);
|
||||
|
||||
const archived = await store.getTask(task.id);
|
||||
expect(archived.review).toEqual(review);
|
||||
|
||||
const restored = await store.unarchiveTask(task.id);
|
||||
expect(restored.review).toEqual(review);
|
||||
});
|
||||
|
||||
it("sets and clears mission linkage fields via updateTask", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
attachments TEXT DEFAULT '[]',
|
||||
steeringComments TEXT DEFAULT '[]',
|
||||
comments TEXT DEFAULT '[]',
|
||||
review TEXT,
|
||||
workflowStepResults TEXT DEFAULT '[]',
|
||||
prInfo TEXT,
|
||||
issueInfo TEXT,
|
||||
@@ -1189,6 +1190,7 @@ export class Database {
|
||||
|
||||
if (this.hasTable("tasks")) {
|
||||
this.addColumnIfMissing("tasks", "executionStartBranch", "TEXT");
|
||||
this.addColumnIfMissing("tasks", "review", "TEXT");
|
||||
}
|
||||
|
||||
if (version >= SCHEMA_VERSION) return;
|
||||
|
||||
@@ -90,6 +90,7 @@ interface TaskRow {
|
||||
attachments: string | null;
|
||||
steeringComments: string | null;
|
||||
comments: string | null;
|
||||
review: string | null;
|
||||
workflowStepResults: string | null;
|
||||
prInfo: string | null;
|
||||
issueInfo: string | null;
|
||||
@@ -751,6 +752,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
});
|
||||
return deduped.length > 0 ? deduped : undefined;
|
||||
})(),
|
||||
review: fromJson<import("./types.js").TaskReview>(row.review) ?? undefined,
|
||||
workflowStepResults: (() => { const w = fromJson<import("./types.js").WorkflowStepResult[]>(row.workflowStepResults); return w && w.length > 0 ? w : undefined; })(),
|
||||
prInfo: fromJson<import("./types.js").PrInfo>(row.prInfo),
|
||||
issueInfo: fromJson<import("./types.js").IssueInfo>(row.issueInfo),
|
||||
@@ -813,6 +815,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
sourceIssue: slim ? undefined : entry.sourceIssue,
|
||||
attachments: slim ? undefined : entry.attachments,
|
||||
comments: entry.comments,
|
||||
review: slim ? undefined : entry.review,
|
||||
log: slim ? [] : entry.log ?? [],
|
||||
timedExecutionMs: slim ? this.computeTimedExecutionMs(entry.log) : undefined,
|
||||
createdAt: entry.createdAt,
|
||||
@@ -936,6 +939,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
sourceIssue: task.sourceIssue,
|
||||
attachments: task.attachments,
|
||||
comments: task.comments,
|
||||
review: task.review,
|
||||
prompt,
|
||||
...agentLogFields,
|
||||
log: [{ timestamp: archivedAt, action: "Task archived" }],
|
||||
@@ -1014,7 +1018,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
"error", "summary", "thinkingLevel", "executionMode",
|
||||
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
|
||||
"createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt",
|
||||
"dependencies", "steps", "comments", "workflowStepResults", "steeringComments",
|
||||
"dependencies", "steps", "comments", "review", "workflowStepResults", "steeringComments",
|
||||
"attachments", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
||||
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
|
||||
"missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
||||
@@ -1064,7 +1068,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
|
||||
"createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt",
|
||||
"dependencies", "steps", "attachments", "steeringComments",
|
||||
"comments", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
||||
"comments", "review", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
||||
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
|
||||
"missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
||||
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
||||
@@ -1107,11 +1111,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, createdAt, updatedAt, columnMovedAt,
|
||||
executionStartedAt, executionCompletedAt,
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
comments, workflowStepResults, prInfo, issueInfo,
|
||||
comments, review, workflowStepResults, prInfo, issueInfo,
|
||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
||||
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
@@ -1166,6 +1170,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
attachments = excluded.attachments,
|
||||
steeringComments = excluded.steeringComments,
|
||||
comments = excluded.comments,
|
||||
review = excluded.review,
|
||||
workflowStepResults = excluded.workflowStepResults,
|
||||
prInfo = excluded.prInfo,
|
||||
issueInfo = excluded.issueInfo,
|
||||
@@ -1249,6 +1254,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
toJson(task.attachments || []),
|
||||
toJson(task.steeringComments || []),
|
||||
toJson(task.comments || []),
|
||||
toJsonNullable(task.review),
|
||||
toJson(task.workflowStepResults || []),
|
||||
toJsonNullable(task.prInfo),
|
||||
toJsonNullable(task.issueInfo),
|
||||
@@ -3170,7 +3176,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
async updateTask(
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
|
||||
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
|
||||
runContext?: RunMutationContext,
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
@@ -3440,6 +3446,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
} else if (updates.executionCompletedAt !== undefined) {
|
||||
task.executionCompletedAt = updates.executionCompletedAt;
|
||||
}
|
||||
if (updates.review === null) {
|
||||
task.review = undefined;
|
||||
} else if (updates.review !== undefined) {
|
||||
task.review = updates.review;
|
||||
}
|
||||
if (updates.workflowStepResults === null) {
|
||||
task.workflowStepResults = undefined;
|
||||
} else if (updates.workflowStepResults !== undefined) {
|
||||
@@ -6088,6 +6099,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
size: entry.size,
|
||||
reviewLevel: entry.reviewLevel,
|
||||
prInfo: entry.prInfo,
|
||||
review: entry.review,
|
||||
issueInfo: entry.issueInfo,
|
||||
sourceIssue: entry.sourceIssue,
|
||||
attachments: entry.attachments,
|
||||
|
||||
@@ -699,6 +699,37 @@ export interface TaskCommentInput {
|
||||
author: string;
|
||||
}
|
||||
|
||||
export type TaskReviewMode = "pull-request" | "direct";
|
||||
export type TaskReviewSource = "github-pr" | "reviewer-agent";
|
||||
export type TaskReviewDecision = "approved" | "changes-requested" | "commented" | "pending";
|
||||
export type TaskReviewItemStatus = "queued" | "in-progress" | "addressed" | "failed";
|
||||
|
||||
export interface TaskReviewItem {
|
||||
id: string;
|
||||
source: TaskReviewSource;
|
||||
status: TaskReviewItemStatus;
|
||||
summary: string;
|
||||
body?: string;
|
||||
filePath?: string;
|
||||
line?: number;
|
||||
commentUrl?: string;
|
||||
reviewer?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
addressedAt?: string;
|
||||
failedReason?: string;
|
||||
}
|
||||
|
||||
export interface TaskReview {
|
||||
mode: TaskReviewMode;
|
||||
source: TaskReviewSource;
|
||||
decision: TaskReviewDecision;
|
||||
summary?: string;
|
||||
latestRefreshAt?: string;
|
||||
selectedItemIds?: string[];
|
||||
items: TaskReviewItem[];
|
||||
}
|
||||
|
||||
export interface TaskDocument {
|
||||
/** UUID primary key */
|
||||
id: string;
|
||||
@@ -918,6 +949,8 @@ export interface Task {
|
||||
attachments?: TaskAttachment[];
|
||||
steeringComments?: SteeringComment[];
|
||||
comments?: TaskComment[];
|
||||
/** Structured review metadata shown in the Review tab. */
|
||||
review?: TaskReview;
|
||||
/** PR information for tasks linked to GitHub pull requests */
|
||||
prInfo?: PrInfo;
|
||||
mergeDetails?: MergeDetails;
|
||||
@@ -2339,6 +2372,8 @@ export interface ArchivedTaskEntry {
|
||||
attachments?: TaskAttachment[];
|
||||
/** User and agent comments remain searchable in the archive DB. */
|
||||
comments?: TaskComment[];
|
||||
/** Structured review metadata shown in the Review tab. */
|
||||
review?: TaskReview;
|
||||
/** Reconstructed prompt content at archive time, without attachment blobs. */
|
||||
prompt?: string;
|
||||
/** Agent log retention mode used when this archive entry was written. */
|
||||
|
||||
@@ -250,6 +250,20 @@ export async function fetchTaskDetail(id: string, projectId?: string): Promise<T
|
||||
throw new Error("Request failed");
|
||||
}
|
||||
|
||||
export interface UpdateTaskReviewRequest {
|
||||
review: TaskDetail["review"] | null;
|
||||
}
|
||||
|
||||
export interface RefreshTaskReviewResponse {
|
||||
review: NonNullable<TaskDetail["review"]>;
|
||||
automationStatus: string | null;
|
||||
}
|
||||
|
||||
export interface ReviseTaskReviewResponse {
|
||||
task: Task;
|
||||
review: NonNullable<TaskDetail["review"]>;
|
||||
}
|
||||
|
||||
export interface CreateTaskRequestOptions {
|
||||
transportNodeId?: string;
|
||||
localNodeId?: string;
|
||||
@@ -5084,6 +5098,21 @@ export function acceptTaskReview(taskId: string, projectId?: string): Promise<Ta
|
||||
});
|
||||
}
|
||||
|
||||
/** Refresh normalized task review data (PR mode or direct mode) */
|
||||
export function refreshTaskReview(taskId: string, projectId?: string): Promise<RefreshTaskReviewResponse> {
|
||||
return api<RefreshTaskReviewResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/refresh`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Request an in-place revision pass for selected review items */
|
||||
export function reviseTaskReviewItems(taskId: string, itemIds: string[], projectId?: string): Promise<ReviseTaskReviewResponse> {
|
||||
return api<ReviseTaskReviewResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/revise`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ itemIds }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Return task to agent - clear assignee and status, move to todo */
|
||||
export function returnTaskToAgent(taskId: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/return-to-agent`, projectId), {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { AgentLogViewer } from "./AgentLogViewer";
|
||||
import { ModelSelectorTab } from "./ModelSelectorTab";
|
||||
import { PrSection } from "./PrSection";
|
||||
import { TaskComments } from "./TaskComments";
|
||||
import { TaskReviewTab } from "./TaskReviewTab";
|
||||
import { MergeDetails } from "./MergeDetails";
|
||||
import { TaskChangesTab } from "./TaskChangesTab";
|
||||
import { TaskForm, type PendingImage } from "./TaskForm";
|
||||
@@ -227,7 +228,7 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
type TabId = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | `plugin-${string}`;
|
||||
type TabId = "definition" | "logs" | "changes" | "review" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | `plugin-${string}`;
|
||||
|
||||
export interface TaskDetailModalProps {
|
||||
task: Task | TaskDetail;
|
||||
@@ -1987,6 +1988,12 @@ export function TaskDetailContent({
|
||||
Changes
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`detail-tab${activeTab === "review" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("review")}
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "comments" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("comments")}
|
||||
@@ -2113,6 +2120,8 @@ export function TaskDetailContent({
|
||||
</div>
|
||||
) : activeTab === "changes" ? (
|
||||
<TaskChangesTab taskId={task.id} worktree={task.worktree} projectId={projectId} column={task.column} mergeDetails={task.mergeDetails} modifiedFiles={task.modifiedFiles} />
|
||||
) : activeTab === "review" ? (
|
||||
<TaskReviewTab task={task} addToast={addToast} projectId={projectId} onTaskUpdated={onTaskUpdated} />
|
||||
) : activeTab === "comments" ? (
|
||||
<TaskComments task={task} addToast={addToast} projectId={projectId} onTaskUpdated={onTaskUpdated} />
|
||||
) : activeTab === "documents" ? (
|
||||
|
||||
106
packages/dashboard/app/components/TaskReviewTab.css
Normal file
106
packages/dashboard/app/components/TaskReviewTab.css
Normal file
@@ -0,0 +1,106 @@
|
||||
.task-review-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.task-review-tab__header {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-review-tab__summary-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-review-tab__summary {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-review-tab__decision {
|
||||
padding: 0 var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.75rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.task-review-tab__decision--approved {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.task-review-tab__decision--changes-requested {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.task-review-tab__decision--commented,
|
||||
.task-review-tab__decision--pending {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.task-review-tab__actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-review-tab__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.task-review-tab__item {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.task-review-tab__row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: var(--space-sm);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.task-review-tab__item-summary {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.task-review-tab__status {
|
||||
color: var(--text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.task-review-tab__status--failed {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.task-review-tab__status--queued,
|
||||
.task-review-tab__status--in-progress {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.task-review-tab__status--addressed {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.task-review-tab__empty {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.task-review-tab__row {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.task-review-tab__status {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
85
packages/dashboard/app/components/TaskReviewTab.tsx
Normal file
85
packages/dashboard/app/components/TaskReviewTab.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import "./TaskReviewTab.css";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import { useMemo, useState } from "react";
|
||||
import { refreshTaskReview, reviseTaskReviewItems } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface Props {
|
||||
task: Task | TaskDetail;
|
||||
projectId?: string;
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Props) {
|
||||
const [selected, setSelected] = useState<string[]>(task.review?.selectedItemIds ?? []);
|
||||
const review = task.review;
|
||||
const canRevise = selected.length > 0;
|
||||
|
||||
const summaryText = useMemo(() => {
|
||||
if (!review) return "No review feedback captured yet.";
|
||||
return review.summary ?? `${review.items.length} review item(s)`;
|
||||
}, [review]);
|
||||
|
||||
const decisionLabel = review?.decision ? review.decision.replace("-", " ") : undefined;
|
||||
|
||||
const toggleSelected = (id: string) => {
|
||||
setSelected((prev) => (prev.includes(id) ? prev.filter((value) => value !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const onRefresh = async () => {
|
||||
try {
|
||||
const result = await refreshTaskReview(task.id, projectId);
|
||||
onTaskUpdated?.({ ...task, review: result.review } as Task);
|
||||
addToast("Review refreshed", "success");
|
||||
} catch (error) {
|
||||
addToast(error instanceof Error ? error.message : "Failed to refresh review", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const onRevise = async () => {
|
||||
try {
|
||||
const result = await reviseTaskReviewItems(task.id, selected, projectId);
|
||||
onTaskUpdated?.({ ...result.task, review: result.review } as Task);
|
||||
addToast("Queued same-task revision", "success");
|
||||
} catch (error) {
|
||||
addToast(error instanceof Error ? error.message : "Failed to queue revision", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="task-review-tab">
|
||||
<div className="task-review-tab__header">
|
||||
<div className="task-review-tab__summary-wrap">
|
||||
<p className="task-review-tab__summary">{summaryText}</p>
|
||||
{decisionLabel ? (
|
||||
<span className={`task-review-tab__decision task-review-tab__decision--${review?.decision}`}>{decisionLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="task-review-tab__actions">
|
||||
<button className="btn btn-sm" onClick={onRefresh}>Refresh</button>
|
||||
<button className="btn btn-primary btn-sm" disabled={!canRevise} onClick={onRevise}>Request revision</button>
|
||||
</div>
|
||||
</div>
|
||||
{review?.items?.length ? (
|
||||
<ul className="task-review-tab__list">
|
||||
{review.items.map((item) => (
|
||||
<li key={item.id} className="task-review-tab__item card">
|
||||
<label className="task-review-tab__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(item.id)}
|
||||
onChange={() => toggleSelected(item.id)}
|
||||
/>
|
||||
<span className="task-review-tab__item-summary">{item.summary}</span>
|
||||
<span className={`task-review-tab__status task-review-tab__status--${item.status}`}>{item.status}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="task-review-tab__empty">No review items yet.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -737,25 +737,26 @@ describe("TaskDetailModal", () => {
|
||||
);
|
||||
|
||||
// For an in-progress task (no workflow steps, no merge commit), the
|
||||
// top-level tabs are: Definition, Logs, Changes, Comments, Documents,
|
||||
// Model, Workflow, Stats, Routing.
|
||||
const tabTexts = ["Definition", "Logs", "Changes", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing"];
|
||||
// top-level tabs are: Definition, Logs, Changes, Review, Comments,
|
||||
// Documents, Model, Workflow, Stats, Routing.
|
||||
const tabTexts = ["Definition", "Logs", "Changes", "Review", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing"];
|
||||
const tabs = screen.getAllByRole("button").filter((b) =>
|
||||
tabTexts.includes(b.textContent || "")
|
||||
);
|
||||
expect(tabs.length).toBe(9);
|
||||
expect(tabs.length).toBe(10);
|
||||
expect(tabs[0].textContent).toBe("Definition");
|
||||
expect(tabs[1].textContent).toBe("Logs");
|
||||
expect(tabs[2].textContent).toBe("Changes");
|
||||
expect(tabs[3].textContent).toBe("Comments");
|
||||
expect(tabs[4].textContent).toBe("Documents");
|
||||
expect(tabs[5].textContent).toBe("Model");
|
||||
expect(tabs[6].textContent).toBe("Workflow");
|
||||
expect(tabs[7].textContent).toBe("Stats");
|
||||
expect(tabs[8].textContent).toBe("Routing");
|
||||
expect(tabs[3].textContent).toBe("Review");
|
||||
expect(tabs[4].textContent).toBe("Comments");
|
||||
expect(tabs[5].textContent).toBe("Documents");
|
||||
expect(tabs[6].textContent).toBe("Model");
|
||||
expect(tabs[7].textContent).toBe("Workflow");
|
||||
expect(tabs[8].textContent).toBe("Stats");
|
||||
expect(tabs[9].textContent).toBe("Routing");
|
||||
|
||||
// Activity and Agent Log are NOT top-level tabs (they are subviews inside Logs)
|
||||
expect(container.querySelectorAll(".detail-tab").length).toBe(9);
|
||||
expect(container.querySelectorAll(".detail-tab").length).toBe(10);
|
||||
// Workflow tab should always appear even when no workflow steps are configured
|
||||
expect(screen.getByText("Workflow")).toBeInTheDocument();
|
||||
// Commits tab should NOT appear for non-done tasks
|
||||
|
||||
@@ -185,7 +185,7 @@ describe("TaskDetailModal", () => {
|
||||
// In-progress tasks show exactly 9 tabs:
|
||||
// Definition, Logs, Changes, Comments, Documents, Model, Workflow, Stats, Routing
|
||||
const tabs = container.querySelectorAll(".detail-tab");
|
||||
expect(tabs.length).toBe(9);
|
||||
expect(tabs.length).toBe(10);
|
||||
expect(tabs[0].textContent).toBe("Definition");
|
||||
expect(tabs[1].textContent).toBe("Logs");
|
||||
expect(tabs[2].textContent).toBe("Changes");
|
||||
@@ -214,7 +214,7 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
// In-progress task with workflow steps: 9 tabs (Workflow after Model, Stats then Routing)
|
||||
const tabs = container.querySelectorAll(".detail-tab");
|
||||
expect(tabs.length).toBe(9);
|
||||
expect(tabs.length).toBe(10);
|
||||
expect(tabs[0].textContent).toBe("Definition");
|
||||
expect(tabs[1].textContent).toBe("Logs");
|
||||
expect(tabs[2].textContent).toBe("Changes");
|
||||
@@ -244,7 +244,7 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
// Done task with commit SHA: Definition, Logs, Changes, Comments, Documents, Model, Workflow, Stats, Routing (9 tabs, no Commits)
|
||||
const tabs = container.querySelectorAll(".detail-tab");
|
||||
expect(tabs.length).toBe(9);
|
||||
expect(tabs.length).toBe(10);
|
||||
expect(tabs[0].textContent).toBe("Definition");
|
||||
expect(tabs[1].textContent).toBe("Logs");
|
||||
expect(tabs[2].textContent).toBe("Changes");
|
||||
@@ -277,7 +277,7 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
// Done task with workflow steps and commit SHA: 9 tabs (no Commits)
|
||||
const tabs = container.querySelectorAll(".detail-tab");
|
||||
expect(tabs.length).toBe(9);
|
||||
expect(tabs.length).toBe(10);
|
||||
expect(tabs[0].textContent).toBe("Definition");
|
||||
expect(tabs[1].textContent).toBe("Logs");
|
||||
expect(tabs[2].textContent).toBe("Changes");
|
||||
|
||||
@@ -290,7 +290,7 @@ describe("TaskDetailModal", () => {
|
||||
expect(screen.queryByText("PROMPT.md")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders Comments tab", () => {
|
||||
it("renders Review and Comments tabs", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
@@ -303,6 +303,7 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Review")).toBeTruthy();
|
||||
expect(screen.getByText("Comments")).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("TaskDetailModal", () => {
|
||||
expect(container.querySelector(".modal-actions .modal-actions-spacer")).toBeTruthy();
|
||||
expect(container.querySelector(".detail-body")).toBeTruthy();
|
||||
const tabs = container.querySelectorAll(".detail-tab");
|
||||
expect(tabs.length).toBe(9);
|
||||
expect(tabs.length).toBe(10);
|
||||
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true);
|
||||
expect(Array.from(tabs).slice(1).every((t) => !t.classList.contains("detail-tab-active"))).toBe(true);
|
||||
// Responsive CSS controls sizing — no inline padding/fontSize/borderBottom leaks
|
||||
|
||||
@@ -35,6 +35,8 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
pauseTask: vi.fn().mockResolvedValue({}),
|
||||
unpauseTask: vi.fn().mockResolvedValue({}),
|
||||
fetchWorkflowResults: vi.fn().mockResolvedValue([]),
|
||||
refreshTaskReview: vi.fn().mockResolvedValue({ review: undefined, automationStatus: null }),
|
||||
reviseTaskReviewItems: vi.fn().mockResolvedValue({ task: makeTask(), review: undefined }),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { TaskReviewTab } from "../TaskReviewTab";
|
||||
import { makeTask } from "./TaskDetailModal.test-helpers";
|
||||
|
||||
const refreshTaskReview = vi.fn();
|
||||
const reviseTaskReviewItems = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
refreshTaskReview,
|
||||
reviseTaskReviewItems,
|
||||
}));
|
||||
|
||||
describe("TaskReviewTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders empty state when review is missing", () => {
|
||||
render(<TaskReviewTab task={makeTask({ review: undefined })} addToast={vi.fn()} />);
|
||||
expect(screen.getByText("No review items yet.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Request revision" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("calls refresh endpoint", async () => {
|
||||
const task = makeTask({ review: { mode: "direct", source: "reviewer-agent", decision: "pending", items: [] } });
|
||||
refreshTaskReview.mockResolvedValue({ review: task.review, automationStatus: null });
|
||||
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(refreshTaskReview).toHaveBeenCalledWith(task.id, undefined);
|
||||
});
|
||||
|
||||
it("renders PR decision and status modifiers", () => {
|
||||
const task = makeTask({
|
||||
review: {
|
||||
mode: "pull-request",
|
||||
source: "github-pr",
|
||||
decision: "changes-requested",
|
||||
summary: "Needs updates",
|
||||
items: [
|
||||
{
|
||||
id: "ri-1",
|
||||
source: "github-pr",
|
||||
status: "failed",
|
||||
summary: "Fix null handling",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
expect(screen.getByText("changes requested")).toBeInTheDocument();
|
||||
expect(screen.getByText("failed").className).toContain("task-review-tab__status--failed");
|
||||
});
|
||||
|
||||
it("renders review items and queues revision for selected entries", async () => {
|
||||
const task = makeTask({
|
||||
review: {
|
||||
mode: "pull-request",
|
||||
source: "github-pr",
|
||||
decision: "changes-requested",
|
||||
summary: "Needs updates",
|
||||
items: [
|
||||
{
|
||||
id: "ri-1",
|
||||
source: "github-pr",
|
||||
status: "queued",
|
||||
summary: "Fix null handling",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
reviseTaskReviewItems.mockResolvedValue({ task, review: task.review });
|
||||
refreshTaskReview.mockResolvedValue({ review: task.review, automationStatus: null });
|
||||
|
||||
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Request revision" }));
|
||||
|
||||
expect(reviseTaskReviewItems).toHaveBeenCalledWith(task.id, ["ri-1"], undefined);
|
||||
});
|
||||
});
|
||||
@@ -2027,5 +2027,71 @@ describe("GET /tasks/:id/file-diffs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/review/refresh", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("refreshes PR-backed review payload", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open",
|
||||
title: "PR",
|
||||
headBranch: "fusion/fn-1",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
},
|
||||
});
|
||||
vi.spyOn(GitHubClient.prototype, "getPrReviewSnapshot").mockResolvedValue({
|
||||
decision: "CHANGES_REQUESTED",
|
||||
checks: [],
|
||||
summary: "needs work",
|
||||
items: [],
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((store.updateTask as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.objectContaining({ review: expect.objectContaining({ mode: "pull-request" }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes direct-mode review payload without PR", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
review: {
|
||||
mode: "direct",
|
||||
source: "reviewer-agent",
|
||||
decision: "pending",
|
||||
items: [],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((store.updateTask as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
|
||||
expect(res.body.review.mode).toBe("direct");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Git Management route tests ---
|
||||
// These are integration tests that run against the actual git repository
|
||||
|
||||
@@ -2137,3 +2137,59 @@ describe("POST /subtasks/*", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("POST /tasks/:id/review/revise", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("queues selected review items and moves task to todo", async () => {
|
||||
const taskWithReview = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-001",
|
||||
column: "in-review",
|
||||
steps: [],
|
||||
review: {
|
||||
mode: "direct",
|
||||
source: "reviewer-agent",
|
||||
decision: "changes-requested",
|
||||
items: [
|
||||
{
|
||||
id: "ri-1",
|
||||
source: "reviewer-agent",
|
||||
status: "failed",
|
||||
summary: "Fix tests",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(taskWithReview);
|
||||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...taskWithReview, column: "todo" });
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks/FN-001/review/revise",
|
||||
JSON.stringify({ itemIds: ["ri-1"] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.objectContaining({ review: expect.objectContaining({ selectedItemIds: ["ri-1"] }) }),
|
||||
);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,6 +73,27 @@ export interface PrCheckStatus {
|
||||
state: PrCheckState;
|
||||
}
|
||||
|
||||
export interface PrReviewItem {
|
||||
id: string;
|
||||
source: "github-pr";
|
||||
status: "queued" | "in-progress" | "addressed" | "failed";
|
||||
summary: string;
|
||||
body?: string;
|
||||
filePath?: string;
|
||||
line?: number;
|
||||
reviewer?: string;
|
||||
commentUrl?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PrReviewSnapshot {
|
||||
decision: ReviewDecision;
|
||||
checks: PrCheckStatus[];
|
||||
items: PrReviewItem[];
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface PrMergeStatus {
|
||||
prInfo: PrInfo;
|
||||
reviewDecision: ReviewDecision;
|
||||
@@ -109,6 +130,15 @@ export type BadgeBatchResponse = Record<
|
||||
>;
|
||||
|
||||
// gh CLI JSON output types
|
||||
interface GhReviewJson {
|
||||
id: string;
|
||||
state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "PENDING" | string;
|
||||
body?: string | null;
|
||||
submittedAt?: string | null;
|
||||
author?: { login?: string | null } | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
interface GhPrViewJson {
|
||||
id?: string;
|
||||
number: number;
|
||||
@@ -126,6 +156,7 @@ interface GhPrViewJson {
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
}>;
|
||||
reviews?: GhReviewJson[];
|
||||
}
|
||||
|
||||
interface GhPrListJson {
|
||||
@@ -521,6 +552,54 @@ export class GitHubClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getPrReviewSnapshot(owner: string | undefined, repo: string | undefined, number: number): Promise<PrReviewSnapshot> {
|
||||
const { owner: resolvedOwner, repo: resolvedRepo } = this.resolveRepo(owner, repo);
|
||||
const pr = await runGhJsonAsync<GhPrViewJson>([
|
||||
"pr",
|
||||
"view",
|
||||
String(number),
|
||||
"--repo",
|
||||
`${resolvedOwner}/${resolvedRepo}`,
|
||||
"--json",
|
||||
"reviewDecision,reviews,comments",
|
||||
]);
|
||||
|
||||
const checks = (await this.getPrMergeStatus(resolvedOwner, resolvedRepo, number)).checks;
|
||||
const commentItems: PrReviewItem[] = (pr.comments ?? []).map((comment) => ({
|
||||
id: `gh-comment-${comment.id}`,
|
||||
source: "github-pr",
|
||||
status: "queued",
|
||||
summary: comment.body.trim().slice(0, 160) || `Comment from @${comment.author?.login ?? "reviewer"}`,
|
||||
body: comment.body,
|
||||
reviewer: comment.author?.login ?? undefined,
|
||||
commentUrl: comment.url,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
}));
|
||||
|
||||
const reviewItems: PrReviewItem[] = (pr.reviews ?? []).map((review) => {
|
||||
const createdAt = review.submittedAt ?? new Date().toISOString();
|
||||
return {
|
||||
id: `gh-review-${review.id}`,
|
||||
source: "github-pr",
|
||||
status: "queued",
|
||||
summary: (review.body ?? "").trim().slice(0, 160) || `Review ${review.state} by @${review.author?.login ?? "reviewer"}`,
|
||||
body: review.body ?? undefined,
|
||||
reviewer: review.author?.login ?? undefined,
|
||||
commentUrl: review.url ?? undefined,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
decision: pr.reviewDecision ?? null,
|
||||
checks,
|
||||
items: [...reviewItems, ...commentItems],
|
||||
summary: `PR #${number} has ${reviewItems.length} review(s) and ${commentItems.length} comment(s).`,
|
||||
};
|
||||
}
|
||||
|
||||
async getPrMergeStatus(owner: string | undefined, repo: string | undefined, number: number): Promise<PrMergeStatus> {
|
||||
if (this.hasGhAuth()) {
|
||||
try {
|
||||
|
||||
@@ -3115,6 +3115,65 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tasks/:id/review/refresh
|
||||
* Refresh normalized review payload for task Review tab.
|
||||
*/
|
||||
router.post("/tasks/:id/review/refresh", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const task = await scopedStore.getTask(req.params.id);
|
||||
|
||||
let review = task.review;
|
||||
|
||||
if (task.prInfo) {
|
||||
const badgeParsed = parseBadgeUrl(task.prInfo.url);
|
||||
const owner = badgeParsed?.owner ?? getCurrentRepo(scopedStore.getRootDir())?.owner;
|
||||
const repo = badgeParsed?.repo ?? getCurrentRepo(scopedStore.getRootDir())?.repo;
|
||||
if (!owner || !repo) {
|
||||
throw badRequest("Could not determine GitHub repository for PR review refresh");
|
||||
}
|
||||
|
||||
const client = new GitHubClient();
|
||||
const snapshot = await client.getPrReviewSnapshot(owner, repo, task.prInfo.number);
|
||||
review = {
|
||||
mode: "pull-request",
|
||||
source: "github-pr",
|
||||
decision:
|
||||
snapshot.decision === "APPROVED"
|
||||
? "approved"
|
||||
: snapshot.decision === "CHANGES_REQUESTED"
|
||||
? "changes-requested"
|
||||
: "pending",
|
||||
summary: snapshot.summary,
|
||||
latestRefreshAt: new Date().toISOString(),
|
||||
selectedItemIds: task.review?.selectedItemIds ?? [],
|
||||
items: snapshot.items,
|
||||
};
|
||||
} else {
|
||||
const existing = task.review;
|
||||
review = {
|
||||
mode: existing?.mode ?? "direct",
|
||||
source: existing?.source ?? "reviewer-agent",
|
||||
decision: existing?.decision ?? "pending",
|
||||
summary: existing?.summary,
|
||||
latestRefreshAt: new Date().toISOString(),
|
||||
selectedItemIds: existing?.selectedItemIds ?? [],
|
||||
items: existing?.items ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
await scopedStore.updateTask(task.id, { review });
|
||||
res.json({ review, automationStatus: task.status ?? null });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
throw notFound(`Task ${req.params.id} not found`);
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tasks/:id/issue/refresh
|
||||
* Force refresh issue status from GitHub API.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { TaskStore, Task, TaskDetail, Column } from "@fusion/core";
|
||||
import {
|
||||
COLUMNS,
|
||||
@@ -1757,6 +1759,95 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
});
|
||||
|
||||
// Queue same-task revision pass for selected review items
|
||||
router.post("/tasks/:id/review/revise", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const task = await scopedStore.getTask(req.params.id);
|
||||
const itemIds = Array.isArray(req.body?.itemIds)
|
||||
? req.body.itemIds.filter((value: unknown): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
|
||||
if (itemIds.length === 0) {
|
||||
throw badRequest("itemIds must be a non-empty array of review item IDs");
|
||||
}
|
||||
if (!task.review) {
|
||||
throw badRequest("Task has no review payload");
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const selectedSet = new Set(itemIds);
|
||||
const selectedSummaries: string[] = [];
|
||||
const updatedItems = task.review.items.map((item) => {
|
||||
if (!selectedSet.has(item.id)) return item;
|
||||
selectedSummaries.push(`- ${item.summary}`);
|
||||
return {
|
||||
...item,
|
||||
status: "queued" as const,
|
||||
updatedAt: now,
|
||||
failedReason: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const review = {
|
||||
...task.review,
|
||||
selectedItemIds: itemIds,
|
||||
items: updatedItems,
|
||||
};
|
||||
|
||||
await scopedStore.updateTask(task.id, {
|
||||
review,
|
||||
status: null,
|
||||
error: null,
|
||||
sessionFile: null,
|
||||
});
|
||||
|
||||
if (selectedSummaries.length > 0) {
|
||||
const fusionDir = typeof scopedStore.getFusionDir === "function"
|
||||
? scopedStore.getFusionDir()
|
||||
: join(scopedStore.getRootDir(), ".fusion");
|
||||
const promptPath = join(fusionDir, "tasks", task.id, "PROMPT.md");
|
||||
try {
|
||||
const promptContent = await readFile(promptPath, "utf-8");
|
||||
const sectionHeader = "## Workflow Revision Instructions";
|
||||
const sectionContent = `${sectionHeader}\n\nAddress the following selected review feedback items in this same task run:\n\n${selectedSummaries.join("\n")}\n`;
|
||||
const sectionRegex = new RegExp(`${sectionHeader}[\\s\\S]*?(?=\\n## |\\n# |$)`, "m");
|
||||
const nextPrompt = promptContent.includes(sectionHeader)
|
||||
? promptContent.replace(sectionRegex, sectionContent)
|
||||
: `${promptContent}\n\n${sectionContent}`;
|
||||
await writeFile(promptPath, nextPrompt, "utf-8");
|
||||
} catch {
|
||||
// non-fatal: task may not have prompt yet
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedSummaries.length > 0) {
|
||||
await scopedStore.addTaskComment(
|
||||
task.id,
|
||||
`Review revision requested for selected items:\n\n${selectedSummaries.join("\n")}`,
|
||||
"user",
|
||||
);
|
||||
}
|
||||
|
||||
const lastDoneStep = [...task.steps]
|
||||
.map((step, index) => ({ step, index }))
|
||||
.reverse()
|
||||
.find(({ step }) => step.status === "done" || step.status === "in-progress");
|
||||
if (lastDoneStep) {
|
||||
await scopedStore.updateStep(task.id, lastDoneStep.index, "pending");
|
||||
}
|
||||
|
||||
const moved = await scopedStore.moveTask(task.id, "todo", { preserveProgress: true });
|
||||
await scopedStore.logEntry(task.id, "Review revision requested", `${itemIds.length} item(s) queued for same-task revision`);
|
||||
res.json({ task: moved, review });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Return task to agent - clear assignee and status, move to todo
|
||||
router.post("/tasks/:id/return-to-agent", async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { TaskStore, Task } from "@fusion/core";
|
||||
|
||||
const mockStore = {
|
||||
addTaskComment: vi.fn<(id: string, text: string, author?: string) => Promise<Task>>(),
|
||||
getTask: vi.fn<(id: string) => Promise<Task>>().mockResolvedValue({ id: "FN-001", review: undefined } as Task),
|
||||
updateTask: vi.fn<(id: string, updates: Partial<Task>) => Promise<Task>>().mockResolvedValue({ id: "FN-001" } as Task),
|
||||
createTask: vi.fn<(input: Parameters<TaskStore["createTask"]>[0]) => Promise<Task>>().mockResolvedValue({ id: "FN-123" } as Task),
|
||||
moveTask: vi.fn<(id: string, column: Task["column"]) => Promise<Task>>().mockResolvedValue({ id: "FN-001", column: "in-progress" } as Task),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
describe("PrCommentHandler", () => {
|
||||
@@ -195,6 +198,27 @@ describe("PrCommentHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleChangesRequested", () => {
|
||||
it("persists review item feedback when changes are requested", async () => {
|
||||
(mockStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-001", column: "in-review", review: undefined } as Task);
|
||||
|
||||
await handler.handleChangesRequested("FN-001", mockPrInfo, "reviewer", "Please add tests");
|
||||
|
||||
expect(mockStore.updateTask).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.objectContaining({
|
||||
review: expect.objectContaining({
|
||||
mode: "pull-request",
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({ source: "github-pr", status: "queued" }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockStore.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFollowUpTask", () => {
|
||||
it("creates follow-up task for unaddressed feedback", async () => {
|
||||
await handler.createFollowUpTask("FN-001", mockPrInfo, [
|
||||
|
||||
@@ -86,6 +86,7 @@ export class PrCommentHandler {
|
||||
|
||||
try {
|
||||
await this.store.addTaskComment(taskId, text, "agent");
|
||||
await this.upsertReviewItem(taskId, prInfo, comment, "queued");
|
||||
prMonitorLog.log(`Added comment for PR review #${comment.id}`);
|
||||
} catch (err) {
|
||||
prMonitorLog.error(`Failed to add comment for ${taskId}:`, err);
|
||||
@@ -179,6 +180,19 @@ export class PrCommentHandler {
|
||||
].join("\n");
|
||||
|
||||
await this.store.addTaskComment(taskId, feedbackText, "agent");
|
||||
await this.upsertReviewItem(
|
||||
taskId,
|
||||
prInfo,
|
||||
{
|
||||
id: Date.now(),
|
||||
body: reviewBody || "(no review body)",
|
||||
user: { login: reviewerLogin },
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
html_url: prInfo.url,
|
||||
},
|
||||
"queued",
|
||||
);
|
||||
await this.store.moveTask(taskId, "in-progress");
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
@@ -231,4 +245,51 @@ Please review the PR comments and address any remaining issues.`;
|
||||
prMonitorLog.error(`Failed to create follow-up task:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertReviewItem(
|
||||
taskId: string,
|
||||
prInfo: PrInfo,
|
||||
comment: PrComment,
|
||||
status: "queued" | "in-progress" | "addressed" | "failed",
|
||||
): Promise<void> {
|
||||
const task = await this.store.getTask(taskId);
|
||||
const now = new Date().toISOString();
|
||||
const current = task.review ?? {
|
||||
mode: "pull-request",
|
||||
source: "github-pr",
|
||||
decision: "pending",
|
||||
items: [],
|
||||
selectedItemIds: [],
|
||||
};
|
||||
const itemId = `gh-comment-${comment.id}`;
|
||||
const existingIndex = current.items.findIndex((item: { id: string }) => item.id === itemId);
|
||||
const nextItem = {
|
||||
id: itemId,
|
||||
source: "github-pr" as const,
|
||||
status,
|
||||
summary: comment.body.trim().slice(0, 160) || `Feedback from @${comment.user.login}`,
|
||||
body: comment.body,
|
||||
reviewer: comment.user.login,
|
||||
commentUrl: comment.html_url,
|
||||
createdAt: comment.created_at,
|
||||
updatedAt: now,
|
||||
};
|
||||
const nextItems = [...current.items];
|
||||
if (existingIndex >= 0) {
|
||||
nextItems[existingIndex] = { ...nextItems[existingIndex], ...nextItem };
|
||||
} else {
|
||||
nextItems.push(nextItem);
|
||||
}
|
||||
|
||||
await this.store.updateTask(taskId, {
|
||||
review: {
|
||||
...current,
|
||||
mode: "pull-request",
|
||||
source: "github-pr",
|
||||
summary: `PR #${prInfo.number} feedback items: ${nextItems.length}`,
|
||||
latestRefreshAt: now,
|
||||
items: nextItems,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user