feat(FN-3277): add review tab reviewState persistence and enriched UI state

This merge completes a review tab feature with persistent `reviewState` — adding the core types, TaskReviewTab UI enhancements (enriched states and interactions), GitHub integration routes for refresh/address flows, and full regression coverage for both PR and non-PR modes. The architecture docs wer

Fusion-Task-Id: FN-3277
This commit is contained in:
Fusion
2026-05-08 10:32:53 -07:00
committed by gsxdsm
parent 31d257d4af
commit cea0f6f6c0
19 changed files with 466 additions and 167 deletions

View File

@@ -753,6 +753,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return deduped.length > 0 ? deduped : undefined;
})(),
review: fromJson<import("./types.js").TaskReview>(row.review) ?? undefined,
reviewState: fromJson<import("./types.js").TaskReviewState>(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),
@@ -940,6 +941,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
attachments: task.attachments,
comments: task.comments,
review: task.review,
reviewState: task.reviewState,
prompt,
...agentLogFields,
log: [{ timestamp: archivedAt, action: "Task archived" }],
@@ -1254,7 +1256,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
toJson(task.attachments || []),
toJson(task.steeringComments || []),
toJson(task.comments || []),
toJsonNullable(task.review),
toJsonNullable(task.reviewState ?? task.review),
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJsonNullable(task.issueInfo),
@@ -3188,7 +3190,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; 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 },
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; reviewState?: import("./types.js").TaskReviewState | 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 () => {
@@ -3463,6 +3465,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.review !== undefined) {
task.review = updates.review;
}
if (updates.reviewState === null) {
task.reviewState = undefined;
} else if (updates.reviewState !== undefined) {
task.reviewState = updates.reviewState;
}
if (updates.workflowStepResults === null) {
task.workflowStepResults = undefined;
} else if (updates.workflowStepResults !== undefined) {

View File

@@ -730,6 +730,74 @@ export interface TaskReview {
items: TaskReviewItem[];
}
export type PrCheckState =
| "success"
| "pending"
| "failure"
| "cancelled"
| "timed_out"
| "action_required"
| "neutral"
| "skipped"
| "stale"
| "startup_failure";
export interface PrCheckStatus {
name: string;
required: boolean;
state: PrCheckState;
}
export interface TaskReviewAuthor {
login: string;
}
export interface PrTaskReviewSummaryReviewer {
login: string;
state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "PENDING";
submittedAt?: string;
}
export interface PrTaskReviewSummary {
reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
reviewers: PrTaskReviewSummaryReviewer[];
blockingReasons: string[];
checks: PrCheckStatus[];
}
export interface TaskReviewStateItem {
id: string;
threadId?: string;
githubCommentId?: number;
path?: string;
diffSide?: string;
body: string;
author: TaskReviewAuthor;
createdAt: string;
updatedAt?: string;
state?: string;
htmlUrl?: string;
isResolved?: boolean;
}
export interface ReviewAddressingRecord {
itemId: string;
status: "queued" | "in-progress" | "addressed" | "failed";
selectedAt: string;
startedAt?: string;
completedAt?: string;
error?: string;
stale?: boolean;
}
export interface TaskReviewState {
source: "pull-request" | "reviewer-agent";
lastRefreshedAt?: string;
summary?: PrTaskReviewSummary;
items: TaskReviewStateItem[];
addressing: ReviewAddressingRecord[];
}
export interface TaskDocument {
/** UUID primary key */
id: string;
@@ -949,8 +1017,10 @@ export interface Task {
attachments?: TaskAttachment[];
steeringComments?: SteeringComment[];
comments?: TaskComment[];
/** Structured review metadata shown in the Review tab. */
/** Structured review metadata shown in the Review tab (legacy contract). */
review?: TaskReview;
/** Structured review metadata shown in the Review tab (canonical contract). */
reviewState?: TaskReviewState;
/** PR information for tasks linked to GitHub pull requests */
prInfo?: PrInfo;
mergeDetails?: MergeDetails;
@@ -2372,8 +2442,10 @@ 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. */
/** Structured review metadata shown in the Review tab (legacy contract). */
review?: TaskReview;
/** Structured review metadata shown in the Review tab (canonical contract). */
reviewState?: TaskReviewState;
/** Reconstructed prompt content at archive time, without attachment blobs. */
prompt?: string;
/** Agent log retention mode used when this archive entry was written. */

View File

@@ -251,17 +251,17 @@ export async function fetchTaskDetail(id: string, projectId?: string): Promise<T
}
export interface UpdateTaskReviewRequest {
review: TaskDetail["review"] | null;
reviewState: TaskDetail["reviewState"] | null;
}
export interface RefreshTaskReviewResponse {
review: NonNullable<TaskDetail["review"]>;
reviewState: NonNullable<TaskDetail["reviewState"]>;
automationStatus: string | null;
}
export interface ReviseTaskReviewResponse {
task: Task;
review: NonNullable<TaskDetail["review"]>;
reviewState: NonNullable<TaskDetail["reviewState"]>;
}
export interface CreateTaskRequestOptions {
@@ -5116,7 +5116,7 @@ export function refreshTaskReview(taskId: string, projectId?: string): Promise<R
/** 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), {
return api<ReviseTaskReviewResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/address`, projectId), {
method: "POST",
body: JSON.stringify({ itemIds }),
});

View File

@@ -31,16 +31,17 @@
text-transform: capitalize;
}
.task-review-tab__decision--approved {
.task-review-tab__decision--APPROVED {
color: var(--color-success);
}
.task-review-tab__decision--changes-requested {
.task-review-tab__decision--CHANGES_REQUESTED {
color: var(--color-error);
}
.task-review-tab__decision--commented,
.task-review-tab__decision--pending {
.task-review-tab__decision--COMMENTED,
.task-review-tab__decision--PENDING,
.task-review-tab__decision--REVIEW_REQUIRED {
color: var(--color-warning);
}
@@ -91,10 +92,26 @@
color: var(--color-success);
}
.task-review-tab__meta,
.task-review-tab__empty {
color: var(--text-muted);
}
.task-review-tab__error {
color: var(--color-error);
}
.task-review-tab__reviewers,
.task-review-tab__blockers {
margin: 0;
padding-left: var(--space-lg);
color: var(--text-muted);
}
.task-review-tab__reviewer {
color: var(--text);
}
@media (max-width: 768px) {
.task-review-tab__row {
grid-template-columns: auto 1fr;

View File

@@ -11,17 +11,27 @@ interface Props {
addToast: (message: string, type?: ToastType) => void;
}
function formatTimestamp(value?: string): string {
if (!value) return "Never";
return new Date(value).toLocaleString();
}
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 [selected, setSelected] = useState<string[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [revising, setRevising] = useState(false);
const [error, setError] = useState<string | null>(null);
const review = task.reviewState;
const canRevise = selected.length > 0 && !revising;
const isPrMode = review?.source === "pull-request";
const summaryText = useMemo(() => {
if (!review) return "No review feedback captured yet.";
return review.summary ?? `${review.items.length} review item(s)`;
const decision = review.summary?.reviewDecision ?? "REVIEW_REQUIRED";
return `${decision} · ${review.items.length} review item(s)`;
}, [review]);
const decisionLabel = review?.decision ? review.decision.replace("-", " ") : undefined;
const decisionLabel = review?.summary?.reviewDecision ?? undefined;
const toggleSelected = (id: string) => {
setSelected((prev) => (prev.includes(id) ? prev.filter((value) => value !== id) : [...prev, id]));
@@ -29,21 +39,34 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
const onRefresh = async () => {
try {
setError(null);
setRefreshing(true);
const result = await refreshTaskReview(task.id, projectId);
onTaskUpdated?.({ ...task, review: result.review } as Task);
onTaskUpdated?.({ ...task, reviewState: result.reviewState } as Task);
addToast("Review refreshed", "success");
} catch (error) {
addToast(error instanceof Error ? error.message : "Failed to refresh review", "error");
} catch (refreshError) {
const message = refreshError instanceof Error ? refreshError.message : "Failed to refresh review";
setError(message);
addToast(message, "error");
} finally {
setRefreshing(false);
}
};
const onRevise = async () => {
try {
setError(null);
setRevising(true);
const result = await reviseTaskReviewItems(task.id, selected, projectId);
onTaskUpdated?.({ ...result.task, review: result.review } as Task);
onTaskUpdated?.({ ...result.task, reviewState: result.reviewState } as Task);
setSelected([]);
addToast("Queued same-task revision", "success");
} catch (error) {
addToast(error instanceof Error ? error.message : "Failed to queue revision", "error");
} catch (reviseError) {
const message = reviseError instanceof Error ? reviseError.message : "Failed to queue revision";
setError(message);
addToast(message, "error");
} finally {
setRevising(false);
}
};
@@ -53,33 +76,53 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
<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>
<span className={`task-review-tab__decision task-review-tab__decision--${decisionLabel}`}>{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>
<button className="btn btn-sm" onClick={onRefresh} disabled={refreshing || !isPrMode}>{refreshing ? "Refreshing…" : "Refresh"}</button>
<button className="btn btn-primary btn-sm" disabled={!canRevise || !isPrMode} onClick={onRevise}>{revising ? "Queueing…" : "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>
<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>
) : null}
{isPrMode && 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 ? (
<ul className="task-review-tab__blockers">
{review.summary.blockingReasons.map((reason) => <li key={reason}>{reason}</li>)}
</ul>
) : null}
{isPrMode && review?.items?.length ? (
<ul className="task-review-tab__list">
{review.items.map((item) => {
const status = review?.addressing.find((record) => record.itemId === item.id)?.status ?? "queued";
return (
<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.path ? `${item.path}: ` : ""}{item.body}</span>
<span className={`task-review-tab__status task-review-tab__status--${status}`}>{status}</span>
</label>
</li>
);
})}
</ul>
) : isPrMode ? (
<div className="task-review-tab__empty">No review items yet.</div>
)}
) : null}
</div>
);
}

View File

@@ -307,6 +307,41 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Comments")).toBeTruthy();
});
it("shows non-PR review shell message in Review tab", () => {
render(
<TaskDetailModal
task={makeTask({ reviewState: { source: "reviewer-agent", items: [], addressing: [] } })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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();
});
it("shows PR review decision details in Review tab", () => {
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: [] } })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Review" }));
expect(screen.getByText("CHANGES_REQUESTED")).toBeTruthy();
expect(screen.getByText("changes requested review is active")).toBeTruthy();
});
describe("inline execution mode toggle", () => {
it("renders standard mode as an unpressed toggle", () => {
render(

View File

@@ -19,14 +19,14 @@ describe("TaskReviewTab", () => {
});
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();
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(screen.getByRole("button", { name: "Request revision" })).toBeDisabled();
});
it("calls refresh endpoint", async () => {
const task = makeTask({ review: { mode: "direct", source: "reviewer-agent", decision: "pending", items: [] } });
apiMocks.refreshTaskReview.mockResolvedValue({ review: task.review, automationStatus: null });
const task = makeTask({ reviewState: { source: "pull-request", summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [] }, items: [], addressing: [] } });
apiMocks.refreshTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null });
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
expect(apiMocks.refreshTaskReview).toHaveBeenCalledWith(task.id, undefined);
@@ -34,51 +34,45 @@ describe("TaskReviewTab", () => {
it("renders PR decision and status modifiers", () => {
const task = makeTask({
review: {
mode: "pull-request",
source: "github-pr",
decision: "changes-requested",
summary: "Needs updates",
reviewState: {
source: "pull-request",
summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [], blockingReasons: [], checks: [] },
items: [
{
id: "ri-1",
source: "github-pr",
status: "failed",
summary: "Fix null handling",
body: "Fix null handling",
author: { login: "reviewer" },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
addressing: [{ itemId: "ri-1", status: "failed", selectedAt: new Date().toISOString() }],
},
});
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
expect(screen.getByText("changes requested")).toBeInTheDocument();
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",
reviewState: {
source: "pull-request",
summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [], blockingReasons: [], checks: [] },
items: [
{
id: "ri-1",
source: "github-pr",
status: "queued",
summary: "Fix null handling",
body: "Fix null handling",
author: { login: "reviewer" },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
addressing: [{ itemId: "ri-1", status: "queued", selectedAt: new Date().toISOString() }],
},
});
apiMocks.reviseTaskReviewItems.mockResolvedValue({ task, review: task.review });
apiMocks.refreshTaskReview.mockResolvedValue({ review: task.review, automationStatus: null });
apiMocks.reviseTaskReviewItems.mockResolvedValue({ task, reviewState: task.reviewState });
apiMocks.refreshTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null });
render(<TaskReviewTab task={task} addToast={vi.fn()} />);

View File

@@ -990,6 +990,32 @@ describe("GitHubClient", () => {
});
});
describe("getPrReviewSnapshot", () => {
it("normalizes reviews/comments into review-state items and summary", async () => {
mockRunGhJsonAsync
.mockResolvedValueOnce({
reviewDecision: "CHANGES_REQUESTED",
reviews: [{ id: "r1", state: "CHANGES_REQUESTED", body: "please fix", submittedAt: "2024-01-01T00:00:00Z", author: { login: "octocat" }, url: "https://github.com/owner/repo/pull/1#review-r1" }],
comments: [{ id: "c1", body: "nit", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:01Z", author: { login: "reviewer" }, url: "https://github.com/owner/repo/pull/1#issuecomment-c1" }],
})
.mockResolvedValueOnce({
number: 1,
url: "https://github.com/owner/repo/pull/1",
title: "PR",
state: "OPEN",
reviewDecision: "CHANGES_REQUESTED",
baseRefName: "main",
headRefName: "fn/fn-1",
})
.mockResolvedValueOnce([]);
const snapshot = await client.getPrReviewSnapshot("owner", "repo", 1);
expect(snapshot.items).toHaveLength(2);
expect(snapshot.summary?.reviewDecision).toBe("CHANGES_REQUESTED");
expect(snapshot.summary?.reviewers[0]).toEqual(expect.objectContaining({ login: "octocat", state: "CHANGES_REQUESTED" }));
});
});
describe("mergePr", () => {
it("merges a PR with gh CLI and refetches merged status", async () => {
mockRunGh.mockReturnValue("Merged pull request");

View File

@@ -2027,6 +2027,38 @@ describe("GET /tasks/:id/file-diffs", () => {
});
});
describe("GET /tasks/:id/review", () => {
let store: ReturnType<typeof createMockStore>;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns cached reviewState payload", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
reviewState: { source: "reviewer-agent", items: [], addressing: [] },
});
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
expect(res.status).toBe(200);
expect(res.body.reviewState.source).toBe("reviewer-agent");
});
it("returns 404 when task is missing", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-404/review");
expect(res.status).toBe(404);
});
});
describe("POST /tasks/:id/review/refresh", () => {
let store: TaskStore;
@@ -2057,7 +2089,12 @@ describe("POST /tasks/:id/review/refresh", () => {
vi.spyOn(GitHubClient.prototype, "getPrReviewSnapshot").mockResolvedValue({
decision: "CHANGES_REQUESTED",
checks: [],
summary: "needs work",
summary: {
reviewDecision: "CHANGES_REQUESTED",
reviewers: [],
blockingReasons: ["needs work"],
checks: [],
},
items: [],
});
@@ -2068,18 +2105,17 @@ describe("POST /tasks/:id/review/refresh", () => {
expect(res.status).toBe(200);
expect((store.updateTask as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ review: expect.objectContaining({ mode: "pull-request" }) }),
expect.objectContaining({ reviewState: expect.objectContaining({ source: "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",
reviewState: {
source: "reviewer-agent",
decision: "pending",
items: [],
addressing: [],
},
});
@@ -2089,7 +2125,7 @@ describe("POST /tasks/:id/review/refresh", () => {
expect(res.status).toBe(200);
expect((store.updateTask as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
expect(res.body.review.mode).toBe("direct");
expect(res.body.reviewState.source).toBe("reviewer-agent");
});
});

View File

@@ -2138,7 +2138,7 @@ describe("POST /subtasks/*", () => {
});
describe("POST /tasks/:id/review/revise", () => {
describe("POST /tasks/:id/review/address", () => {
let store: TaskStore;
beforeEach(() => {
@@ -2158,20 +2158,18 @@ describe("POST /tasks/:id/review/revise", () => {
id: "FN-001",
column: "in-review",
steps: [],
review: {
mode: "direct",
reviewState: {
source: "reviewer-agent",
decision: "changes-requested",
items: [
{
id: "ri-1",
source: "reviewer-agent",
status: "failed",
summary: "Fix tests",
body: "Fix tests",
author: { login: "reviewer" },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
addressing: [],
},
};
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(taskWithReview);
@@ -2180,7 +2178,7 @@ describe("POST /tasks/:id/review/revise", () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/FN-001/review/revise",
"/api/tasks/FN-001/review/address",
JSON.stringify({ itemIds: ["ri-1"] }),
{ "Content-Type": "application/json" },
);
@@ -2188,7 +2186,7 @@ describe("POST /tasks/:id/review/revise", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ review: expect.objectContaining({ selectedItemIds: ["ri-1"] }) }),
expect.objectContaining({ reviewState: expect.objectContaining({ addressing: expect.arrayContaining([expect.objectContaining({ itemId: "ri-1", status: "queued" })]) }) }),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true });
});

View File

@@ -87,11 +87,33 @@ export interface PrReviewItem {
updatedAt: string;
}
export interface PrReviewStateItem {
id: string;
threadId?: string;
githubCommentId?: number;
path?: string;
diffSide?: string;
body: string;
author: { login: string };
createdAt: string;
updatedAt?: string;
state?: string;
htmlUrl?: string;
isResolved?: boolean;
}
export interface PrReviewSummary {
reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
reviewers: Array<{ login: string; state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "PENDING"; submittedAt?: string }>;
blockingReasons: string[];
checks: PrCheckStatus[];
}
export interface PrReviewSnapshot {
decision: ReviewDecision;
checks: PrCheckStatus[];
items: PrReviewItem[];
summary?: string;
items: PrReviewStateItem[];
summary?: PrReviewSummary;
}
export interface PrMergeStatus {
@@ -564,31 +586,29 @@ export class GitHubClient {
"reviewDecision,reviews,comments",
]);
const checks = (await this.getPrMergeStatus(resolvedOwner, resolvedRepo, number)).checks;
const commentItems: PrReviewItem[] = (pr.comments ?? []).map((comment) => ({
const mergeStatus = await this.getPrMergeStatus(resolvedOwner, resolvedRepo, number);
const checks = mergeStatus.checks;
const commentItems: PrReviewStateItem[] = (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"}`,
githubCommentId: Number.parseInt(comment.id, 10),
body: comment.body,
reviewer: comment.author?.login ?? undefined,
commentUrl: comment.url,
author: { login: comment.author?.login ?? "reviewer" },
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
htmlUrl: comment.url,
state: "COMMENTED",
}));
const reviewItems: PrReviewItem[] = (pr.reviews ?? []).map((review) => {
const reviewItems: PrReviewStateItem[] = (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,
body: review.body ?? `Review ${review.state}`,
author: { login: review.author?.login ?? "reviewer" },
createdAt,
updatedAt: createdAt,
htmlUrl: review.url ?? undefined,
state: review.state,
};
});
@@ -596,7 +616,16 @@ export class GitHubClient {
decision: pr.reviewDecision ?? null,
checks,
items: [...reviewItems, ...commentItems],
summary: `PR #${number} has ${reviewItems.length} review(s) and ${commentItems.length} comment(s).`,
summary: {
reviewDecision: pr.reviewDecision ?? null,
reviewers: (pr.reviews ?? []).map((review) => ({
login: review.author?.login ?? "reviewer",
state: review.state === "APPROVED" || review.state === "CHANGES_REQUESTED" || review.state === "COMMENTED" || review.state === "PENDING" ? review.state : "COMMENTED",
submittedAt: review.submittedAt ?? undefined,
})),
blockingReasons: mergeStatus.blockingReasons,
checks,
},
};
}

View File

@@ -1,6 +1,11 @@
import { Router, type Request, type Response } from "express";
import { createRequire } from "node:module";
import { getCreateAiSessionFactory, type PluginContext, type PluginRouteDefinition, type TaskStore } from "@fusion/core";
import { createRoadmapPluginRoutes } from "../../../plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js";
const require = createRequire(import.meta.url);
const { createRoadmapPluginRoutes } = require("../../../plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js") as {
createRoadmapPluginRoutes: () => PluginRouteDefinition[];
};
function isRouteResponse(value: unknown): value is { status: number; body?: unknown } {
return (

View File

@@ -1,15 +1,18 @@
export {
FEATURE_SUGGESTION_SYSTEM_PROMPT,
MILESTONE_SUGGESTION_SYSTEM_PROMPT,
ParseError,
ServiceUnavailableError,
SUGGESTION_TIMEOUT_MS,
ValidationError,
__resetSuggestionState,
__setCreateAiSessionFactory,
__setCreateFnAgent,
generateFeatureSuggestions,
generateMilestoneSuggestions,
validateFeatureSuggestionInput,
validateSuggestionInput,
} from "../../../plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const roadmapSuggestions = require("../../../plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js") as Record<string, unknown>;
export const FEATURE_SUGGESTION_SYSTEM_PROMPT = roadmapSuggestions.FEATURE_SUGGESTION_SYSTEM_PROMPT as string;
export const MILESTONE_SUGGESTION_SYSTEM_PROMPT = roadmapSuggestions.MILESTONE_SUGGESTION_SYSTEM_PROMPT as string;
export const ParseError = roadmapSuggestions.ParseError as new (message: string) => Error;
export const ServiceUnavailableError = roadmapSuggestions.ServiceUnavailableError as new (message: string) => Error;
export const SUGGESTION_TIMEOUT_MS = roadmapSuggestions.SUGGESTION_TIMEOUT_MS as number;
export const ValidationError = roadmapSuggestions.ValidationError as new (message: string) => Error;
export const __resetSuggestionState = roadmapSuggestions.__resetSuggestionState as () => void;
export const __setCreateAiSessionFactory = roadmapSuggestions.__setCreateAiSessionFactory as (factory: unknown) => void;
export const __setCreateFnAgent = roadmapSuggestions.__setCreateFnAgent as (factory: unknown) => void;
export const generateFeatureSuggestions = roadmapSuggestions.generateFeatureSuggestions as (...args: unknown[]) => Promise<unknown>;
export const generateMilestoneSuggestions = roadmapSuggestions.generateMilestoneSuggestions as (...args: unknown[]) => Promise<unknown>;
export const validateFeatureSuggestionInput = roadmapSuggestions.validateFeatureSuggestionInput as (input: unknown) => void;
export const validateSuggestionInput = roadmapSuggestions.validateSuggestionInput as (input: unknown) => void;

View File

@@ -3124,6 +3124,20 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
});
router.get("/tasks/:id/review", async (req, res) => {
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 });
} 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/review/refresh
* Refresh normalized review payload for task Review tab.
@@ -3133,7 +3147,8 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
let review = task.review;
let reviewState = task.reviewState;
const now = new Date().toISOString();
if (task.prInfo) {
const badgeParsed = parseBadgeUrl(task.prInfo.url);
@@ -3145,35 +3160,29 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
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",
const previousAddressing = task.reviewState?.addressing ?? [];
const availableIds = new Set(snapshot.items.map((item) => item.id));
const addressing = previousAddressing.map((record) => availableIds.has(record.itemId) ? record : { ...record, stale: true });
reviewState = {
source: "pull-request",
lastRefreshedAt: now,
summary: snapshot.summary,
latestRefreshAt: new Date().toISOString(),
selectedItemIds: task.review?.selectedItemIds ?? [],
items: snapshot.items,
addressing,
};
} 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 ?? [],
reviewState = {
source: task.reviewState?.source ?? "reviewer-agent",
lastRefreshedAt: now,
summary: task.reviewState?.summary,
items: task.reviewState?.items ?? [],
addressing: task.reviewState?.addressing ?? [],
};
}
await scopedStore.updateTask(task.id, { review });
res.json({ review, automationStatus: task.status ?? null });
await scopedStore.updateTask(task.id, { reviewState });
res.json({ reviewState, automationStatus: task.status ?? null });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
if ((err as NodeJS.ErrnoException).code === "ENOENT") {

View File

@@ -1771,7 +1771,7 @@ 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) => {
router.post("/tasks/:id/review/address", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
@@ -1782,32 +1782,31 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
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");
if (!task.reviewState) {
throw badRequest("Task has no reviewState 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 selectedItems = task.reviewState.items.filter((item) => selectedSet.has(item.id));
for (const item of selectedItems) {
const excerpt = item.body.length > 140 ? `${item.body.slice(0, 140)}…` : item.body;
selectedSummaries.push(`- [${item.id}] @${item.author.login}${item.path ? ` (${item.path})` : ""}: ${excerpt}`);
}
const review = {
...task.review,
selectedItemIds: itemIds,
items: updatedItems,
const nextAddressing = [
...task.reviewState.addressing.filter((record) => !selectedSet.has(record.itemId)),
...itemIds.map((itemId: string) => ({ itemId, status: "queued" as const, selectedAt: now })),
];
const reviewState = {
...task.reviewState,
addressing: nextAddressing,
};
await scopedStore.updateTask(task.id, {
review,
reviewState,
status: null,
error: null,
sessionFile: null,
@@ -1821,7 +1820,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
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 sectionContent = `${sectionHeader}\n\n**PR Review Revision Request**\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)
@@ -1835,7 +1834,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (selectedSummaries.length > 0) {
await scopedStore.addTaskComment(
task.id,
`Review revision requested for selected items:\n\n${selectedSummaries.join("\n")}`,
`**PR Review Revision Request**\n\nReview revision requested for selected items:\n\n${selectedSummaries.join("\n")}`,
"user",
);
}
@@ -1850,7 +1849,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
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 });
res.json({ task: moved, reviewState });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;