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:
@@ -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 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 type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js";
|
||||
export * from "./mesh-replication-protocol.js";
|
||||
export * from "./mesh-task-replication.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 TaskReviewItemStatus = "queued" | "in-progress" | "addressed" | "failed";
|
||||
|
||||
export interface TaskReviewItem {
|
||||
export interface LegacyTaskReviewItem {
|
||||
id: string;
|
||||
source: TaskReviewSource;
|
||||
status: TaskReviewItemStatus;
|
||||
@@ -734,7 +734,7 @@ export interface TaskReview {
|
||||
summary?: string;
|
||||
latestRefreshAt?: string;
|
||||
selectedItemIds?: string[];
|
||||
items: TaskReviewItem[];
|
||||
items: LegacyTaskReviewItem[];
|
||||
}
|
||||
|
||||
export type PrCheckState =
|
||||
@@ -838,6 +838,43 @@ export interface TaskReviewState {
|
||||
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 {
|
||||
/** UUID primary key */
|
||||
id: string;
|
||||
|
||||
@@ -73,6 +73,8 @@ import {
|
||||
fetchMemoryBackendStatus,
|
||||
fetchPluginDashboardViews,
|
||||
fetchPluginUiSlots,
|
||||
fetchTaskReviewData,
|
||||
refreshTaskReviewData,
|
||||
type ProjectInfo,
|
||||
type ProjectHealth,
|
||||
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 {
|
||||
Task,
|
||||
TaskDetail,
|
||||
TaskReviewData,
|
||||
TaskAttachment,
|
||||
TaskComment,
|
||||
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) */
|
||||
export function fetchTaskReview(taskId: string, projectId?: string): Promise<TaskReviewResponse> {
|
||||
return api<TaskReviewResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review`, projectId));
|
||||
export async function fetchTaskReview(taskId: string, projectId?: string): Promise<TaskReviewResponse> {
|
||||
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) */
|
||||
export function refreshTaskReview(taskId: string, projectId?: string): Promise<RefreshTaskReviewResponse> {
|
||||
return api<RefreshTaskReviewResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/refresh`, projectId), {
|
||||
export async function refreshTaskReview(taskId: string, projectId?: string): Promise<RefreshTaskReviewResponse> {
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2059,9 +2059,9 @@ describe("GET /tasks/:id/review", () => {
|
||||
|
||||
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reviewState.source).toBe("reviewer-agent");
|
||||
expect(res.body.reviewState.items[0].reviewType).toBe("code");
|
||||
expect(res.body.reviewState.items[0].verdict).toBe("REVISE");
|
||||
expect(res.body.mode).toBe("reviewer-agent");
|
||||
expect(res.body.items[0].sourceMode).toBe("reviewer-agent");
|
||||
expect(res.body.items[0].reviewState).toBe("REVISE");
|
||||
});
|
||||
|
||||
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");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reviewState.items).toEqual([]);
|
||||
expect(res.body.emptyMessage).toBe("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.");
|
||||
expect(res.body.mode).toBe("reviewer-agent");
|
||||
expect(res.body.summary).toBeNull();
|
||||
expect(res.body.items).toEqual([]);
|
||||
});
|
||||
|
||||
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");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reviewState.items[0].summary).toBe("plan review Step 1: APPROVE");
|
||||
expect(res.body.reviewState.items[0].step).toBe(1);
|
||||
expect(res.body.items[0].title).toContain("plan review APPROVE");
|
||||
expect(res.body.items[0].itemId).toContain("step-1");
|
||||
});
|
||||
|
||||
it("returns 404 when task is missing", async () => {
|
||||
@@ -2134,25 +2135,11 @@ describe("POST /tasks/:id/review/refresh", () => {
|
||||
commentCount: 0,
|
||||
},
|
||||
});
|
||||
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: [],
|
||||
blockingReasons: ["needs work"],
|
||||
checks: [],
|
||||
},
|
||||
vi.spyOn(GitHubClient.prototype, "getPrReviewDetails").mockResolvedValue({
|
||||
mode: "pull-request",
|
||||
refreshable: true,
|
||||
fetchedAt: "2026-05-01T10:00:00.000Z",
|
||||
summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [], blockingReasons: ["needs work"], checks: [] },
|
||||
items: [],
|
||||
});
|
||||
|
||||
@@ -2161,17 +2148,7 @@ 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",
|
||||
refreshSource: "manual",
|
||||
refreshStatus: "ready",
|
||||
lastRefreshedAt: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(res.body.mode).toBe("pull-request");
|
||||
});
|
||||
|
||||
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: [] },
|
||||
});
|
||||
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({}), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reviewState.refreshStatus).toBe("error");
|
||||
expect(res.body.reviewState.refreshError).toContain("GitHub outage");
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toContain("GitHub outage");
|
||||
});
|
||||
|
||||
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({}), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
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();
|
||||
expect(res.body.mode).toBe("reviewer-agent");
|
||||
expect(getDetailsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 {
|
||||
isGhAvailable,
|
||||
isGhAuthenticated,
|
||||
@@ -584,7 +584,7 @@ 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 details = await this.getPrReviewDetails(resolvedOwner, resolvedRepo, number);
|
||||
const details = await this.getRawPrReviewDetails(resolvedOwner, resolvedRepo, number);
|
||||
const mergeStatus = await this.getPrMergeStatus(resolvedOwner, resolvedRepo, number);
|
||||
const checks = mergeStatus.checks;
|
||||
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()) {
|
||||
try {
|
||||
return await this.getPrReviewDetailsWithGh(owner, repo, number);
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
BatchStatusResult,
|
||||
IssueInfo,
|
||||
PrInfo,
|
||||
Task,
|
||||
TaskStore,
|
||||
} 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[]> {
|
||||
try {
|
||||
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
|
||||
* Force refresh issue status from GitHub API.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
COLUMNS,
|
||||
TASK_PRIORITIES,
|
||||
@@ -11,12 +11,90 @@ import {
|
||||
validateNodeOverrideChange,
|
||||
canAgentTakeImplementationTask,
|
||||
formatRoleMismatchReason,
|
||||
getCurrentRepo,
|
||||
} from "@fusion/core";
|
||||
import { GitHubClient } from "../github.js";
|
||||
import { parseGitHubBadgeUrl } from "./register-git-github.js";
|
||||
import { planTaskWorktreePath } from "@fusion/engine";
|
||||
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
|
||||
import { fetchFromRemoteNode } from "./register-settings-sync-helpers.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 {
|
||||
runtimeLogger: { error: (message: string, data?: Record<string, unknown>) => void; warn: (message: string, data?: Record<string, unknown>) => void };
|
||||
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
|
||||
router.post("/tasks/:id/review/address", async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user