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:
@@ -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) {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()} />);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user