feat(FN-3278): add direct-mode review tab to task detail modal
Merges FN-3278 review tab direct-mode (data, rendering, regression tests, typing fixes, and docs across Steps 2/3/6), FN-3750 mobile keyboard stabilization for mailbox and reply composer, FN-3751 fire-and-forget wake dispatch to prevent request hangs, and FN-3748 workspace aliases for @fusion/* pack Fusion-Task-Id: FN-3278
This commit is contained in:
@@ -702,6 +702,8 @@ export interface TaskCommentInput {
|
||||
export type TaskReviewMode = "pull-request" | "direct";
|
||||
export type TaskReviewSource = "github-pr" | "reviewer-agent";
|
||||
export type TaskReviewDecision = "approved" | "changes-requested" | "commented" | "pending";
|
||||
export type TaskReviewVerdict = "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE";
|
||||
export type TaskReviewerType = "plan" | "code";
|
||||
export type TaskReviewItemStatus = "queued" | "in-progress" | "addressed" | "failed";
|
||||
|
||||
export interface TaskReviewItem {
|
||||
@@ -778,6 +780,12 @@ export interface TaskReviewStateItem {
|
||||
state?: string;
|
||||
htmlUrl?: string;
|
||||
isResolved?: boolean;
|
||||
source?: TaskReviewSource;
|
||||
reviewType?: TaskReviewerType;
|
||||
verdict?: TaskReviewVerdict;
|
||||
step?: number;
|
||||
summary?: string;
|
||||
addressingStatus?: TaskReviewItemStatus;
|
||||
}
|
||||
|
||||
export interface ReviewAddressingRecord {
|
||||
@@ -790,10 +798,16 @@ export interface ReviewAddressingRecord {
|
||||
stale?: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewerTaskReviewSummary {
|
||||
verdict?: TaskReviewVerdict;
|
||||
reviewType?: TaskReviewerType;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface TaskReviewState {
|
||||
source: "pull-request" | "reviewer-agent";
|
||||
lastRefreshedAt?: string;
|
||||
summary?: PrTaskReviewSummary;
|
||||
summary?: PrTaskReviewSummary | ReviewerTaskReviewSummary;
|
||||
items: TaskReviewStateItem[];
|
||||
addressing: ReviewAddressingRecord[];
|
||||
}
|
||||
|
||||
@@ -254,6 +254,12 @@ export interface UpdateTaskReviewRequest {
|
||||
reviewState: TaskDetail["reviewState"] | null;
|
||||
}
|
||||
|
||||
export interface TaskReviewResponse {
|
||||
reviewState: NonNullable<TaskDetail["reviewState"]>;
|
||||
automationStatus: string | null;
|
||||
emptyMessage?: string | null;
|
||||
}
|
||||
|
||||
export interface RefreshTaskReviewResponse {
|
||||
reviewState: NonNullable<TaskDetail["reviewState"]>;
|
||||
automationStatus: string | null;
|
||||
@@ -5107,6 +5113,11 @@ export function acceptTaskReview(taskId: string, projectId?: string): Promise<Ta
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch normalized task review data (PR mode or direct mode) */
|
||||
export function fetchTaskReview(taskId: string, projectId?: string): Promise<TaskReviewResponse> {
|
||||
return api<TaskReviewResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review`, projectId));
|
||||
}
|
||||
|
||||
/** 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), {
|
||||
|
||||
@@ -74,6 +74,23 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.task-review-tab__direct-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-review-tab__body {
|
||||
margin: 0;
|
||||
padding: var(--space-sm);
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-primary);
|
||||
}
|
||||
|
||||
.task-review-tab__status {
|
||||
color: var(--text-muted);
|
||||
text-transform: capitalize;
|
||||
@@ -120,4 +137,12 @@
|
||||
.task-review-tab__status {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.task-review-tab__actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-review-tab__actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "./TaskReviewTab.css";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import { useMemo, useState } from "react";
|
||||
import { refreshTaskReview, reviseTaskReviewItems } from "../api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface Props {
|
||||
@@ -11,27 +11,78 @@ interface Props {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
const REVIEW_LOAD_ERROR_MESSAGE = "Failed to load review data.";
|
||||
const DIRECT_MODE_EMPTY_MESSAGE =
|
||||
"No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.";
|
||||
|
||||
function formatTimestamp(value?: string): string {
|
||||
if (!value) return "Never";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
type ReviewItem = NonNullable<TaskDetail["reviewState"]>["items"][number];
|
||||
|
||||
function getItemStatus(item: ReviewItem): "queued" | "in-progress" | "addressed" | "failed" {
|
||||
if (
|
||||
item.addressingStatus === "queued" ||
|
||||
item.addressingStatus === "in-progress" ||
|
||||
item.addressingStatus === "addressed" ||
|
||||
item.addressingStatus === "failed"
|
||||
) {
|
||||
return item.addressingStatus;
|
||||
}
|
||||
return "queued";
|
||||
}
|
||||
|
||||
export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Props) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [revising, setRevising] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const review = task.reviewState;
|
||||
const [emptyMessage, setEmptyMessage] = useState<string | null>(null);
|
||||
const [review, setReview] = useState(task.reviewState ?? null);
|
||||
|
||||
const canRevise = selected.length > 0 && !revising;
|
||||
const isPrMode = review?.source === "pull-request";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchTaskReview(task.id, projectId)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setReview(result.reviewState);
|
||||
setEmptyMessage(result.emptyMessage ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setError(REVIEW_LOAD_ERROR_MESSAGE);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [task.id, projectId]);
|
||||
|
||||
const summaryText = useMemo(() => {
|
||||
if (!review) return "No review feedback captured yet.";
|
||||
const decision = review.summary?.reviewDecision ?? "REVIEW_REQUIRED";
|
||||
return `${decision} · ${review.items.length} review item(s)`;
|
||||
if (review.source === "pull-request") {
|
||||
const prSummary = review.summary as { reviewDecision?: string } | undefined;
|
||||
return `${prSummary?.reviewDecision ?? "REVIEW_REQUIRED"} · ${review.items.length} review item(s)`;
|
||||
}
|
||||
const reviewerSummary = review.summary as { summary?: string } | undefined;
|
||||
return `${reviewerSummary?.summary ?? "reviewer-agent"} · ${review.items.length} review item(s)`;
|
||||
}, [review]);
|
||||
|
||||
const decisionLabel = review?.summary?.reviewDecision ?? undefined;
|
||||
const decisionLabel = !review
|
||||
? undefined
|
||||
: review.source === "pull-request"
|
||||
? (review.summary as { reviewDecision?: string } | undefined)?.reviewDecision
|
||||
: (review.summary as { verdict?: string } | undefined)?.verdict;
|
||||
|
||||
const toggleSelected = (id: string) => {
|
||||
setSelected((prev) => (prev.includes(id) ? prev.filter((value) => value !== id) : [...prev, id]));
|
||||
@@ -42,12 +93,12 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
|
||||
setError(null);
|
||||
setRefreshing(true);
|
||||
const result = await refreshTaskReview(task.id, projectId);
|
||||
setReview(result.reviewState);
|
||||
onTaskUpdated?.({ ...task, reviewState: result.reviewState } as Task);
|
||||
addToast("Review refreshed", "success");
|
||||
} catch (refreshError) {
|
||||
const message = refreshError instanceof Error ? refreshError.message : "Failed to refresh review";
|
||||
setError(message);
|
||||
addToast(message, "error");
|
||||
} catch {
|
||||
setError(REVIEW_LOAD_ERROR_MESSAGE);
|
||||
addToast(REVIEW_LOAD_ERROR_MESSAGE, "error");
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
@@ -58,6 +109,7 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
|
||||
setError(null);
|
||||
setRevising(true);
|
||||
const result = await reviseTaskReviewItems(task.id, selected, projectId);
|
||||
setReview(result.reviewState);
|
||||
onTaskUpdated?.({ ...result.task, reviewState: result.reviewState } as Task);
|
||||
setSelected([]);
|
||||
addToast("Queued same-task revision", "success");
|
||||
@@ -85,18 +137,19 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-review-tab__meta">Last refreshed: {formatTimestamp(review?.lastRefreshedAt)}</div>
|
||||
{error ? <div className="task-review-tab__error">{error}</div> : null}
|
||||
{!isPrMode ? (
|
||||
<div className="task-review-tab__empty">GitHub PR review details are only available when auto-merge uses Pull Request mode. Reviewer-agent feedback will appear here in direct mode.</div>
|
||||
{loading ? <div className="task-review-tab__meta">Loading review data…</div> : null}
|
||||
{!loading && error ? <div className="task-review-tab__error">{error}</div> : null}
|
||||
{!loading && !error && !isPrMode && review?.items?.length === 0 ? (
|
||||
<div className="task-review-tab__empty">{emptyMessage ?? DIRECT_MODE_EMPTY_MESSAGE}</div>
|
||||
) : null}
|
||||
{isPrMode && review?.summary?.reviewers?.length ? (
|
||||
{isPrMode && review?.summary && "reviewers" in review.summary && review.summary.reviewers?.length ? (
|
||||
<ul className="task-review-tab__reviewers">
|
||||
{review.summary.reviewers.map((reviewer) => (
|
||||
<li key={`${reviewer.login}-${reviewer.state}`} className="task-review-tab__reviewer">@{reviewer.login} · {reviewer.state}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{isPrMode && review?.summary?.blockingReasons?.length ? (
|
||||
{isPrMode && review?.summary && "blockingReasons" in review.summary && review.summary.blockingReasons?.length ? (
|
||||
<ul className="task-review-tab__blockers">
|
||||
{review.summary.blockingReasons.map((reason) => <li key={reason}>{reason}</li>)}
|
||||
</ul>
|
||||
@@ -120,9 +173,34 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : isPrMode ? (
|
||||
<div className="task-review-tab__empty">No review items yet.</div>
|
||||
) : null}
|
||||
{!loading && !error && !isPrMode && review?.items?.length ? (
|
||||
<ul className="task-review-tab__list">
|
||||
{review.items
|
||||
.slice()
|
||||
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
|
||||
.map((item) => {
|
||||
const status = getItemStatus(item);
|
||||
return (
|
||||
<li key={item.id} className="task-review-tab__item card">
|
||||
<div className="task-review-tab__direct-item">
|
||||
<div className="task-review-tab__summary-wrap">
|
||||
<span className="task-review-tab__decision">reviewer-agent</span>
|
||||
{item.reviewType ? <span className="task-review-tab__meta">{item.reviewType} review</span> : null}
|
||||
{typeof item.step === "number" ? <span className="task-review-tab__meta">Step {item.step}</span> : null}
|
||||
{item.verdict ? <span className="task-review-tab__decision">{item.verdict}</span> : null}
|
||||
<span className={`task-review-tab__status task-review-tab__status--${status}`}>{status}</span>
|
||||
</div>
|
||||
{item.summary ? <p className="task-review-tab__summary">{item.summary}</p> : null}
|
||||
<div className="task-review-tab__meta">{formatTimestamp(item.createdAt)}</div>
|
||||
<pre className="task-review-tab__body">{item.body}</pre>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
{isPrMode && !loading && !error && !review?.items?.length ? <div className="task-review-tab__empty">No review items yet.</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
setupTaskDetailModalHooks,
|
||||
} from "./TaskDetailModal.test-helpers";
|
||||
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
|
||||
import * as dashboardApi from "../../api";
|
||||
|
||||
setupTaskDetailModalHooks();
|
||||
|
||||
@@ -307,7 +308,7 @@ describe("TaskDetailModal", () => {
|
||||
expect(screen.getByText("Comments")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows non-PR review shell message in Review tab", () => {
|
||||
it("shows non-PR review shell message in Review tab", async () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ reviewState: { source: "reviewer-agent", items: [], addressing: [] } })}
|
||||
@@ -321,10 +322,25 @@ describe("TaskDetailModal", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review" }));
|
||||
expect(screen.getByText("GitHub PR review details are only available when auto-merge uses Pull Request mode. Reviewer-agent feedback will appear here in direct mode.")).toBeTruthy();
|
||||
expect(await screen.findByText("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows PR review decision details in Review tab", () => {
|
||||
it("shows PR review decision details in Review tab", async () => {
|
||||
vi.mocked(dashboardApi.fetchTaskReview).mockResolvedValueOnce({
|
||||
reviewState: {
|
||||
source: "pull-request",
|
||||
summary: {
|
||||
reviewDecision: "CHANGES_REQUESTED",
|
||||
reviewers: [{ login: "octocat", state: "CHANGES_REQUESTED" }],
|
||||
blockingReasons: ["changes requested review is active"],
|
||||
checks: [],
|
||||
},
|
||||
items: [],
|
||||
addressing: [],
|
||||
},
|
||||
automationStatus: null,
|
||||
emptyMessage: null,
|
||||
});
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ reviewState: { source: "pull-request", summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [{ login: "octocat", state: "CHANGES_REQUESTED" }], blockingReasons: ["changes requested review is active"], checks: [] }, items: [], addressing: [] } })}
|
||||
@@ -338,7 +354,7 @@ describe("TaskDetailModal", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review" }));
|
||||
expect(screen.getByText("CHANGES_REQUESTED")).toBeTruthy();
|
||||
expect(await screen.findByText("CHANGES_REQUESTED")).toBeTruthy();
|
||||
expect(screen.getByText("changes requested review is active")).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -35,8 +35,9 @@ 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 }),
|
||||
fetchTaskReview: vi.fn().mockResolvedValue({ reviewState: { source: "reviewer-agent", items: [], addressing: [] }, automationStatus: null, emptyMessage: "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode." }),
|
||||
refreshTaskReview: vi.fn().mockResolvedValue({ reviewState: undefined, automationStatus: null }),
|
||||
reviseTaskReviewItems: vi.fn().mockResolvedValue({ task: makeTask(), reviewState: undefined }),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import { TaskReviewTab } from "../TaskReviewTab";
|
||||
import { makeTask } from "./TaskDetailModal.test-helpers";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
fetchTaskReview: vi.fn(),
|
||||
refreshTaskReview: vi.fn(),
|
||||
reviseTaskReviewItems: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchTaskReview: apiMocks.fetchTaskReview,
|
||||
refreshTaskReview: apiMocks.refreshTaskReview,
|
||||
reviseTaskReviewItems: apiMocks.reviseTaskReviewItems,
|
||||
}));
|
||||
@@ -18,21 +20,28 @@ describe("TaskReviewTab", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders empty state when review is missing", () => {
|
||||
it("renders direct-mode empty state when no reviewer feedback exists", async () => {
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({
|
||||
reviewState: { source: "reviewer-agent", items: [], addressing: [] },
|
||||
automationStatus: null,
|
||||
emptyMessage: "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.",
|
||||
});
|
||||
|
||||
render(<TaskReviewTab task={makeTask({ reviewState: undefined })} addToast={vi.fn()} />);
|
||||
expect(screen.getByText("GitHub PR review details are only available when auto-merge uses Pull Request mode. Reviewer-agent feedback will appear here in direct mode.")).toBeInTheDocument();
|
||||
expect(await screen.findByText("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Request revision" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("calls refresh endpoint", async () => {
|
||||
const task = makeTask({ reviewState: { source: "pull-request", summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [] }, items: [], addressing: [] } });
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
apiMocks.refreshTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null });
|
||||
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refresh" }));
|
||||
expect(apiMocks.refreshTaskReview).toHaveBeenCalledWith(task.id, undefined);
|
||||
});
|
||||
|
||||
it("renders PR decision and status modifiers", () => {
|
||||
it("renders PR decision and status modifiers", async () => {
|
||||
const task = makeTask({
|
||||
reviewState: {
|
||||
source: "pull-request",
|
||||
@@ -49,8 +58,9 @@ describe("TaskReviewTab", () => {
|
||||
},
|
||||
});
|
||||
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
expect(screen.getByText("CHANGES_REQUESTED")).toBeInTheDocument();
|
||||
await screen.findByText("CHANGES_REQUESTED");
|
||||
expect(screen.getByText("failed").className).toContain("task-review-tab__status--failed");
|
||||
});
|
||||
|
||||
@@ -71,14 +81,46 @@ describe("TaskReviewTab", () => {
|
||||
},
|
||||
});
|
||||
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
apiMocks.reviseTaskReviewItems.mockResolvedValue({ task, reviewState: task.reviewState });
|
||||
apiMocks.refreshTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null });
|
||||
|
||||
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
fireEvent.click(await screen.findByRole("checkbox"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Request revision" }));
|
||||
|
||||
expect(apiMocks.reviseTaskReviewItems).toHaveBeenCalledWith(task.id, ["ri-1"], undefined);
|
||||
});
|
||||
|
||||
it("renders reviewer-agent entries in direct mode", async () => {
|
||||
const task = makeTask();
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({
|
||||
reviewState: {
|
||||
source: "reviewer-agent",
|
||||
summary: { verdict: "REVISE", reviewType: "code", summary: "Needs fixes" },
|
||||
items: [
|
||||
{
|
||||
id: "reviewer-code-1",
|
||||
body: "## Code Review:\n\n### Verdict:\nREVISE",
|
||||
author: { login: "reviewer-agent" },
|
||||
createdAt: new Date().toISOString(),
|
||||
reviewType: "code",
|
||||
verdict: "REVISE",
|
||||
step: 2,
|
||||
summary: "code review Step 2: REVISE",
|
||||
addressingStatus: "in-progress",
|
||||
},
|
||||
],
|
||||
addressing: [],
|
||||
},
|
||||
automationStatus: null,
|
||||
emptyMessage: null,
|
||||
});
|
||||
|
||||
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
expect(await screen.findByText("reviewer-agent")).toBeInTheDocument();
|
||||
expect(screen.getByText("Step 2")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("REVISE").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export const dashboardApiMocks = {
|
||||
fetchAuthStatus: vi.fn(async () => ({ providers: [] })),
|
||||
fetchModels: vi.fn(async () => ({ models: [], favoriteProviders: [], favoriteModels: [] })),
|
||||
fetchTaskDetail: vi.fn(),
|
||||
fetchTaskReview: vi.fn(),
|
||||
fetchUnreadCount: vi.fn(async () => ({ unreadCount: 0 })),
|
||||
} satisfies Record<string, AnyFn>;
|
||||
|
||||
|
||||
@@ -2041,15 +2041,63 @@ describe("GET /tasks/:id/review", () => {
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns cached reviewState payload", async () => {
|
||||
it("returns normalized reviewer-agent payload in direct mode", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
reviewState: { source: "reviewer-agent", items: [], addressing: [] },
|
||||
log: [{ timestamp: "2026-05-01T10:00:00.000Z", action: "code review Step 2: REVISE" }],
|
||||
});
|
||||
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
timestamp: "2026-05-01T10:00:01.000Z",
|
||||
taskId: "FN-001",
|
||||
type: "text",
|
||||
text: "## Code Review:\n\n### Verdict: REVISE\n\n### Summary\nNeeds null guard\n",
|
||||
agent: "reviewer",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reviewState.source).toBe("reviewer-agent");
|
||||
expect(res.body.reviewState.items[0].reviewType).toBe("code");
|
||||
expect(res.body.reviewState.items[0].verdict).toBe("REVISE");
|
||||
});
|
||||
|
||||
it("returns exact empty payload/message when no reviewer feedback exists", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
reviewState: { source: "reviewer-agent", items: [], addressing: [] },
|
||||
log: [],
|
||||
});
|
||||
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reviewState.items).toEqual([]);
|
||||
expect(res.body.emptyMessage).toBe("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.");
|
||||
});
|
||||
|
||||
it("falls back to task log summary when reviewer output is incomplete", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
reviewState: { source: "reviewer-agent", items: [], addressing: [] },
|
||||
log: [{ timestamp: "2026-05-01T10:00:00.000Z", action: "plan review Step 1: APPROVE" }],
|
||||
});
|
||||
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
timestamp: "2026-05-01T10:00:01.000Z",
|
||||
taskId: "FN-001",
|
||||
type: "text",
|
||||
text: "partial stream",
|
||||
agent: "reviewer",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reviewState.items[0].summary).toBe("plan review Step 1: APPROVE");
|
||||
expect(res.body.reviewState.items[0].step).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 404 when task is missing", async () => {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
|
||||
function createMockStore(): TaskStore {
|
||||
const now = "2026-05-01T00:00:00.000Z";
|
||||
return {
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: "FN-001",
|
||||
description: "task",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
function buildApp(store: TaskStore) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("task review routes", () => {
|
||||
it("returns normalized reviewer-agent payload for direct mode", async () => {
|
||||
const store = createMockStore();
|
||||
vi.mocked(store.getTask).mockResolvedValue({
|
||||
...(await store.getTask("FN-001")),
|
||||
log: [{ timestamp: "2026-05-01T10:00:00.000Z", action: "code review Step 2: REVISE" }],
|
||||
} as any);
|
||||
vi.mocked(store.getAgentLogs).mockResolvedValue([
|
||||
{
|
||||
timestamp: "2026-05-01T10:00:01.000Z",
|
||||
taskId: "FN-001",
|
||||
type: "text",
|
||||
text: "## Code Review:\n\n### Verdict: REVISE\n\n### Summary\nNeeds guard\n",
|
||||
agent: "reviewer",
|
||||
},
|
||||
] as any);
|
||||
|
||||
const res = await REQUEST(buildApp(store), "GET", "/api/tasks/FN-001/review");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reviewState.source).toBe("reviewer-agent");
|
||||
expect(res.body.reviewState.items[0].reviewType).toBe("code");
|
||||
});
|
||||
|
||||
it("returns exact empty payload/message when no feedback exists", async () => {
|
||||
const store = createMockStore();
|
||||
const res = await REQUEST(buildApp(store), "GET", "/api/tasks/FN-001/review");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reviewState.items).toEqual([]);
|
||||
expect(res.body.emptyMessage).toBe("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.");
|
||||
});
|
||||
|
||||
it("falls back to task-log summary when reviewer output is incomplete", async () => {
|
||||
const store = createMockStore();
|
||||
vi.mocked(store.getTask).mockResolvedValue({
|
||||
...(await store.getTask("FN-001")),
|
||||
log: [{ timestamp: "2026-05-01T10:00:00.000Z", action: "plan review Step 1: APPROVE" }],
|
||||
} as any);
|
||||
vi.mocked(store.getAgentLogs).mockResolvedValue([
|
||||
{ timestamp: "2026-05-01T10:00:01.000Z", taskId: "FN-001", type: "text", text: "partial", agent: "reviewer" },
|
||||
] as any);
|
||||
|
||||
const res = await REQUEST(buildApp(store), "GET", "/api/tasks/FN-001/review");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reviewState.items[0].summary).toBe("plan review Step 1: APPROVE");
|
||||
expect(res.body.reviewState.items[0].step).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,15 @@
|
||||
import { type NextFunction, type Request, type Response } from "express";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import type { BatchStatusEntry, BatchStatusResponse, BatchStatusResult, IssueInfo, PrInfo, TaskStore } from "@fusion/core";
|
||||
import type {
|
||||
BatchStatusEntry,
|
||||
BatchStatusResponse,
|
||||
BatchStatusResult,
|
||||
IssueInfo,
|
||||
PrInfo,
|
||||
Task,
|
||||
TaskStore,
|
||||
} from "@fusion/core";
|
||||
import { getCurrentRepo, isGhAuthenticated } from "@fusion/core";
|
||||
import {
|
||||
ApiError,
|
||||
@@ -75,6 +83,103 @@ export function parseGitHubBadgeUrl(url: string | undefined): { owner: string; r
|
||||
}
|
||||
}
|
||||
|
||||
const DIRECT_REVIEW_EMPTY_MESSAGE =
|
||||
"No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.";
|
||||
|
||||
type CanonicalTaskReviewState = NonNullable<Task["reviewState"]>;
|
||||
type CanonicalTaskReviewStateItem = CanonicalTaskReviewState["items"][number];
|
||||
type CanonicalTaskReviewVerdict = NonNullable<CanonicalTaskReviewStateItem["verdict"]>;
|
||||
type CanonicalTaskReviewerType = NonNullable<CanonicalTaskReviewStateItem["reviewType"]>;
|
||||
|
||||
const REVIEW_BLOCK_RE = /##\s+(Code|Plan)\s+Review:[\s\S]*?(?=\n##\s+(?:Code|Plan)\s+Review:|$)/gi;
|
||||
const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
||||
const REVIEW_SUMMARY_RE = /###\s+Summary\s*\n([\s\S]*?)(?=\n###\s+|$)/i;
|
||||
const REVIEW_STEP_RE = /^(plan|code) review Step (\d+): (APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
||||
|
||||
function extractDirectReviewItems(task: Task, reviewerText: string): CanonicalTaskReviewStateItem[] {
|
||||
const fallbackLogs = (task.log ?? []).filter((entry) => REVIEW_STEP_RE.test(entry.action));
|
||||
const fallbackByType = new Map<CanonicalTaskReviewerType, { step?: number; verdict?: CanonicalTaskReviewVerdict; timestamp: string; summary: string }>();
|
||||
for (const entry of fallbackLogs) {
|
||||
const match = entry.action.match(REVIEW_STEP_RE);
|
||||
if (!match) continue;
|
||||
const reviewType = match[1].toLowerCase() === "plan" ? "plan" : "code";
|
||||
fallbackByType.set(reviewType, {
|
||||
step: Number.parseInt(match[2], 10),
|
||||
verdict: match[3].toUpperCase() as CanonicalTaskReviewVerdict,
|
||||
timestamp: entry.timestamp,
|
||||
summary: entry.action,
|
||||
});
|
||||
}
|
||||
|
||||
const items: CanonicalTaskReviewStateItem[] = [];
|
||||
const blocks = reviewerText.match(REVIEW_BLOCK_RE) ?? [];
|
||||
for (let index = 0; index < blocks.length; index += 1) {
|
||||
const block = blocks[index] ?? "";
|
||||
const typeMatch = block.match(/##\s+(Code|Plan)\s+Review:/i);
|
||||
const reviewType: CanonicalTaskReviewerType = typeMatch?.[1]?.toLowerCase() === "plan" ? "plan" : "code";
|
||||
const verdict = block.match(REVIEW_VERDICT_RE)?.[1]?.toUpperCase() as CanonicalTaskReviewVerdict | undefined;
|
||||
const summary = block.match(REVIEW_SUMMARY_RE)?.[1]?.trim() || fallbackByType.get(reviewType)?.summary;
|
||||
const fallback = fallbackByType.get(reviewType);
|
||||
items.push({
|
||||
id: `reviewer-${reviewType}-${index + 1}`,
|
||||
body: block.trim(),
|
||||
author: { login: "reviewer-agent" },
|
||||
createdAt: fallback?.timestamp ?? task.updatedAt,
|
||||
source: "reviewer-agent",
|
||||
reviewType,
|
||||
verdict: verdict ?? fallback?.verdict,
|
||||
step: fallback?.step,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length > 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return fallbackLogs.map((entry, index) => {
|
||||
const match = entry.action.match(REVIEW_STEP_RE);
|
||||
const reviewType: CanonicalTaskReviewerType = match?.[1]?.toLowerCase() === "plan" ? "plan" : "code";
|
||||
const verdict = match?.[3]?.toUpperCase() as CanonicalTaskReviewVerdict | undefined;
|
||||
const step = match?.[2] ? Number.parseInt(match[2], 10) : undefined;
|
||||
return {
|
||||
id: `reviewer-fallback-${index + 1}`,
|
||||
body: entry.action,
|
||||
author: { login: "reviewer-agent" },
|
||||
createdAt: entry.timestamp,
|
||||
source: "reviewer-agent",
|
||||
reviewType,
|
||||
verdict,
|
||||
step,
|
||||
summary: entry.action,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function buildDirectReviewState(task: Task, store: TaskStore): Promise<CanonicalTaskReviewState> {
|
||||
const agentLogs = await store.getAgentLogs(task.id);
|
||||
const reviewerText = agentLogs
|
||||
.filter((entry) => entry.agent === "reviewer" && entry.type === "text")
|
||||
.map((entry) => entry.text)
|
||||
.join("");
|
||||
const items = extractDirectReviewItems(task, reviewerText);
|
||||
const newest = [...items].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||
const latest = newest[0];
|
||||
return {
|
||||
source: "reviewer-agent",
|
||||
lastRefreshedAt: new Date().toISOString(),
|
||||
summary: latest
|
||||
? {
|
||||
verdict: latest.verdict,
|
||||
reviewType: latest.reviewType,
|
||||
summary: latest.summary,
|
||||
}
|
||||
: { summary: DIRECT_REVIEW_EMPTY_MESSAGE },
|
||||
items: newest,
|
||||
addressing: task.reviewState?.addressing ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGitHubRemotes(cwd?: string): Promise<GitRemote[]> {
|
||||
try {
|
||||
const output = await runGitCommand(["remote", "-v"], cwd, 5000);
|
||||
@@ -3128,7 +3233,19 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const task = await scopedStore.getTask(req.params.id);
|
||||
res.json({ reviewState: task.reviewState ?? null, automationStatus: task.status ?? null });
|
||||
const hasPrReview = task.reviewState?.source === "pull-request";
|
||||
const reviewState = (hasPrReview
|
||||
? task.reviewState
|
||||
: await buildDirectReviewState(task, scopedStore)) ?? {
|
||||
source: "reviewer-agent",
|
||||
items: [],
|
||||
addressing: [],
|
||||
};
|
||||
res.json({
|
||||
reviewState,
|
||||
automationStatus: task.status ?? null,
|
||||
emptyMessage: !hasPrReview && reviewState.items.length === 0 ? DIRECT_REVIEW_EMPTY_MESSAGE : null,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
@@ -3172,13 +3289,8 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
addressing,
|
||||
};
|
||||
} else {
|
||||
reviewState = {
|
||||
source: task.reviewState?.source ?? "reviewer-agent",
|
||||
lastRefreshedAt: now,
|
||||
summary: task.reviewState?.summary,
|
||||
items: task.reviewState?.items ?? [],
|
||||
addressing: task.reviewState?.addressing ?? [],
|
||||
};
|
||||
reviewState = await buildDirectReviewState(task, scopedStore);
|
||||
reviewState.lastRefreshedAt = now;
|
||||
}
|
||||
|
||||
await scopedStore.updateTask(task.id, { reviewState });
|
||||
|
||||
Reference in New Issue
Block a user