feat(FN-3283): add canonical task review data endpoints
Added canonical task review data endpoints (FN-3283) with new core types, expanded task workflow API routes, and updated GitHub integration (removing deprecated git-github routes in favor of consolidated github.ts). New API tests added for the task endpoints. Fusion-Task-Id: FN-3283
This commit is contained in:
@@ -1271,8 +1271,8 @@ Pull-request auto-merge tasks persist structured review metadata on the task as
|
|||||||
|
|
||||||
API flow:
|
API flow:
|
||||||
|
|
||||||
1. `GET /api/tasks/:id/review` returns cached `reviewState` for modal load.
|
1. `GET /api/tasks/:id/review` returns canonical `TaskReviewData` (`mode`, `refreshable`, `fetchedAt`, `summary`, `items[]`) for modal load.
|
||||||
2. `POST /api/tasks/:id/review/refresh` fetches latest GitHub PR review/review-comment state, updates `reviewState`, and preserves addressing history (items that disappear are retained as `stale: true`).
|
2. `POST /api/tasks/:id/review/refresh` returns the same `TaskReviewData` shape after re-fetching source data (GitHub PR mode or reviewer-agent direct mode).
|
||||||
3. `POST /api/tasks/:id/review/address` records selected review items as queued, appends a deterministic `**PR Review Revision Request**` steering comment payload, clears transient failure/session state, and requeues the same task to `todo` for same-task revision.
|
3. `POST /api/tasks/:id/review/address` records selected review items as queued, appends a deterministic `**PR Review Revision Request**` steering comment payload, clears transient failure/session state, and requeues the same task to `todo` for same-task revision.
|
||||||
|
|
||||||
UI contract boundary:
|
UI contract boundary:
|
||||||
|
|||||||
@@ -259,6 +259,8 @@ This file is the contract for execution and review.
|
|||||||
|
|
||||||
- **Task comments** (`fn task comment`) are general collaboration notes.
|
- **Task comments** (`fn task comment`) are general collaboration notes.
|
||||||
- **Review tab feedback** is dedicated actionable review input (PR review data in pull-request mode, reviewer-agent findings in direct/non-PR mode) used to request same-task revisions.
|
- **Review tab feedback** is dedicated actionable review input (PR review data in pull-request mode, reviewer-agent findings in direct/non-PR mode) used to request same-task revisions.
|
||||||
|
- Review data is served from task-scoped API endpoints: `GET /api/tasks/:id/review` and `POST /api/tasks/:id/review/refresh`.
|
||||||
|
- Both endpoints return one normalized contract (`TaskReviewData`) with stable per-item `itemId` values and `sourceMode` (`pull-request` or `reviewer-agent`) so Review UI and per-item progress can share one read model.
|
||||||
- The Review tab supports **manual refresh** without closing/reopening Task Detail:
|
- The Review tab supports **manual refresh** without closing/reopening Task Detail:
|
||||||
- Pull-request mode refreshes live GitHub-backed review decision/thread/comment state and updates PR metadata freshness.
|
- Pull-request mode refreshes live GitHub-backed review decision/thread/comment state and updates PR metadata freshness.
|
||||||
- Direct/non-PR mode refreshes normalized reviewer-agent feedback from persisted task review artifacts and does not call GitHub.
|
- Direct/non-PR mode refreshes normalized reviewer-agent feedback from persisted task review artifacts and does not call GitHub.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { 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 } from "./types.js";
|
||||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||||
|
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js";
|
||||||
export * from "./mesh-replication-protocol.js";
|
export * from "./mesh-replication-protocol.js";
|
||||||
export * from "./mesh-task-replication.js";
|
export * from "./mesh-task-replication.js";
|
||||||
export * from "./shared-mesh-state.js";
|
export * from "./shared-mesh-state.js";
|
||||||
|
|||||||
@@ -711,7 +711,7 @@ export type TaskReviewVerdict = "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE"
|
|||||||
export type TaskReviewerType = "plan" | "code";
|
export type TaskReviewerType = "plan" | "code";
|
||||||
export type TaskReviewItemStatus = "queued" | "in-progress" | "addressed" | "failed";
|
export type TaskReviewItemStatus = "queued" | "in-progress" | "addressed" | "failed";
|
||||||
|
|
||||||
export interface TaskReviewItem {
|
export interface LegacyTaskReviewItem {
|
||||||
id: string;
|
id: string;
|
||||||
source: TaskReviewSource;
|
source: TaskReviewSource;
|
||||||
status: TaskReviewItemStatus;
|
status: TaskReviewItemStatus;
|
||||||
@@ -734,7 +734,7 @@ export interface TaskReview {
|
|||||||
summary?: string;
|
summary?: string;
|
||||||
latestRefreshAt?: string;
|
latestRefreshAt?: string;
|
||||||
selectedItemIds?: string[];
|
selectedItemIds?: string[];
|
||||||
items: TaskReviewItem[];
|
items: LegacyTaskReviewItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PrCheckState =
|
export type PrCheckState =
|
||||||
@@ -838,6 +838,43 @@ export interface TaskReviewState {
|
|||||||
addressing: ReviewAddressingRecord[];
|
addressing: ReviewAddressingRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskReviewSummary {
|
||||||
|
reviewDecision?: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
|
||||||
|
reviewers?: PrTaskReviewSummaryReviewer[];
|
||||||
|
blockingReasons?: string[];
|
||||||
|
checks?: PrCheckStatus[];
|
||||||
|
verdict?: TaskReviewVerdict;
|
||||||
|
reviewType?: TaskReviewerType;
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskReviewDataItem {
|
||||||
|
itemId: string;
|
||||||
|
sourceMode: "pull-request" | "reviewer-agent";
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
author: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
url?: string;
|
||||||
|
filePath?: string;
|
||||||
|
line?: number;
|
||||||
|
threadId?: string;
|
||||||
|
reviewState?: string | null;
|
||||||
|
isResolved?: boolean;
|
||||||
|
progressStatus?: "queued" | "in-progress" | "addressed" | "failed" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskReviewItem = TaskReviewDataItem;
|
||||||
|
|
||||||
|
export interface TaskReviewData {
|
||||||
|
mode: "pull-request" | "reviewer-agent";
|
||||||
|
refreshable: boolean;
|
||||||
|
fetchedAt: string | null;
|
||||||
|
summary: TaskReviewSummary | null;
|
||||||
|
items: TaskReviewItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskDocument {
|
export interface TaskDocument {
|
||||||
/** UUID primary key */
|
/** UUID primary key */
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ import {
|
|||||||
fetchMemoryBackendStatus,
|
fetchMemoryBackendStatus,
|
||||||
fetchPluginDashboardViews,
|
fetchPluginDashboardViews,
|
||||||
fetchPluginUiSlots,
|
fetchPluginUiSlots,
|
||||||
|
fetchTaskReviewData,
|
||||||
|
refreshTaskReviewData,
|
||||||
type ProjectInfo,
|
type ProjectInfo,
|
||||||
type ProjectHealth,
|
type ProjectHealth,
|
||||||
type ActivityFeedEntry,
|
type ActivityFeedEntry,
|
||||||
@@ -1071,3 +1073,27 @@ describe("batchUpdateTaskModels", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("task review data api wrappers", () => {
|
||||||
|
it("fetchTaskReviewData calls task review endpoint", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||||
|
mockFetchResponse(true, { mode: "reviewer-agent", refreshable: true, fetchedAt: null, summary: null, items: [] })
|
||||||
|
) as unknown as typeof fetch;
|
||||||
|
await fetchTaskReviewData("FN-123", "proj-1");
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/tasks/FN-123/review?projectId=proj-1",
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshTaskReviewData posts to refresh endpoint", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||||
|
mockFetchResponse(true, { mode: "pull-request", refreshable: true, fetchedAt: "2026-05-01T00:00:00.000Z", summary: null, items: [] })
|
||||||
|
) as unknown as typeof fetch;
|
||||||
|
await refreshTaskReviewData("FN-123");
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/tasks/FN-123/review/refresh",
|
||||||
|
expect.objectContaining({ method: "POST" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
Task,
|
Task,
|
||||||
TaskDetail,
|
TaskDetail,
|
||||||
|
TaskReviewData,
|
||||||
TaskAttachment,
|
TaskAttachment,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TaskCreateInput,
|
TaskCreateInput,
|
||||||
@@ -5114,14 +5115,54 @@ export function acceptTaskReview(taskId: string, projectId?: string): Promise<Ta
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapTaskReviewDataToLegacy(data: TaskReviewData): TaskReviewResponse {
|
||||||
|
return {
|
||||||
|
reviewState: {
|
||||||
|
source: data.mode,
|
||||||
|
summary: data.summary ?? undefined,
|
||||||
|
items: data.items.map((item) => ({
|
||||||
|
id: item.itemId,
|
||||||
|
body: item.body,
|
||||||
|
author: { login: item.author },
|
||||||
|
createdAt: item.createdAt ?? new Date(0).toISOString(),
|
||||||
|
updatedAt: item.updatedAt ?? undefined,
|
||||||
|
path: item.filePath,
|
||||||
|
threadId: item.threadId,
|
||||||
|
htmlUrl: item.url,
|
||||||
|
state: item.reviewState ?? undefined,
|
||||||
|
isResolved: item.isResolved,
|
||||||
|
})),
|
||||||
|
addressing: [],
|
||||||
|
lastRefreshedAt: data.fetchedAt ?? undefined,
|
||||||
|
refreshStatus: "ready",
|
||||||
|
refreshSource: "initial-load",
|
||||||
|
},
|
||||||
|
automationStatus: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetch normalized task review data (PR mode or direct mode) */
|
/** Fetch normalized task review data (PR mode or direct mode) */
|
||||||
export function fetchTaskReview(taskId: string, projectId?: string): Promise<TaskReviewResponse> {
|
export async function fetchTaskReview(taskId: string, projectId?: string): Promise<TaskReviewResponse> {
|
||||||
return api<TaskReviewResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review`, projectId));
|
const data = await api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review`, projectId));
|
||||||
|
return mapTaskReviewDataToLegacy(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch canonical review payload for future review-tab rendering. */
|
||||||
|
export function fetchTaskReviewData(taskId: string, projectId?: string): Promise<TaskReviewData> {
|
||||||
|
return api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review`, projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Refresh normalized task review data (PR mode or direct mode) */
|
/** Refresh normalized task review data (PR mode or direct mode) */
|
||||||
export function refreshTaskReview(taskId: string, projectId?: string): Promise<RefreshTaskReviewResponse> {
|
export async function refreshTaskReview(taskId: string, projectId?: string): Promise<RefreshTaskReviewResponse> {
|
||||||
return api<RefreshTaskReviewResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/refresh`, projectId), {
|
const data = await api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/refresh`, projectId), {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
return mapTaskReviewDataToLegacy(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh canonical review payload for future review-tab rendering. */
|
||||||
|
export function refreshTaskReviewData(taskId: string, projectId?: string): Promise<TaskReviewData> {
|
||||||
|
return api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/refresh`, projectId), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2059,9 +2059,9 @@ describe("GET /tasks/:id/review", () => {
|
|||||||
|
|
||||||
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
|
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.reviewState.source).toBe("reviewer-agent");
|
expect(res.body.mode).toBe("reviewer-agent");
|
||||||
expect(res.body.reviewState.items[0].reviewType).toBe("code");
|
expect(res.body.items[0].sourceMode).toBe("reviewer-agent");
|
||||||
expect(res.body.reviewState.items[0].verdict).toBe("REVISE");
|
expect(res.body.items[0].reviewState).toBe("REVISE");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns exact empty payload/message when no reviewer feedback exists", async () => {
|
it("returns exact empty payload/message when no reviewer feedback exists", async () => {
|
||||||
@@ -2074,8 +2074,9 @@ describe("GET /tasks/:id/review", () => {
|
|||||||
|
|
||||||
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
|
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.reviewState.items).toEqual([]);
|
expect(res.body.mode).toBe("reviewer-agent");
|
||||||
expect(res.body.emptyMessage).toBe("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.");
|
expect(res.body.summary).toBeNull();
|
||||||
|
expect(res.body.items).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to task log summary when reviewer output is incomplete", async () => {
|
it("falls back to task log summary when reviewer output is incomplete", async () => {
|
||||||
@@ -2096,8 +2097,8 @@ describe("GET /tasks/:id/review", () => {
|
|||||||
|
|
||||||
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
|
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.reviewState.items[0].summary).toBe("plan review Step 1: APPROVE");
|
expect(res.body.items[0].title).toContain("plan review APPROVE");
|
||||||
expect(res.body.reviewState.items[0].step).toBe(1);
|
expect(res.body.items[0].itemId).toContain("step-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 when task is missing", async () => {
|
it("returns 404 when task is missing", async () => {
|
||||||
@@ -2134,25 +2135,11 @@ describe("POST /tasks/:id/review/refresh", () => {
|
|||||||
commentCount: 0,
|
commentCount: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
vi.spyOn(GitHubClient.prototype, "getPrReviewSnapshot").mockResolvedValue({
|
vi.spyOn(GitHubClient.prototype, "getPrReviewDetails").mockResolvedValue({
|
||||||
decision: "CHANGES_REQUESTED",
|
mode: "pull-request",
|
||||||
checks: [],
|
refreshable: true,
|
||||||
prInfo: {
|
fetchedAt: "2026-05-01T10:00:00.000Z",
|
||||||
url: "https://github.com/owner/repo/pull/42",
|
summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [], blockingReasons: ["needs work"], checks: [] },
|
||||||
number: 42,
|
|
||||||
status: "open",
|
|
||||||
title: "PR",
|
|
||||||
headBranch: "fusion/fn-1",
|
|
||||||
baseBranch: "main",
|
|
||||||
commentCount: 1,
|
|
||||||
},
|
|
||||||
commentCount: 1,
|
|
||||||
summary: {
|
|
||||||
reviewDecision: "CHANGES_REQUESTED",
|
|
||||||
reviewers: [],
|
|
||||||
blockingReasons: ["needs work"],
|
|
||||||
checks: [],
|
|
||||||
},
|
|
||||||
items: [],
|
items: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2161,17 +2148,7 @@ describe("POST /tasks/:id/review/refresh", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect((store.updateTask as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
expect(res.body.mode).toBe("pull-request");
|
||||||
"FN-001",
|
|
||||||
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 () => {
|
it("returns scoped refresh error payload in PR mode when GitHub refresh fails", async () => {
|
||||||
@@ -2188,15 +2165,14 @@ describe("POST /tasks/:id/review/refresh", () => {
|
|||||||
},
|
},
|
||||||
reviewState: { source: "pull-request", items: [], addressing: [] },
|
reviewState: { source: "pull-request", items: [], addressing: [] },
|
||||||
});
|
});
|
||||||
vi.spyOn(GitHubClient.prototype, "getPrReviewSnapshot").mockRejectedValue(new Error("GitHub outage"));
|
vi.spyOn(GitHubClient.prototype, "getPrReviewDetails").mockRejectedValue(new Error("GitHub outage"));
|
||||||
|
|
||||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(500);
|
||||||
expect(res.body.reviewState.refreshStatus).toBe("error");
|
expect(res.body.error).toContain("GitHub outage");
|
||||||
expect(res.body.reviewState.refreshError).toContain("GitHub outage");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes direct-mode review payload without PR", async () => {
|
it("refreshes direct-mode review payload without PR", async () => {
|
||||||
@@ -2209,26 +2185,15 @@ describe("POST /tasks/:id/review/refresh", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getSnapshotSpy = vi.spyOn(GitHubClient.prototype, "getPrReviewSnapshot");
|
const getDetailsSpy = vi.spyOn(GitHubClient.prototype, "getPrReviewDetails");
|
||||||
|
|
||||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect((store.updateTask as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
expect(res.body.mode).toBe("reviewer-agent");
|
||||||
"FN-001",
|
expect(getDetailsSpy).not.toHaveBeenCalled();
|
||||||
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 () => {
|
it("returns 404 when task is missing", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { IssueInfo, PrInfo } from "@fusion/core";
|
import type { IssueInfo, PrInfo, TaskReviewData, TaskReviewItem, TaskReviewSummary } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
isGhAvailable,
|
isGhAvailable,
|
||||||
isGhAuthenticated,
|
isGhAuthenticated,
|
||||||
@@ -584,7 +584,7 @@ export class GitHubClient {
|
|||||||
|
|
||||||
async getPrReviewSnapshot(owner: string | undefined, repo: string | undefined, number: number): Promise<PrReviewSnapshot> {
|
async getPrReviewSnapshot(owner: string | undefined, repo: string | undefined, number: number): Promise<PrReviewSnapshot> {
|
||||||
const { owner: resolvedOwner, repo: resolvedRepo } = this.resolveRepo(owner, repo);
|
const { owner: resolvedOwner, repo: resolvedRepo } = this.resolveRepo(owner, repo);
|
||||||
const details = await this.getPrReviewDetails(resolvedOwner, resolvedRepo, number);
|
const details = await this.getRawPrReviewDetails(resolvedOwner, resolvedRepo, number);
|
||||||
const mergeStatus = await this.getPrMergeStatus(resolvedOwner, resolvedRepo, number);
|
const mergeStatus = await this.getPrMergeStatus(resolvedOwner, resolvedRepo, number);
|
||||||
const checks = mergeStatus.checks;
|
const checks = mergeStatus.checks;
|
||||||
const commentItems: PrReviewStateItem[] = (details.comments ?? []).map((comment) => ({
|
const commentItems: PrReviewStateItem[] = (details.comments ?? []).map((comment) => ({
|
||||||
@@ -632,7 +632,61 @@ export class GitHubClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPrReviewDetails(owner: string, repo: string, number: number): Promise<PrReviewDetails> {
|
async getPrReviewDetails(owner: string | undefined, repo: string | undefined, number: number): Promise<TaskReviewData> {
|
||||||
|
const { owner: resolvedOwner, repo: resolvedRepo } = this.resolveRepo(owner, repo);
|
||||||
|
const details = await this.getRawPrReviewDetails(resolvedOwner, resolvedRepo, number);
|
||||||
|
const mergeStatus = await this.getPrMergeStatus(resolvedOwner, resolvedRepo, number);
|
||||||
|
const fetchedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const reviewItems: TaskReviewItem[] = (details.reviews ?? []).map((review) => ({
|
||||||
|
itemId: `gh-review-${review.id}`,
|
||||||
|
sourceMode: "pull-request",
|
||||||
|
title: `Review ${review.state}`,
|
||||||
|
body: review.body ?? `Review ${review.state}`,
|
||||||
|
author: review.author?.login ?? "reviewer",
|
||||||
|
createdAt: review.submittedAt ?? null,
|
||||||
|
updatedAt: review.submittedAt ?? null,
|
||||||
|
url: review.url ?? undefined,
|
||||||
|
threadId: `review-${review.id}`,
|
||||||
|
reviewState: review.state ?? null,
|
||||||
|
progressStatus: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const commentItems: TaskReviewItem[] = (details.comments ?? []).map((comment) => ({
|
||||||
|
itemId: `gh-comment-${comment.id}`,
|
||||||
|
sourceMode: "pull-request",
|
||||||
|
title: "PR comment",
|
||||||
|
body: comment.body,
|
||||||
|
author: comment.author?.login ?? "reviewer",
|
||||||
|
createdAt: comment.createdAt ?? null,
|
||||||
|
updatedAt: comment.updatedAt ?? null,
|
||||||
|
url: comment.url,
|
||||||
|
threadId: `comment-${comment.id}`,
|
||||||
|
reviewState: "COMMENTED",
|
||||||
|
progressStatus: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const summary: TaskReviewSummary = {
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
blockingReasons: mergeStatus.blockingReasons,
|
||||||
|
checks: mergeStatus.checks,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: "pull-request",
|
||||||
|
refreshable: true,
|
||||||
|
fetchedAt,
|
||||||
|
summary,
|
||||||
|
items: [...reviewItems, ...commentItems],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRawPrReviewDetails(owner: string, repo: string, number: number): Promise<PrReviewDetails> {
|
||||||
if (this.hasGhAuth()) {
|
if (this.hasGhAuth()) {
|
||||||
try {
|
try {
|
||||||
return await this.getPrReviewDetailsWithGh(owner, repo, number);
|
return await this.getPrReviewDetailsWithGh(owner, repo, number);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type {
|
|||||||
BatchStatusResult,
|
BatchStatusResult,
|
||||||
IssueInfo,
|
IssueInfo,
|
||||||
PrInfo,
|
PrInfo,
|
||||||
Task,
|
|
||||||
TaskStore,
|
TaskStore,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { getCurrentRepo, isGhAuthenticated } from "@fusion/core";
|
import { getCurrentRepo, isGhAuthenticated } from "@fusion/core";
|
||||||
@@ -83,103 +82,6 @@ export function parseGitHubBadgeUrl(url: string | undefined): { owner: string; r
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIRECT_REVIEW_EMPTY_MESSAGE =
|
|
||||||
"No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.";
|
|
||||||
|
|
||||||
type CanonicalTaskReviewState = NonNullable<Task["reviewState"]>;
|
|
||||||
type CanonicalTaskReviewStateItem = CanonicalTaskReviewState["items"][number];
|
|
||||||
type CanonicalTaskReviewVerdict = NonNullable<CanonicalTaskReviewStateItem["verdict"]>;
|
|
||||||
type CanonicalTaskReviewerType = NonNullable<CanonicalTaskReviewStateItem["reviewType"]>;
|
|
||||||
|
|
||||||
const REVIEW_BLOCK_RE = /##\s+(Code|Plan)\s+Review:[\s\S]*?(?=\n##\s+(?:Code|Plan)\s+Review:|$)/gi;
|
|
||||||
const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
|
||||||
const REVIEW_SUMMARY_RE = /###\s+Summary\s*\n([\s\S]*?)(?=\n###\s+|$)/i;
|
|
||||||
const REVIEW_STEP_RE = /^(plan|code) review Step (\d+): (APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
|
||||||
|
|
||||||
function extractDirectReviewItems(task: Task, reviewerText: string): CanonicalTaskReviewStateItem[] {
|
|
||||||
const fallbackLogs = (task.log ?? []).filter((entry) => REVIEW_STEP_RE.test(entry.action));
|
|
||||||
const fallbackByType = new Map<CanonicalTaskReviewerType, { step?: number; verdict?: CanonicalTaskReviewVerdict; timestamp: string; summary: string }>();
|
|
||||||
for (const entry of fallbackLogs) {
|
|
||||||
const match = entry.action.match(REVIEW_STEP_RE);
|
|
||||||
if (!match) continue;
|
|
||||||
const reviewType = match[1].toLowerCase() === "plan" ? "plan" : "code";
|
|
||||||
fallbackByType.set(reviewType, {
|
|
||||||
step: Number.parseInt(match[2], 10),
|
|
||||||
verdict: match[3].toUpperCase() as CanonicalTaskReviewVerdict,
|
|
||||||
timestamp: entry.timestamp,
|
|
||||||
summary: entry.action,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const items: CanonicalTaskReviewStateItem[] = [];
|
|
||||||
const blocks = reviewerText.match(REVIEW_BLOCK_RE) ?? [];
|
|
||||||
for (let index = 0; index < blocks.length; index += 1) {
|
|
||||||
const block = blocks[index] ?? "";
|
|
||||||
const typeMatch = block.match(/##\s+(Code|Plan)\s+Review:/i);
|
|
||||||
const reviewType: CanonicalTaskReviewerType = typeMatch?.[1]?.toLowerCase() === "plan" ? "plan" : "code";
|
|
||||||
const verdict = block.match(REVIEW_VERDICT_RE)?.[1]?.toUpperCase() as CanonicalTaskReviewVerdict | undefined;
|
|
||||||
const summary = block.match(REVIEW_SUMMARY_RE)?.[1]?.trim() || fallbackByType.get(reviewType)?.summary;
|
|
||||||
const fallback = fallbackByType.get(reviewType);
|
|
||||||
items.push({
|
|
||||||
id: `reviewer-${reviewType}-${index + 1}`,
|
|
||||||
body: block.trim(),
|
|
||||||
author: { login: "reviewer-agent" },
|
|
||||||
createdAt: fallback?.timestamp ?? task.updatedAt,
|
|
||||||
source: "reviewer-agent",
|
|
||||||
reviewType,
|
|
||||||
verdict: verdict ?? fallback?.verdict,
|
|
||||||
step: fallback?.step,
|
|
||||||
summary,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.length > 0) {
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallbackLogs.map((entry, index) => {
|
|
||||||
const match = entry.action.match(REVIEW_STEP_RE);
|
|
||||||
const reviewType: CanonicalTaskReviewerType = match?.[1]?.toLowerCase() === "plan" ? "plan" : "code";
|
|
||||||
const verdict = match?.[3]?.toUpperCase() as CanonicalTaskReviewVerdict | undefined;
|
|
||||||
const step = match?.[2] ? Number.parseInt(match[2], 10) : undefined;
|
|
||||||
return {
|
|
||||||
id: `reviewer-fallback-${index + 1}`,
|
|
||||||
body: entry.action,
|
|
||||||
author: { login: "reviewer-agent" },
|
|
||||||
createdAt: entry.timestamp,
|
|
||||||
source: "reviewer-agent",
|
|
||||||
reviewType,
|
|
||||||
verdict,
|
|
||||||
step,
|
|
||||||
summary: entry.action,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildDirectReviewState(task: Task, store: TaskStore): Promise<CanonicalTaskReviewState> {
|
|
||||||
const agentLogs = await store.getAgentLogs(task.id);
|
|
||||||
const reviewerText = agentLogs
|
|
||||||
.filter((entry) => entry.agent === "reviewer" && entry.type === "text")
|
|
||||||
.map((entry) => entry.text)
|
|
||||||
.join("");
|
|
||||||
const items = extractDirectReviewItems(task, reviewerText);
|
|
||||||
const newest = [...items].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
|
||||||
const latest = newest[0];
|
|
||||||
return {
|
|
||||||
source: "reviewer-agent",
|
|
||||||
lastRefreshedAt: new Date().toISOString(),
|
|
||||||
summary: latest
|
|
||||||
? {
|
|
||||||
verdict: latest.verdict,
|
|
||||||
reviewType: latest.reviewType,
|
|
||||||
summary: latest.summary,
|
|
||||||
}
|
|
||||||
: { summary: DIRECT_REVIEW_EMPTY_MESSAGE },
|
|
||||||
items: newest,
|
|
||||||
addressing: task.reviewState?.addressing ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getGitHubRemotes(cwd?: string): Promise<GitRemote[]> {
|
export async function getGitHubRemotes(cwd?: string): Promise<GitRemote[]> {
|
||||||
try {
|
try {
|
||||||
const output = await runGitCommand(["remote", "-v"], cwd, 5000);
|
const output = await runGitCommand(["remote", "-v"], cwd, 5000);
|
||||||
@@ -3229,118 +3131,6 @@ 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);
|
|
||||||
const hasPrReview = task.reviewState?.source === "pull-request";
|
|
||||||
const reviewState = (hasPrReview
|
|
||||||
? task.reviewState
|
|
||||||
: await buildDirectReviewState(task, scopedStore)) ?? {
|
|
||||||
source: "reviewer-agent",
|
|
||||||
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;
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
router.post("/tasks/:id/review/refresh", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
|
||||||
const task = await scopedStore.getTask(req.params.id);
|
|
||||||
|
|
||||||
let reviewState = task.reviewState;
|
|
||||||
let nextPrInfo = task.prInfo;
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
if (task.prInfo) {
|
|
||||||
const badgeParsed = parseBadgeUrl(task.prInfo.url);
|
|
||||||
const owner = badgeParsed?.owner ?? getCurrentRepo(scopedStore.getRootDir())?.owner;
|
|
||||||
const repo = badgeParsed?.repo ?? getCurrentRepo(scopedStore.getRootDir())?.repo;
|
|
||||||
if (!owner || !repo) {
|
|
||||||
throw badRequest("Could not determine GitHub repository for PR review refresh");
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
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 });
|
|
||||||
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") {
|
|
||||||
throw notFound(`Task ${req.params.id} not found`);
|
|
||||||
}
|
|
||||||
rethrowAsApiError(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/tasks/:id/issue/refresh
|
* POST /api/tasks/:id/issue/refresh
|
||||||
* Force refresh issue status from GitHub API.
|
* Force refresh issue status from GitHub API.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createReadStream } from "node:fs";
|
import { createReadStream } from "node:fs";
|
||||||
import type { TaskStore, Task, TaskDetail, Column } from "@fusion/core";
|
import type { TaskStore, Task, TaskDetail, Column, TaskReviewData, TaskReviewItem, TaskReviewSummary } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
COLUMNS,
|
COLUMNS,
|
||||||
TASK_PRIORITIES,
|
TASK_PRIORITIES,
|
||||||
@@ -11,12 +11,90 @@ import {
|
|||||||
validateNodeOverrideChange,
|
validateNodeOverrideChange,
|
||||||
canAgentTakeImplementationTask,
|
canAgentTakeImplementationTask,
|
||||||
formatRoleMismatchReason,
|
formatRoleMismatchReason,
|
||||||
|
getCurrentRepo,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
|
import { GitHubClient } from "../github.js";
|
||||||
|
import { parseGitHubBadgeUrl } from "./register-git-github.js";
|
||||||
import { planTaskWorktreePath } from "@fusion/engine";
|
import { planTaskWorktreePath } from "@fusion/engine";
|
||||||
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
|
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
|
||||||
import { fetchFromRemoteNode } from "./register-settings-sync-helpers.js";
|
import { fetchFromRemoteNode } from "./register-settings-sync-helpers.js";
|
||||||
import type { ApiRoutesContext } from "./types.js";
|
import type { ApiRoutesContext } from "./types.js";
|
||||||
|
|
||||||
|
const REVIEW_BLOCK_RE = /##\s+(Code|Plan)\s+Review:[\s\S]*?(?=\n##\s+(?:Code|Plan)\s+Review:|$)/gi;
|
||||||
|
const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
||||||
|
const REVIEW_STEP_RE = /^(plan|code) review Step (\d+): (APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
||||||
|
|
||||||
|
function buildReviewerAgentItemId(input: { index: number; reviewType: "plan" | "code"; step?: number; verdict?: string; createdAt?: string }): string {
|
||||||
|
const stepPart = input.step ? `step-${input.step}` : "step-na";
|
||||||
|
const verdictPart = (input.verdict ?? "unknown").toLowerCase();
|
||||||
|
const timePart = (input.createdAt ?? "na").replace(/[:.]/g, "-");
|
||||||
|
return `reviewer-${input.reviewType}-${stepPart}-${verdictPart}-${timePart}-${input.index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildDirectTaskReviewData(task: Task, store: TaskStore): Promise<TaskReviewData> {
|
||||||
|
const agentLogs = await store.getAgentLogs(task.id);
|
||||||
|
const reviewerText = agentLogs.filter((entry) => entry.agent === "reviewer" && entry.type === "text").map((entry) => entry.text).join("\n");
|
||||||
|
const fallbackLogs = (task.log ?? []).filter((entry) => REVIEW_STEP_RE.test(entry.action));
|
||||||
|
|
||||||
|
const items: TaskReviewItem[] = [];
|
||||||
|
const blocks = reviewerText.match(REVIEW_BLOCK_RE) ?? [];
|
||||||
|
for (let index = 0; index < blocks.length; index += 1) {
|
||||||
|
const block = blocks[index] ?? "";
|
||||||
|
const typeMatch = block.match(/##\s+(Code|Plan)\s+Review:/i);
|
||||||
|
const reviewType = typeMatch?.[1]?.toLowerCase() === "plan" ? "plan" : "code";
|
||||||
|
const verdict = block.match(REVIEW_VERDICT_RE)?.[1]?.toUpperCase();
|
||||||
|
const fallback = fallbackLogs[index];
|
||||||
|
const createdAt = fallback?.timestamp ?? task.updatedAt;
|
||||||
|
items.push({
|
||||||
|
itemId: buildReviewerAgentItemId({ index, reviewType, verdict, createdAt }),
|
||||||
|
sourceMode: "reviewer-agent",
|
||||||
|
title: `${reviewType} review ${verdict ?? "feedback"}`,
|
||||||
|
body: block.trim(),
|
||||||
|
author: "reviewer-agent",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
reviewState: verdict ?? null,
|
||||||
|
progressStatus: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
fallbackLogs.forEach((entry, index) => {
|
||||||
|
const match = entry.action.match(REVIEW_STEP_RE);
|
||||||
|
const reviewType = match?.[1]?.toLowerCase() === "plan" ? "plan" : "code";
|
||||||
|
const verdict = match?.[3]?.toUpperCase();
|
||||||
|
items.push({
|
||||||
|
itemId: buildReviewerAgentItemId({ index, reviewType, step: match?.[2] ? Number.parseInt(match[2], 10) : undefined, verdict, createdAt: entry.timestamp }),
|
||||||
|
sourceMode: "reviewer-agent",
|
||||||
|
title: `${reviewType} review ${verdict ?? "feedback"}`,
|
||||||
|
body: entry.action,
|
||||||
|
author: "reviewer-agent",
|
||||||
|
createdAt: entry.timestamp,
|
||||||
|
updatedAt: entry.timestamp,
|
||||||
|
reviewState: verdict ?? null,
|
||||||
|
progressStatus: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...items].sort((a, b) => Date.parse(b.createdAt ?? "") - Date.parse(a.createdAt ?? ""));
|
||||||
|
const latest = sorted[0];
|
||||||
|
const summary: TaskReviewSummary | null = latest
|
||||||
|
? {
|
||||||
|
summary: latest.title,
|
||||||
|
verdict: (latest.reviewState as "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE" | null | undefined) ?? undefined,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: "reviewer-agent",
|
||||||
|
refreshable: true,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
summary,
|
||||||
|
items: sorted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface TaskWorkflowRouteDeps {
|
interface TaskWorkflowRouteDeps {
|
||||||
runtimeLogger: { error: (message: string, data?: Record<string, unknown>) => void; warn: (message: string, data?: Record<string, unknown>) => void };
|
runtimeLogger: { error: (message: string, data?: Record<string, unknown>) => void; warn: (message: string, data?: Record<string, unknown>) => void };
|
||||||
upload: { single: (name: string) => unknown };
|
upload: { single: (name: string) => unknown };
|
||||||
@@ -1768,6 +1846,62 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/tasks/:id/review", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
const task = await scopedStore.getTask(req.params.id);
|
||||||
|
let reviewData: TaskReviewData;
|
||||||
|
|
||||||
|
if (task.prInfo) {
|
||||||
|
const badgeParsed = parseGitHubBadgeUrl(task.prInfo.url);
|
||||||
|
const repoInfo = getCurrentRepo(scopedStore.getRootDir());
|
||||||
|
const owner = badgeParsed?.owner ?? repoInfo?.owner;
|
||||||
|
const repo = badgeParsed?.repo ?? repoInfo?.repo;
|
||||||
|
if (!owner || !repo) {
|
||||||
|
throw badRequest("Could not determine GitHub repository for PR review fetch");
|
||||||
|
}
|
||||||
|
reviewData = await new GitHubClient(options?.githubToken ?? process.env.GITHUB_TOKEN).getPrReviewDetails(owner, repo, task.prInfo.number);
|
||||||
|
} else {
|
||||||
|
reviewData = await buildDirectTaskReviewData(task, scopedStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(reviewData);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/tasks/:id/review/refresh", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
const task = await scopedStore.getTask(req.params.id);
|
||||||
|
let reviewData: TaskReviewData;
|
||||||
|
if (task.prInfo) {
|
||||||
|
const badgeParsed = parseGitHubBadgeUrl(task.prInfo.url);
|
||||||
|
const repoInfo = getCurrentRepo(scopedStore.getRootDir());
|
||||||
|
const owner = badgeParsed?.owner ?? repoInfo?.owner;
|
||||||
|
const repo = badgeParsed?.repo ?? repoInfo?.repo;
|
||||||
|
if (!owner || !repo) {
|
||||||
|
throw badRequest("Could not determine GitHub repository for PR review refresh");
|
||||||
|
}
|
||||||
|
reviewData = await new GitHubClient(options?.githubToken ?? process.env.GITHUB_TOKEN).getPrReviewDetails(owner, repo, task.prInfo.number);
|
||||||
|
} else {
|
||||||
|
reviewData = await buildDirectTaskReviewData(task, scopedStore);
|
||||||
|
}
|
||||||
|
res.json(reviewData);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Queue same-task revision pass for selected review items
|
// Queue same-task revision pass for selected review items
|
||||||
router.post("/tasks/:id/review/address", async (req, res) => {
|
router.post("/tasks/:id/review/address", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user