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:
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user