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