feat(FN-3279): refresh review state on manual trigger and restore roadmap e

Adds review tab refresh UX, metadata, and API (FN-3279) alongside permanent-agent action gating coordination tools (FN-3724), and introduces two new workspace plugin packages: `fusion-plugin-even-cards` (board/task cards with auth and board-routes) and `fusion-plugin-even-realities-glasses` (cards,

Fusion-Task-Id: FN-3279
This commit is contained in:
Fusion
2026-05-08 12:54:47 -07:00
committed by gsxdsm
parent fe5bf04b45
commit 3c6fd99584
13 changed files with 628 additions and 43 deletions

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapCreateInput, RoadmapUpdateInput, RoadmapMilestoneCreateInput, RoadmapMilestoneUpdateInput, RoadmapFeatureCreateInput, RoadmapFeatureUpdateInput, RoadmapWithHierarchy, RoadmapExportBundle, RoadmapMissionPlanningHandoff, RoadmapFeatureTaskPlanningHandoff } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export * from "./mesh-replication-protocol.js";
export * from "./mesh-task-replication.js";

View File

@@ -1,3 +1,4 @@
/** Valid thinking effort levels for AI agent sessions, controlling the cost/quality tradeoff of reasoning. */
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const;
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
@@ -578,6 +579,105 @@ export interface IssueInfo {
* This contract stores source identity so the originating issue can be
* re-associated even when live status is unavailable.
*/
export interface RoadmapFeature {
id: string;
roadmapId: string;
milestoneId: string;
title: string;
description?: string;
status: "planned" | "in-progress" | "done";
priority?: "low" | "medium" | "high";
notes?: string;
owner?: string;
targetDate?: string;
effort?: string;
orderIndex?: number;
createdAt: string;
updatedAt: string;
}
export interface RoadmapMilestone {
id: string;
roadmapId: string;
title: string;
description?: string;
status: "planned" | "in-progress" | "done";
targetDate?: string;
orderIndex?: number;
createdAt: string;
updatedAt: string;
features?: RoadmapFeature[];
}
export interface Roadmap {
id: string;
title: string;
description?: string;
status: "planned" | "in-progress" | "done";
targetDate?: string;
owner?: string;
createdAt: string;
updatedAt: string;
}
export interface RoadmapCreateInput {
title: string;
description?: string;
status?: Roadmap["status"];
targetDate?: string;
owner?: string;
}
export type RoadmapUpdateInput = Partial<RoadmapCreateInput>;
export interface RoadmapMilestoneCreateInput {
title: string;
description?: string;
status?: RoadmapMilestone["status"];
targetDate?: string;
}
export type RoadmapMilestoneUpdateInput = Partial<RoadmapMilestoneCreateInput>;
export interface RoadmapFeatureCreateInput {
title: string;
description?: string;
status?: RoadmapFeature["status"];
priority?: RoadmapFeature["priority"];
notes?: string;
owner?: string;
targetDate?: string;
effort?: string;
}
export type RoadmapFeatureUpdateInput = Partial<RoadmapFeatureCreateInput>;
export interface RoadmapWithHierarchy extends Roadmap {
milestones: Array<RoadmapMilestone & { features: RoadmapFeature[] }>;
}
export interface RoadmapExportBundle {
roadmap: RoadmapWithHierarchy;
exportedAt: string;
formatVersion?: number;
}
export interface RoadmapMissionPlanningHandoff {
roadmapId: string;
roadmapTitle?: string;
missionTitle?: string;
summary: string;
description?: string;
}
export interface RoadmapFeatureTaskPlanningHandoff {
roadmapId: string;
featureId: string;
featureTitle?: string;
taskTitle?: string;
taskDescription: string;
}
export interface TaskSourceIssue {
/** Issue provider key (for example: "github", "gitlab", "jira"). */
provider: string;
@@ -804,9 +904,15 @@ export interface ReviewerTaskReviewSummary {
summary?: string;
}
export type TaskReviewRefreshSource = "manual" | "auto" | "initial-load";
export type TaskReviewRefreshStatus = "idle" | "refreshing" | "ready" | "error";
export interface TaskReviewState {
source: "pull-request" | "reviewer-agent";
lastRefreshedAt?: string;
refreshSource?: TaskReviewRefreshSource;
refreshStatus?: TaskReviewRefreshStatus;
refreshError?: string;
summary?: PrTaskReviewSummary | ReviewerTaskReviewSummary;
items: TaskReviewStateItem[];
addressing: ReviewAddressingRecord[];

View File

@@ -258,11 +258,13 @@ export interface TaskReviewResponse {
reviewState: NonNullable<TaskDetail["reviewState"]>;
automationStatus: string | null;
emptyMessage?: string | null;
prInfo?: TaskDetail["prInfo"];
}
export interface RefreshTaskReviewResponse {
reviewState: NonNullable<TaskDetail["reviewState"]>;
automationStatus: string | null;
prInfo?: TaskDetail["prInfo"];
}
export interface ReviseTaskReviewResponse {

View File

@@ -114,6 +114,16 @@
color: var(--text-muted);
}
.task-review-tab__refresh-meta {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.task-review-tab__refresh-meta .status-dot {
flex-shrink: 0;
}
.task-review-tab__error {
color: var(--color-error);
}
@@ -145,4 +155,8 @@
.task-review-tab__actions .btn {
flex: 1;
}
.task-review-tab__refresh-meta {
align-items: flex-start;
}
}

View File

@@ -20,6 +20,12 @@ function formatTimestamp(value?: string): string {
return new Date(value).toLocaleString();
}
function formatRefreshSource(source?: "manual" | "auto" | "initial-load"): string {
if (source === "manual") return "Manual";
if (source === "auto") return "Background";
return "Initial load";
}
type ReviewItem = NonNullable<TaskDetail["reviewState"]>["items"][number];
function getItemStatus(item: ReviewItem): "queued" | "in-progress" | "addressed" | "failed" {
@@ -84,6 +90,20 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
? (review.summary as { reviewDecision?: string } | undefined)?.reviewDecision
: (review.summary as { verdict?: string } | undefined)?.verdict;
const refreshStatus = refreshing ? "refreshing" : (review?.refreshStatus ?? "ready");
const refreshToneClass =
refreshStatus === "error"
? "status-dot status-dot--error"
: refreshStatus === "refreshing"
? "status-dot status-dot--pending"
: "status-dot status-dot--online";
const refreshLabel =
refreshStatus === "error"
? "Refresh failed"
: refreshStatus === "refreshing"
? "Refreshing"
: "Up to date";
const toggleSelected = (id: string) => {
setSelected((prev) => (prev.includes(id) ? prev.filter((value) => value !== id) : [...prev, id]));
};
@@ -94,11 +114,19 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
setRefreshing(true);
const result = await refreshTaskReview(task.id, projectId);
setReview(result.reviewState);
onTaskUpdated?.({ ...task, reviewState: result.reviewState } as Task);
onTaskUpdated?.({ ...task, reviewState: result.reviewState, prInfo: result.prInfo ?? task.prInfo } as Task);
if (result.reviewState.refreshStatus === "error") {
const refreshMessage = result.reviewState.refreshError ?? "Failed to refresh review data.";
setError(refreshMessage);
addToast(refreshMessage, "error");
return;
}
setError(null);
addToast("Review refreshed", "success");
} catch {
setError(REVIEW_LOAD_ERROR_MESSAGE);
addToast(REVIEW_LOAD_ERROR_MESSAGE, "error");
} catch (refreshError) {
const message = refreshError instanceof Error ? refreshError.message : REVIEW_LOAD_ERROR_MESSAGE;
setError(message);
addToast(message, "error");
} finally {
setRefreshing(false);
}
@@ -132,11 +160,14 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
) : null}
</div>
<div className="task-review-tab__actions">
<button className="btn btn-sm" onClick={onRefresh} disabled={refreshing || !isPrMode}>{refreshing ? "Refreshing…" : "Refresh"}</button>
<button className="btn btn-sm" onClick={onRefresh} disabled={refreshing || loading}>{refreshing ? "Refreshing…" : "Refresh"}</button>
<button className="btn btn-primary btn-sm" disabled={!canRevise || !isPrMode} onClick={onRevise}>{revising ? "Queueing…" : "Request revision"}</button>
</div>
</div>
<div className="task-review-tab__meta">Last refreshed: {formatTimestamp(review?.lastRefreshedAt)}</div>
<div className="task-review-tab__meta task-review-tab__refresh-meta" aria-live="polite">
<span className={refreshToneClass} aria-hidden="true" />
<span>{refreshLabel} · Last refreshed: {formatTimestamp(review?.lastRefreshedAt)} · {formatRefreshSource(review?.refreshSource)}</span>
</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 ? (

View File

@@ -325,6 +325,60 @@ describe("TaskDetailModal", () => {
expect(await screen.findByText("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.")).toBeTruthy();
});
it("keeps Comments tab available after Review refresh", async () => {
vi.mocked(dashboardApi.fetchTaskReview).mockResolvedValueOnce({
reviewState: {
source: "pull-request",
summary: {
reviewDecision: "REVIEW_REQUIRED",
reviewers: [],
blockingReasons: [],
checks: [],
},
items: [],
addressing: [],
},
automationStatus: null,
emptyMessage: null,
});
vi.mocked(dashboardApi.refreshTaskReview).mockResolvedValueOnce({
reviewState: {
source: "pull-request",
summary: {
reviewDecision: "APPROVED",
reviewers: [{ login: "octocat", state: "APPROVED" }],
blockingReasons: [],
checks: [],
},
items: [],
addressing: [],
refreshStatus: "ready",
},
automationStatus: null,
});
render(
<TaskDetailModal
task={makeTask({ reviewState: { source: "pull-request", summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [] }, items: [], addressing: [] } })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Review" }));
fireEvent.click(await screen.findByRole("button", { name: "Refresh" }));
expect(await screen.findByText("APPROVED")).toBeTruthy();
const commentsTab = screen.getByRole("button", { name: "Comments" });
expect(commentsTab).toBeInTheDocument();
fireEvent.click(commentsTab);
expect(screen.getByRole("heading", { name: "Comments" })).toBeInTheDocument();
});
it("shows PR review decision details in Review tab", async () => {
vi.mocked(dashboardApi.fetchTaskReview).mockResolvedValueOnce({
reviewState: {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { TaskReviewTab } from "../TaskReviewTab";
import { makeTask } from "./TaskDetailModal.test-helpers";
@@ -32,13 +32,67 @@ describe("TaskReviewTab", () => {
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: [] } });
it("calls refresh endpoint and updates rendered PR content in place", async () => {
const addToast = vi.fn();
const task = makeTask({ reviewState: { source: "pull-request", summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [] }, items: [], addressing: [], refreshStatus: "ready" } });
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()} />);
apiMocks.refreshTaskReview.mockResolvedValue({
reviewState: {
source: "pull-request",
summary: { reviewDecision: "APPROVED", reviewers: [{ login: "octocat", state: "APPROVED" }], blockingReasons: [], checks: [] },
items: [{ id: "ri-2", body: "Looks good", author: { login: "octocat" }, createdAt: new Date().toISOString() }],
addressing: [],
refreshStatus: "ready",
},
automationStatus: null,
});
render(<TaskReviewTab task={task} addToast={addToast} />);
fireEvent.click(await screen.findByRole("button", { name: "Refresh" }));
expect(apiMocks.refreshTaskReview).toHaveBeenCalledWith(task.id, undefined);
expect(await screen.findByText("APPROVED")).toBeInTheDocument();
expect(screen.getByText("Looks good")).toBeInTheDocument();
expect(addToast).toHaveBeenCalledWith("Review refreshed", "success");
});
it("shows in-flight refresh state while refresh is pending", async () => {
let resolveRefresh: ((value: unknown) => void) | undefined;
const refreshPromise = new Promise((resolve) => {
resolveRefresh = resolve;
});
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.mockReturnValue(refreshPromise as Promise<never>);
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: "Refresh" }));
expect(screen.getByRole("button", { name: "Refreshing…" })).toBeDisabled();
resolveRefresh?.({ reviewState: task.reviewState, automationStatus: null });
await waitFor(() => expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled());
});
it("shows scoped refresh error when refresh response reports error state", async () => {
const addToast = vi.fn();
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,
refreshStatus: "error",
refreshError: "GitHub rate limit reached",
},
automationStatus: null,
prInfo: task.prInfo,
});
render(<TaskReviewTab task={task} addToast={addToast} />);
fireEvent.click(await screen.findByRole("button", { name: "Refresh" }));
expect(await screen.findByText("GitHub rate limit reached")).toBeInTheDocument();
expect(addToast).toHaveBeenCalledWith("GitHub rate limit reached", "error");
});
it("renders PR decision and status modifiers", async () => {
@@ -93,6 +147,50 @@ describe("TaskReviewTab", () => {
expect(apiMocks.reviseTaskReviewItems).toHaveBeenCalledWith(task.id, ["ri-1"], undefined);
});
it("refreshes and updates direct-mode reviewer-agent content", async () => {
const addToast = vi.fn();
const task = makeTask();
apiMocks.fetchTaskReview.mockResolvedValue({
reviewState: {
source: "reviewer-agent",
summary: { summary: "No feedback" },
items: [],
addressing: [],
},
automationStatus: null,
emptyMessage: null,
});
apiMocks.refreshTaskReview.mockResolvedValue({
reviewState: {
source: "reviewer-agent",
summary: { verdict: "APPROVE", reviewType: "code", summary: "Ship it" },
items: [
{
id: "reviewer-code-2",
body: "## Code Review:\n\n### Verdict:\nAPPROVE",
author: { login: "reviewer-agent" },
createdAt: new Date().toISOString(),
reviewType: "code",
verdict: "APPROVE",
step: 3,
summary: "code review Step 3: APPROVE",
},
],
addressing: [],
refreshStatus: "ready",
},
automationStatus: null,
});
render(<TaskReviewTab task={task} addToast={addToast} />);
fireEvent.click(await screen.findByRole("button", { name: "Refresh" }));
expect((await screen.findAllByText("APPROVE")).length).toBeGreaterThan(0);
expect(screen.getByText("Step 3")).toBeInTheDocument();
expect(addToast).toHaveBeenCalledWith("Review refreshed", "success");
});
it("renders reviewer-agent entries in direct mode", async () => {
const task = makeTask();
apiMocks.fetchTaskReview.mockResolvedValue({

View File

@@ -1012,8 +1012,58 @@ describe("GitHubClient", () => {
const snapshot = await client.getPrReviewSnapshot("owner", "repo", 1);
expect(snapshot.items).toHaveLength(2);
expect(snapshot.summary?.reviewDecision).toBe("CHANGES_REQUESTED");
expect(snapshot.prInfo.number).toBe(1);
expect(snapshot.commentCount).toBe(1);
expect(snapshot.summary?.reviewers[0]).toEqual(expect.objectContaining({ login: "octocat", state: "CHANGES_REQUESTED" }));
});
it("falls back to API review details when gh fails and token is available", async () => {
mockRunGhJsonAsync.mockImplementation(() => {
throw new Error("gh down");
});
const clientWithToken = new GitHubClient("ghp_token");
const mockFetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
repository: {
pullRequest: {
reviewDecision: "APPROVED",
comments: { nodes: [{ id: "C_1", body: "lgtm", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:01Z", url: "https://example.com/c1", author: { login: "bot" } }] },
reviews: { nodes: [{ id: "R_1", state: "APPROVED", body: "good", submittedAt: "2024-01-01T00:00:00Z", url: "https://example.com/r1", author: { login: "reviewer" } }] },
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
repository: {
pullRequest: {
number: 1,
url: "https://github.com/owner/repo/pull/1",
title: "PR",
state: "OPEN",
reviewDecision: "APPROVED",
baseRefName: "main",
headRefName: "fn/fn-1",
comments: { totalCount: 1 },
commits: { nodes: [{ commit: { statusCheckRollup: { contexts: { nodes: [] } } } }] },
},
},
},
}),
});
global.fetch = mockFetch as any;
const snapshot = await clientWithToken.getPrReviewSnapshot("owner", "repo", 1);
expect(snapshot.summary?.reviewDecision).toBe("APPROVED");
expect(snapshot.items).toHaveLength(2);
vi.restoreAllMocks();
});
});
describe("mergePr", () => {

View File

@@ -2137,6 +2137,16 @@ describe("POST /tasks/:id/review/refresh", () => {
vi.spyOn(GitHubClient.prototype, "getPrReviewSnapshot").mockResolvedValue({
decision: "CHANGES_REQUESTED",
checks: [],
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "PR",
headBranch: "fusion/fn-1",
baseBranch: "main",
commentCount: 1,
},
commentCount: 1,
summary: {
reviewDecision: "CHANGES_REQUESTED",
reviewers: [],
@@ -2153,10 +2163,42 @@ describe("POST /tasks/:id/review/refresh", () => {
expect(res.status).toBe(200);
expect((store.updateTask as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ reviewState: expect.objectContaining({ source: "pull-request" }) }),
expect.objectContaining({
reviewState: expect.objectContaining({
source: "pull-request",
refreshSource: "manual",
refreshStatus: "ready",
lastRefreshedAt: expect.any(String),
}),
}),
);
});
it("returns scoped refresh error payload in PR mode when GitHub refresh fails", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "PR",
headBranch: "fusion/fn-1",
baseBranch: "main",
commentCount: 0,
},
reviewState: { source: "pull-request", items: [], addressing: [] },
});
vi.spyOn(GitHubClient.prototype, "getPrReviewSnapshot").mockRejectedValue(new Error("GitHub outage"));
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.reviewState.refreshStatus).toBe("error");
expect(res.body.reviewState.refreshError).toContain("GitHub outage");
});
it("refreshes direct-mode review payload without PR", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
@@ -2167,13 +2209,34 @@ describe("POST /tasks/:id/review/refresh", () => {
},
});
const getSnapshotSpy = vi.spyOn(GitHubClient.prototype, "getPrReviewSnapshot");
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect((store.updateTask as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
expect((store.updateTask as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({
reviewState: expect.objectContaining({
source: "reviewer-agent",
refreshSource: "manual",
refreshStatus: "ready",
lastRefreshedAt: expect.any(String),
}),
}),
);
expect(res.body.reviewState.source).toBe("reviewer-agent");
expect(getSnapshotSpy).not.toHaveBeenCalled();
});
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(), "POST", "/api/tasks/FN-404/review/refresh", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(404);
});
});

View File

@@ -114,6 +114,8 @@ export interface PrReviewSnapshot {
checks: PrCheckStatus[];
items: PrReviewStateItem[];
summary?: PrReviewSummary;
prInfo: PrInfo;
commentCount: number;
}
export interface PrMergeStatus {
@@ -181,6 +183,12 @@ interface GhPrViewJson {
reviews?: GhReviewJson[];
}
interface PrReviewDetails {
reviewDecision: ReviewDecision;
comments: GhPrViewJson["comments"];
reviews: GhReviewJson[];
}
interface GhPrListJson {
number: number;
url: string;
@@ -576,20 +584,12 @@ export class GitHubClient {
async getPrReviewSnapshot(owner: string | undefined, repo: string | undefined, number: number): Promise<PrReviewSnapshot> {
const { owner: resolvedOwner, repo: resolvedRepo } = this.resolveRepo(owner, repo);
const pr = await runGhJsonAsync<GhPrViewJson>([
"pr",
"view",
String(number),
"--repo",
`${resolvedOwner}/${resolvedRepo}`,
"--json",
"reviewDecision,reviews,comments",
]);
const details = await this.getPrReviewDetails(resolvedOwner, resolvedRepo, number);
const mergeStatus = await this.getPrMergeStatus(resolvedOwner, resolvedRepo, number);
const checks = mergeStatus.checks;
const commentItems: PrReviewStateItem[] = (pr.comments ?? []).map((comment) => ({
const commentItems: PrReviewStateItem[] = (details.comments ?? []).map((comment) => ({
id: `gh-comment-${comment.id}`,
threadId: `thread-comment-${comment.id}`,
githubCommentId: Number.parseInt(comment.id, 10),
body: comment.body,
author: { login: comment.author?.login ?? "reviewer" },
@@ -599,10 +599,11 @@ export class GitHubClient {
state: "COMMENTED",
}));
const reviewItems: PrReviewStateItem[] = (pr.reviews ?? []).map((review) => {
const reviewItems: PrReviewStateItem[] = (details.reviews ?? []).map((review) => {
const createdAt = review.submittedAt ?? new Date().toISOString();
return {
id: `gh-review-${review.id}`,
threadId: `thread-review-${review.id}`,
body: review.body ?? `Review ${review.state}`,
author: { login: review.author?.login ?? "reviewer" },
createdAt,
@@ -613,12 +614,14 @@ export class GitHubClient {
});
return {
decision: pr.reviewDecision ?? null,
decision: details.reviewDecision ?? null,
checks,
items: [...reviewItems, ...commentItems],
prInfo: mergeStatus.prInfo,
commentCount: commentItems.length,
summary: {
reviewDecision: pr.reviewDecision ?? null,
reviewers: (pr.reviews ?? []).map((review) => ({
reviewDecision: details.reviewDecision ?? null,
reviewers: (details.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,
@@ -629,6 +632,128 @@ export class GitHubClient {
};
}
private async getPrReviewDetails(owner: string, repo: string, number: number): Promise<PrReviewDetails> {
if (this.hasGhAuth()) {
try {
return await this.getPrReviewDetailsWithGh(owner, repo, number);
} catch (err) {
if (this.token) {
return this.getPrReviewDetailsWithApi(owner, repo, number);
}
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
return this.getPrReviewDetailsWithApi(owner, repo, number);
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
}
private async getPrReviewDetailsWithGh(owner: string, repo: string, number: number): Promise<PrReviewDetails> {
const pr = await runGhJsonAsync<GhPrViewJson>([
"pr",
"view",
String(number),
"--repo",
`${owner}/${repo}`,
"--json",
"reviewDecision,reviews,comments",
]);
return {
reviewDecision: pr.reviewDecision ?? null,
comments: pr.comments ?? [],
reviews: pr.reviews ?? [],
};
}
private async getPrReviewDetailsWithApi(owner: string, repo: string, number: number): Promise<PrReviewDetails> {
const response = await fetch(`${this.baseUrl}/graphql`, {
method: "POST",
headers: this.buildHeaders(),
body: JSON.stringify({
query: `query PullRequestReviewDetails($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
reviewDecision
comments(first: 100) {
nodes {
id
body
createdAt
updatedAt
url
author { login }
}
}
reviews(first: 100) {
nodes {
id
state
body
submittedAt
url
author { login }
}
}
}
}
}`,
variables: { owner, repo, number },
}),
});
const payload = await response.json() as {
data?: {
repository?: {
pullRequest?: {
reviewDecision?: ReviewDecision;
comments?: { nodes?: Array<{ id: string; body: string; createdAt: string; updatedAt: string; url: string; author?: { login?: string | null } | null } | null> };
reviews?: { nodes?: Array<{ id: string; state: string; body?: string | null; submittedAt?: string | null; url?: string | null; author?: { login?: string | null } | null } | null> };
};
};
};
errors?: Array<{ message: string }>;
};
if (!response.ok || payload.errors?.length) {
const message = payload.errors?.[0]?.message || response.statusText;
throw new Error(`GitHub API error: ${response.status} ${message}`);
}
const pr = payload.data?.repository?.pullRequest;
if (!pr) {
throw new Error(`PR #${number} not found in ${owner}/${repo}`);
}
return {
reviewDecision: pr.reviewDecision ?? null,
comments: (pr.comments?.nodes ?? []).flatMap((comment) => {
if (!comment) return [];
return [{
id: comment.id,
body: comment.body,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
url: comment.url,
author: { login: comment.author?.login ?? "reviewer" },
}];
}),
reviews: (pr.reviews?.nodes ?? []).flatMap((review) => {
if (!review) return [];
return [{
id: review.id,
state: review.state,
body: review.body,
submittedAt: review.submittedAt,
url: review.url,
author: { login: review.author?.login ?? "reviewer" },
}];
}),
};
}
async getPrMergeStatus(owner: string | undefined, repo: string | undefined, number: number): Promise<PrMergeStatus> {
if (this.hasGhAuth()) {
try {

View File

@@ -3241,10 +3241,13 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
items: [],
addressing: [],
};
reviewState.refreshStatus = reviewState.refreshStatus ?? "ready";
reviewState.refreshSource = reviewState.refreshSource ?? "initial-load";
res.json({
reviewState,
automationStatus: task.status ?? null,
emptyMessage: !hasPrReview && reviewState.items.length === 0 ? DIRECT_REVIEW_EMPTY_MESSAGE : null,
prInfo: task.prInfo,
});
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
@@ -3265,6 +3268,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const task = await scopedStore.getTask(req.params.id);
let reviewState = task.reviewState;
let nextPrInfo = task.prInfo;
const now = new Date().toISOString();
if (task.prInfo) {
@@ -3275,26 +3279,59 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
throw badRequest("Could not determine GitHub repository for PR review refresh");
}
const client = new GitHubClient();
const snapshot = await client.getPrReviewSnapshot(owner, repo, task.prInfo.number);
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 });
const client = new GitHubClient(githubToken);
try {
const snapshot = await client.getPrReviewSnapshot(owner, repo, task.prInfo.number);
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,
items: snapshot.items,
addressing,
};
reviewState = {
source: "pull-request",
lastRefreshedAt: now,
refreshSource: "manual",
refreshStatus: "ready",
refreshError: undefined,
summary: snapshot.summary,
items: snapshot.items,
addressing,
};
nextPrInfo = {
...task.prInfo,
...snapshot.prInfo,
commentCount: snapshot.commentCount,
lastCheckedAt: now,
};
} catch (refreshError) {
const message = refreshError instanceof Error ? refreshError.message : "Failed to refresh GitHub review data";
reviewState = {
source: "pull-request",
lastRefreshedAt: now,
refreshSource: "manual",
refreshStatus: "error",
refreshError: message,
summary: task.reviewState?.summary,
items: task.reviewState?.items ?? [],
addressing: task.reviewState?.addressing ?? [],
};
await scopedStore.updateTask(task.id, { reviewState });
res.json({ reviewState, automationStatus: task.status ?? null, prInfo: task.prInfo });
return;
}
} else {
reviewState = await buildDirectReviewState(task, scopedStore);
reviewState.lastRefreshedAt = now;
reviewState.refreshSource = "manual";
reviewState.refreshStatus = "ready";
reviewState.refreshError = undefined;
}
await scopedStore.updateTask(task.id, { reviewState });
res.json({ reviewState, automationStatus: task.status ?? null });
if (nextPrInfo) {
await scopedStore.updatePrInfo(task.id, nextPrInfo);
}
res.json({ reviewState, automationStatus: task.status ?? null, prInfo: nextPrInfo });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
if ((err as NodeJS.ErrnoException).code === "ENOENT") {