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

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