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:
Fusion
2026-05-09 03:01:38 -07:00
committed by gsxdsm
parent 1abbb10607
commit 07249fd301
10 changed files with 327 additions and 277 deletions

View File

@@ -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";

View File

@@ -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;

View File

@@ -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" })
);
});
});

View File

@@ -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",
});
}

View File

@@ -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 () => {

View File

@@ -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);

View File

@@ -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.

View File

@@ -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 {