FN-7185: add PR feedback addressing action
Add a dashboard action that starts an AI pass for actionable pull-request review feedback. - Add a lifecycle route that validates linked PR tasks, seeds a ce-resolve-pr-feedback steering prompt, returns in-review tasks to active work, and wakes assigned agents when needed. - Add shared PR feedback gating plus task-card and Review tab buttons with loading states, toasts, styling, docs, and translations. - Cover the route, API client, task card, and Review tab behavior with regression tests. Files changed: .changeset/fn-7185-address-pr-feedback.md | 7 + docs/dashboard-guide.md | 1 + packages/dashboard/app/__tests__/api-tasks.test.ts | 12 ++ packages/dashboard/app/api/legacy.ts | 11 ++ packages/dashboard/app/components/TaskCard.css | 7 +- packages/dashboard/app/components/TaskCard.tsx | 44 +++++- .../dashboard/app/components/TaskReviewTab.tsx | 41 ++++- .../app/components/__tests__/TaskCard.test.tsx | 164 +++++++++++++++++++- .../components/__tests__/TaskReviewTab.test.tsx | 127 ++++++++++++++++ packages/dashboard/app/utils/prFeedback.ts | 22 +++ .../dashboard/src/__tests__/routes-tasks.test.ts | 165 +++++++++++++++++++++ .../src/routes/register-task-workflow-routes.ts | 67 +++++++++ packages/i18n/locales/en/app.json | 10 ++ packages/i18n/locales/es/app.json | 22 ++- packages/i18n/locales/fr/app.json | 22 ++- packages/i18n/locales/ko/app.json | 22 ++- packages/i18n/locales/zh-CN/app.json | 22 ++- packages/i18n/locales/zh-TW/app.json | 22 ++- 18 files changed, 764 insertions(+), 24 deletions(-) Fusion-Task-Id: FN-7185 Fusion-Task-Lineage: b2e98163-4fc6-45c9-8ace-a14eeb096e10 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7185-address-pr-feedback.md
Normal file
7
.changeset/fn-7185-address-pr-feedback.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add an "Address PR feedback" button that starts an AI session to resolve PR review comments.
|
||||
category: feature
|
||||
dev: New POST /tasks/:id/pr/address-feedback route seeds a ce-resolve-pr-feedback steering prompt and wakes the assigned agent; button gates on linked-PR actionable feedback (commentCount or CHANGES_REQUESTED).
|
||||
@@ -1009,6 +1009,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou
|
||||
- Project Settings → Project Models includes optional **PR title prompt guidance** and **PR description prompt guidance** fields. Blank fields preserve the default Create PR metadata prompt; populated fields append guidance for the generated title or body sections.
|
||||
- The **Artifacts** tab combines task documents written by agents or users with task-scoped registered media artifacts. The gallery uses thumbnail-first image/video cards, image and video previews can expand into a dismissible full-size lightbox, video and audio use native controls, document artifacts show text previews, and generic artifacts open through their media URL.
|
||||
- The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread.
|
||||
- When a linked PR has actionable comments or a changes-requested decision, **Address PR feedback** appears in the Review tab and on the task card; it starts a same-task AI session to evaluate open PR threads, fix valid issues, reply, and resolve them.
|
||||
- Review comments hide GitHub template HTML comments in both Markdown and Plain modes, show author avatars or User/Bot fallbacks, label Human vs Bot/agent authors, and include All/Human/Bot filtering.
|
||||
- **Request revision** in Review resumes work on the same task ID (no refinement task): `in-progress` tasks get steering injection, while `in-review` tasks are moved back to `in-progress` for the same branch/worktree revision pass. The selected feedback can come from either PR review data or reviewer-agent feedback shown in the tab.
|
||||
- Review supports a manual **Refresh** action in-place: PR mode pulls latest GitHub review state/decision, while direct mode rehydrates reviewer-agent feedback from task agent logs (no GitHub call).
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
fetchPluginUiSlots,
|
||||
fetchTaskReviewData,
|
||||
refreshTaskReviewData,
|
||||
addressPrFeedback,
|
||||
type ProjectInfo,
|
||||
type ProjectHealth,
|
||||
type ActivityFeedEntry,
|
||||
@@ -1231,6 +1232,17 @@ describe("task review data api wrappers", () => {
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
|
||||
it("addressPrFeedback posts to the project-scoped PR feedback endpoint", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(mockFetchResponse(true, { task: FAKE_DETAIL })) as unknown as typeof fetch;
|
||||
|
||||
await addressPrFeedback("FN-123", "proj-1");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/tasks/FN-123/pr/address-feedback?projectId=proj-1",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteTask", () => {
|
||||
|
||||
@@ -347,6 +347,10 @@ export interface ReviseTaskReviewResponse {
|
||||
reviewState: NonNullable<TaskDetail["reviewState"]>;
|
||||
}
|
||||
|
||||
export interface AddressPrFeedbackResponse {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
export interface DuplicateMatch {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -6517,6 +6521,13 @@ export function reviseTaskReviewItems(taskId: string, selectedItems: SelectedRev
|
||||
});
|
||||
}
|
||||
|
||||
/** Request an AI pass that addresses open pull-request feedback for the task's primary PR. */
|
||||
export function addressPrFeedback(taskId: string, projectId?: string): Promise<AddressPrFeedbackResponse> {
|
||||
return api<AddressPrFeedbackResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/pr/address-feedback`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Return task to agent - clear assignee and status, move to todo */
|
||||
export function returnTaskToAgent(taskId: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/return-to-agent`, projectId), {
|
||||
|
||||
@@ -942,10 +942,15 @@ The execution-time badge is part of the footer's bottom-right chip cluster, so i
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-create-pr-action:hover {
|
||||
.card-create-pr-action:hover:not(:disabled) {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.card-create-pr-action:disabled {
|
||||
cursor: wait;
|
||||
opacity: var(--opacity-disabled);
|
||||
}
|
||||
|
||||
.card-create-pr-action:focus-visible {
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
outline: none;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getErrorMessage,
|
||||
} from "@fusion/core";
|
||||
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
|
||||
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent, type WorkflowFieldDefinition } from "../api";
|
||||
import { addressPrFeedback, fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent, type WorkflowFieldDefinition } from "../api";
|
||||
import { GitHubBadge } from "./GitHubBadge";
|
||||
import { PrCreateModal } from "./PrCreateModal";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
@@ -30,6 +30,7 @@ import { getTaskAgeStalenessCopy, shouldShowTaskAgeStalenessBadge } from "../uti
|
||||
import { getUnifiedTaskProgress } from "../utils/taskProgress";
|
||||
import { getPrBadgeModifierClass } from "../utils/prBadgeClass";
|
||||
import { getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
|
||||
import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
|
||||
@@ -442,10 +443,6 @@ export interface CliCardState {
|
||||
| "needsAttention";
|
||||
}
|
||||
|
||||
function getTaskPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
|
||||
return task.prInfos?.[0] ?? task.prInfo;
|
||||
}
|
||||
|
||||
function areTaskBadgeInfosEqual(
|
||||
previous: PrInfo | IssueInfo | undefined,
|
||||
next: PrInfo | IssueInfo | undefined,
|
||||
@@ -735,6 +732,7 @@ function TaskCardComponent({
|
||||
const [showSendBackMenu, setShowSendBackMenu] = useState(false);
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
const [isPrCreateOpen, setIsPrCreateOpen] = useState(false);
|
||||
const [isAddressingPrFeedback, setIsAddressingPrFeedback] = useState(false);
|
||||
const [timeIndicatorNowMs, setTimeIndicatorNowMs] = useState(() => Date.now());
|
||||
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -1311,6 +1309,7 @@ function TaskCardComponent({
|
||||
&& !isPaused
|
||||
&& !isFailed
|
||||
&& !queued;
|
||||
const showAddressPrFeedbackAction = canStartPrFeedbackAddressing(task);
|
||||
const metaRowVisible =
|
||||
(task.dependencies?.length ?? 0) > 0
|
||||
|| queued
|
||||
@@ -1318,7 +1317,7 @@ function TaskCardComponent({
|
||||
|| Boolean(task.blockedBy)
|
||||
|| Boolean(task.overlapBlockedBy)
|
||||
|| Boolean(fanout && fanout.totalCount > 0);
|
||||
const shouldRenderActionRow = Boolean(onPromote) || showCreatePrQuickAction || (showInReviewMoveControl && !metaRowVisible);
|
||||
const shouldRenderActionRow = Boolean(onPromote) || showCreatePrQuickAction || showAddressPrFeedbackAction || (showInReviewMoveControl && !metaRowVisible);
|
||||
|
||||
const renderInReviewMoveControl = () => (
|
||||
<div className="card-send-back" ref={sendBackRef}>
|
||||
@@ -1714,6 +1713,21 @@ function TaskCardComponent({
|
||||
void onPromote(task.id);
|
||||
}, [isPromoting, onPromote, task.id]);
|
||||
|
||||
const handleAddressPrFeedbackClick = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
if (isAddressingPrFeedback) return;
|
||||
|
||||
setIsAddressingPrFeedback(true);
|
||||
try {
|
||||
await addressPrFeedback(task.id, projectId);
|
||||
addToast(t("tasks.addressPrFeedbackStarted", "Addressing PR feedback — AI session started"), "success");
|
||||
} catch (err) {
|
||||
addToast(t("tasks.addressPrFeedbackFailed", "Failed to start PR feedback session: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
} finally {
|
||||
setIsAddressingPrFeedback(false);
|
||||
}
|
||||
}, [addToast, isAddressingPrFeedback, projectId, t, task.id]);
|
||||
|
||||
const handleRetryTask = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
if (!onRetryTask || isRetrying) return;
|
||||
@@ -2498,6 +2512,24 @@ function TaskCardComponent({
|
||||
{t("tasks.createPr", "Create PR")}
|
||||
</button>
|
||||
)}
|
||||
{showAddressPrFeedbackAction && (
|
||||
<button
|
||||
type="button"
|
||||
className="card-create-pr-action card-address-pr-feedback-action"
|
||||
data-testid={`card-address-pr-feedback-${task.id}`}
|
||||
title={t("tasks.addressPrFeedbackTitle", "Start an AI session to address PR feedback")}
|
||||
aria-label={t("tasks.addressPrFeedbackAriaLabel", "Address PR feedback")}
|
||||
disabled={isAddressingPrFeedback}
|
||||
onClick={handleAddressPrFeedbackClick}
|
||||
>
|
||||
{/*
|
||||
FNXC:TaskCardPrFeedback 2026-06-28-00:00:
|
||||
Operators need the task card affordance to appear only when the primary linked PR has actionable feedback. The click seeds the ce-resolve-pr-feedback steering prompt through the lifecycle route instead of reading untrusted PR comments as instructions.
|
||||
*/}
|
||||
<Bot size={12} />
|
||||
{isAddressingPrFeedback ? t("tasks.addressingPrFeedback", "Addressing…") : t("tasks.addressPrFeedback", "Address PR feedback")}
|
||||
</button>
|
||||
)}
|
||||
{onPromote && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,11 +4,12 @@ import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
|
||||
import { Bot, ExternalLink, GitPullRequest, User } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems, updateTask } from "../api";
|
||||
import { addressPrFeedback, fetchTaskReview, refreshTaskReview, reviseTaskReviewItems, updateTask } from "../api";
|
||||
import type { SelectedReviewItem } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { linkifyFilePaths } from "../utils/filePathLinkify";
|
||||
import { resolveReviewCommentAuthor } from "../utils/githubCommentAuthor";
|
||||
import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||
|
||||
@@ -162,6 +163,7 @@ export function TaskReviewTab({
|
||||
task.autoMerge === true ? "on" : task.autoMerge === false ? "off" : "follow-default",
|
||||
);
|
||||
const [isSavingAutoMergePreference, setIsSavingAutoMergePreference] = useState(false);
|
||||
const [addressingPrFeedback, setAddressingPrFeedback] = useState(false);
|
||||
|
||||
const isPrMode = review?.source === "pull-request";
|
||||
const prSummary = isPrMode ? review?.summary as TaskReviewSummary | undefined : undefined;
|
||||
@@ -175,6 +177,10 @@ export function TaskReviewTab({
|
||||
}, [authorTypeFilter, displayItems]);
|
||||
const visibleItemIds = useMemo(() => new Set(filteredDisplayItems.map((item) => item.id)), [filteredDisplayItems]);
|
||||
const canRevise = selected.length > 0 && !revising;
|
||||
const canAddressPrFeedback = isPrMode
|
||||
&& Boolean(getTaskPrimaryPrInfo(task))
|
||||
&& (task.column === "in-review" || task.column === "in-progress")
|
||||
&& (canStartPrFeedbackAddressing(task) || displayItems.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
writeBooleanPref(REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY, renderMarkdown);
|
||||
@@ -287,6 +293,22 @@ export function TaskReviewTab({
|
||||
}
|
||||
};
|
||||
|
||||
const onAddressPrFeedback = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setAddressingPrFeedback(true);
|
||||
const result = await addressPrFeedback(task.id, projectId);
|
||||
onTaskUpdated?.(result.task);
|
||||
addToast(t("taskReview.addressPrFeedbackStarted", "Addressing PR feedback — AI session started"), "success");
|
||||
} catch (addressError) {
|
||||
const message = addressError instanceof Error ? addressError.message : t("taskReview.addressPrFeedbackFailed", "Failed to start PR feedback session");
|
||||
setError(message);
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
setAddressingPrFeedback(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onRevise = async () => {
|
||||
try {
|
||||
if (!review) return;
|
||||
@@ -397,6 +419,23 @@ export function TaskReviewTab({
|
||||
{t("taskReview.createPr", "Create PR")}
|
||||
</button>
|
||||
) : null}
|
||||
{canAddressPrFeedback ? (
|
||||
<>
|
||||
{/*
|
||||
FNXC:TaskReviewPrFeedback 2026-06-28-00:00:
|
||||
The Review tab must expose the same gated Address PR feedback action as task cards when PR comments or CHANGES_REQUESTED make feedback actionable. Route the click through the lifecycle API so the ce-resolve-pr-feedback steering prompt is visible in Chat and can wake the assigned agent.
|
||||
*/}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => void onAddressPrFeedback()}
|
||||
disabled={addressingPrFeedback}
|
||||
data-testid="task-review-address-pr-feedback"
|
||||
>
|
||||
<Bot />
|
||||
{addressingPrFeedback ? t("taskReview.addressingPrFeedback", "Addressing…") : t("taskReview.addressPrFeedback", "Address PR feedback")}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => setRenderMarkdown((prev) => !prev)}
|
||||
|
||||
@@ -76,6 +76,7 @@ vi.mock("../../hooks/useBatchBadgeFetch", () => ({
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
addressPrFeedback: vi.fn(),
|
||||
fetchTaskDetail: vi.fn(),
|
||||
uploadAttachment: vi.fn(),
|
||||
fetchMission: vi.fn(),
|
||||
@@ -89,7 +90,7 @@ vi.mock("../../hooks/useConfirm", () => ({
|
||||
useConfirm: () => ({ confirm: mockConfirm, confirmWithChoice: mockConfirmWithChoice }),
|
||||
}));
|
||||
|
||||
import { uploadAttachment, fetchMission, fetchAgent, fetchAgents } from "../../api";
|
||||
import { addressPrFeedback, uploadAttachment, fetchMission, fetchAgent, fetchAgents } from "../../api";
|
||||
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
||||
import { writeCache, SWR_CACHE_KEYS } from "../../utils/swrCache";
|
||||
|
||||
@@ -167,6 +168,7 @@ afterEach(() => {
|
||||
unsubscribeFromBadgeMock.mockReset();
|
||||
mockConfirm.mockReset();
|
||||
mockConfirmWithChoice.mockReset();
|
||||
vi.mocked(addressPrFeedback).mockReset();
|
||||
});
|
||||
|
||||
describe("TaskCard", () => {
|
||||
@@ -838,6 +840,166 @@ describe("TaskCard", () => {
|
||||
expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides Address PR feedback when the task has no actionable PR feedback", () => {
|
||||
const noPrRender = render(
|
||||
<TaskCard
|
||||
task={makeTask({ id: "FN-NO-PR", prInfo: undefined as any, prInfos: undefined })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("card-address-pr-feedback-FN-NO-PR")).toBeNull();
|
||||
expect(noPrRender.container.querySelector(".card-action-row")).toBeNull();
|
||||
noPrRender.unmount();
|
||||
|
||||
const noFeedbackRender = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
id: "FN-NO-FEEDBACK",
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/8",
|
||||
number: 8,
|
||||
status: "open",
|
||||
title: "No feedback PR",
|
||||
headBranch: "fusion/fn-001",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
lastReviewDecision: "APPROVED",
|
||||
} as any,
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("card-address-pr-feedback-FN-NO-FEEDBACK")).toBeNull();
|
||||
expect(noFeedbackRender.container.querySelector(".card-action-row")).toBeNull();
|
||||
noFeedbackRender.unmount();
|
||||
|
||||
const unsupportedColumnRender = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
id: "FN-DONE-FEEDBACK",
|
||||
column: "done",
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/13",
|
||||
number: 13,
|
||||
status: "open",
|
||||
title: "Feedback on done task",
|
||||
headBranch: "fusion/fn-done",
|
||||
baseBranch: "main",
|
||||
commentCount: 2,
|
||||
lastReviewDecision: "CHANGES_REQUESTED",
|
||||
} as any,
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("card-address-pr-feedback-FN-DONE-FEEDBACK")).toBeNull();
|
||||
expect(unsupportedColumnRender.container.querySelector(".card-action-row")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders Address PR feedback once for actionable primary PR feedback", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
id: "FN-PR-FEEDBACK",
|
||||
prInfos: [
|
||||
{
|
||||
url: "https://github.com/owner/repo/pull/9",
|
||||
number: 9,
|
||||
status: "open",
|
||||
title: "Feedback PR",
|
||||
headBranch: "fusion/fn-001",
|
||||
baseBranch: "main",
|
||||
commentCount: 2,
|
||||
} as any,
|
||||
{
|
||||
url: "https://github.com/owner/repo/pull/10",
|
||||
number: 10,
|
||||
status: "open",
|
||||
title: "Secondary PR",
|
||||
headBranch: "fusion/fn-001-alt",
|
||||
baseBranch: "main",
|
||||
commentCount: 4,
|
||||
} as any,
|
||||
],
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByTestId("card-address-pr-feedback-FN-PR-FEEDBACK");
|
||||
expect(buttons).toHaveLength(1);
|
||||
expect(buttons[0]).toHaveClass("card-create-pr-action", "card-address-pr-feedback-action");
|
||||
expect(buttons[0].closest(".card-action-row")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("starts Address PR feedback from the card without opening detail", async () => {
|
||||
const addToast = vi.fn();
|
||||
const onOpenDetail = vi.fn();
|
||||
vi.mocked(addressPrFeedback).mockResolvedValue({ task: makeTask({ id: "FN-CLICK" }) });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
id: "FN-CLICK",
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/11",
|
||||
number: 11,
|
||||
status: "open",
|
||||
title: "Changes requested PR",
|
||||
headBranch: "fusion/fn-click",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
lastReviewDecision: "CHANGES_REQUESTED",
|
||||
} as any,
|
||||
})}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
projectId="proj-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-address-pr-feedback-FN-CLICK"));
|
||||
|
||||
await waitFor(() => expect(addressPrFeedback).toHaveBeenCalledWith("FN-CLICK", "proj-1"));
|
||||
expect(addToast).toHaveBeenCalledWith("Addressing PR feedback — AI session started", "success");
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an error toast when Address PR feedback cannot start", async () => {
|
||||
const addToast = vi.fn();
|
||||
vi.mocked(addressPrFeedback).mockRejectedValue(new Error("wake failed"));
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
id: "FN-ERROR",
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/12",
|
||||
number: 12,
|
||||
status: "open",
|
||||
title: "Feedback PR",
|
||||
headBranch: "fusion/fn-error",
|
||||
baseBranch: "main",
|
||||
commentCount: 1,
|
||||
} as any,
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={addToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-address-pr-feedback-FN-ERROR"));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Failed to start PR feedback session: wake failed", "error"));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ paused: true, userPaused: false },
|
||||
{ paused: false, userPaused: true },
|
||||
|
||||
@@ -15,6 +15,7 @@ const apiMocks = vi.hoisted(() => ({
|
||||
refreshTaskReview: vi.fn(),
|
||||
reviseTaskReviewItems: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
addressPrFeedback: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
@@ -22,6 +23,7 @@ vi.mock("../../api", () => ({
|
||||
refreshTaskReview: apiMocks.refreshTaskReview,
|
||||
reviseTaskReviewItems: apiMocks.reviseTaskReviewItems,
|
||||
updateTask: apiMocks.updateTask,
|
||||
addressPrFeedback: apiMocks.addressPrFeedback,
|
||||
}));
|
||||
|
||||
async function renderWithAct(ui: Parameters<typeof rtlRender>[0]) {
|
||||
@@ -132,6 +134,131 @@ describe("TaskReviewTab", () => {
|
||||
expect(screen.getByText("No checks reported")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Address PR feedback without a linked PR or actionable feedback", async () => {
|
||||
const noPrTask = makeTask({
|
||||
prInfo: undefined,
|
||||
reviewState: {
|
||||
source: "pull-request",
|
||||
summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [], blockingReasons: [], checks: [] },
|
||||
items: [{ id: "ri-1", body: "Fix it", author: { login: "reviewer" }, createdAt: "2026-06-27T00:00:00.000Z" }],
|
||||
addressing: [],
|
||||
},
|
||||
});
|
||||
apiMocks.fetchTaskReview.mockResolvedValueOnce({ reviewState: noPrTask.reviewState, automationStatus: null, emptyMessage: null });
|
||||
const { unmount } = await renderWithAct(<TaskReviewTab task={noPrTask} addToast={vi.fn()} />);
|
||||
expect(screen.queryByTestId("task-review-address-pr-feedback")).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
const noFeedbackTask = makeTask({
|
||||
prInfo: {
|
||||
number: 42,
|
||||
url: "https://github.com/acme/repo/pull/42",
|
||||
status: "open",
|
||||
title: "Feature PR",
|
||||
headBranch: "feature",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
lastReviewDecision: "APPROVED",
|
||||
},
|
||||
reviewState: {
|
||||
source: "pull-request",
|
||||
summary: { reviewDecision: "APPROVED", reviewers: [], blockingReasons: [], checks: [] },
|
||||
items: [],
|
||||
addressing: [],
|
||||
},
|
||||
});
|
||||
apiMocks.fetchTaskReview.mockResolvedValueOnce({ reviewState: noFeedbackTask.reviewState, automationStatus: null, emptyMessage: null });
|
||||
const noFeedbackRender = await renderWithAct(<TaskReviewTab task={noFeedbackTask} addToast={vi.fn()} />);
|
||||
expect(screen.queryByTestId("task-review-address-pr-feedback")).not.toBeInTheDocument();
|
||||
noFeedbackRender.unmount();
|
||||
|
||||
const unsupportedColumnTask = makeTask({
|
||||
column: "done",
|
||||
prInfo: {
|
||||
number: 43,
|
||||
url: "https://github.com/acme/repo/pull/43",
|
||||
status: "open",
|
||||
title: "Feedback on done task",
|
||||
headBranch: "feature-done",
|
||||
baseBranch: "main",
|
||||
commentCount: 2,
|
||||
lastReviewDecision: "CHANGES_REQUESTED",
|
||||
},
|
||||
reviewState: {
|
||||
source: "pull-request",
|
||||
summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [], blockingReasons: [], checks: [] },
|
||||
items: [{ id: "ri-2", body: "Fix it", author: { login: "reviewer" }, createdAt: "2026-06-27T00:00:00.000Z" }],
|
||||
addressing: [],
|
||||
},
|
||||
});
|
||||
apiMocks.fetchTaskReview.mockResolvedValueOnce({ reviewState: unsupportedColumnTask.reviewState, automationStatus: null, emptyMessage: null });
|
||||
await renderWithAct(<TaskReviewTab task={unsupportedColumnTask} addToast={vi.fn()} />);
|
||||
expect(screen.queryByTestId("task-review-address-pr-feedback")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("starts Address PR feedback from PR mode when actionable feedback exists", async () => {
|
||||
const addToast = vi.fn();
|
||||
const onTaskUpdated = vi.fn();
|
||||
const task = makeTask({
|
||||
prInfo: {
|
||||
number: 42,
|
||||
url: "https://github.com/acme/repo/pull/42",
|
||||
status: "open",
|
||||
title: "Feature PR",
|
||||
headBranch: "feature",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
lastReviewDecision: "CHANGES_REQUESTED",
|
||||
},
|
||||
reviewState: {
|
||||
source: "pull-request",
|
||||
summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [], blockingReasons: [], checks: [] },
|
||||
items: [],
|
||||
addressing: [],
|
||||
},
|
||||
});
|
||||
const updatedTask = { ...task, column: "in-progress" };
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
apiMocks.addressPrFeedback.mockResolvedValue({ task: updatedTask });
|
||||
|
||||
await renderWithAct(<TaskReviewTab task={task} addToast={addToast} onTaskUpdated={onTaskUpdated} projectId="proj-1" />);
|
||||
fireEvent.click(await screen.findByTestId("task-review-address-pr-feedback"));
|
||||
|
||||
await waitFor(() => expect(apiMocks.addressPrFeedback).toHaveBeenCalledWith(task.id, "proj-1"));
|
||||
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
|
||||
expect(addToast).toHaveBeenCalledWith("Addressing PR feedback — AI session started", "success");
|
||||
});
|
||||
|
||||
it("shows Address PR feedback errors", async () => {
|
||||
const addToast = vi.fn();
|
||||
const task = makeTask({
|
||||
prInfo: {
|
||||
number: 42,
|
||||
url: "https://github.com/acme/repo/pull/42",
|
||||
status: "open",
|
||||
title: "Feature PR",
|
||||
headBranch: "feature",
|
||||
baseBranch: "main",
|
||||
commentCount: 2,
|
||||
lastReviewDecision: "REVIEW_REQUIRED",
|
||||
},
|
||||
reviewState: {
|
||||
source: "pull-request",
|
||||
summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [] },
|
||||
items: [],
|
||||
addressing: [],
|
||||
},
|
||||
});
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
apiMocks.addressPrFeedback.mockRejectedValue(new Error("Cannot wake agent"));
|
||||
|
||||
await renderWithAct(<TaskReviewTab task={task} addToast={addToast} />);
|
||||
fireEvent.click(await screen.findByTestId("task-review-address-pr-feedback"));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Cannot wake agent", "error"));
|
||||
expect(await screen.findByText("Cannot wake agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders PR decision, reviewers, checks, blockers, and per-item GitHub metadata", async () => {
|
||||
const task = makeTask({
|
||||
reviewState: {
|
||||
|
||||
22
packages/dashboard/app/utils/prFeedback.ts
Normal file
22
packages/dashboard/app/utils/prFeedback.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { PrInfo, Task } from "@fusion/core";
|
||||
|
||||
export function getTaskPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
|
||||
return task.prInfos?.[0] ?? task.prInfo;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskReview 2026-06-28-00:00:
|
||||
The Address PR feedback affordance must render identically on the task card and Review tab. Gate it on one shared predicate so a linked primary PR with comments or CHANGES_REQUESTED is actionable, while no-PR and no-feedback states render no empty button shell.
|
||||
|
||||
FNXC:TaskReview 2026-06-28-16:39:
|
||||
The button promises an AI session starts, so it must only render for task states the lifecycle route can actually start or wake. Restrict the launch affordance to in-review and in-progress tasks rather than letting terminal/todo cards add steering comments without active work.
|
||||
*/
|
||||
export function hasActionablePrFeedback(task: Pick<Task, "prInfo" | "prInfos">): boolean {
|
||||
const prInfo = getTaskPrimaryPrInfo(task);
|
||||
if (!prInfo) return false;
|
||||
return (prInfo.commentCount ?? 0) > 0 || prInfo.lastReviewDecision === "CHANGES_REQUESTED";
|
||||
}
|
||||
|
||||
export function canStartPrFeedbackAddressing(task: Pick<Task, "column" | "prInfo" | "prInfos">): boolean {
|
||||
return (task.column === "in-review" || task.column === "in-progress") && hasActionablePrFeedback(task);
|
||||
}
|
||||
@@ -220,6 +220,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
linkGithubIssue: vi.fn().mockResolvedValue(undefined),
|
||||
recordActivity: vi.fn().mockResolvedValue(undefined),
|
||||
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
getDistributedTaskIdAllocator: vi.fn().mockReturnValue({
|
||||
reserveDistributedTaskId: vi.fn().mockResolvedValue({ reservationId: "res-1", taskId: "FN-7001" }),
|
||||
@@ -2778,3 +2779,167 @@ describe("POST /tasks/:id/review/address", () => {
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/pr/address-feedback", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore({ updateStep: vi.fn() } as unknown as Partial<TaskStore>);
|
||||
});
|
||||
|
||||
function buildApp(options?: Parameters<typeof createApiRoutes>[1]) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, options));
|
||||
return app;
|
||||
}
|
||||
|
||||
const linkedPr = {
|
||||
number: 42,
|
||||
url: "https://github.com/acme/repo/pull/42",
|
||||
status: "open",
|
||||
title: "Feature PR",
|
||||
headBranch: "feature/fn-7185",
|
||||
baseBranch: "main",
|
||||
commentCount: 3,
|
||||
lastReviewDecision: "CHANGES_REQUESTED" as const,
|
||||
};
|
||||
|
||||
it("rejects tasks without a linked pull request", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "FN-001", prInfo: undefined, prInfos: undefined });
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/pr/address-feedback", "{}", { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("linked pull request");
|
||||
expect(store.addSteeringComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects linked PR tasks outside startable columns before recording steering", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-001",
|
||||
column: "done",
|
||||
prInfo: linkedPr,
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/pr/address-feedback", "{}", { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("in-review or in-progress");
|
||||
expect(store.addSteeringComment).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves in-review tasks to in-progress and records steering plus log context", async () => {
|
||||
const inReviewTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-001",
|
||||
column: "in-review",
|
||||
status: "awaiting-user-review",
|
||||
error: "previous error",
|
||||
sessionFile: "session.json",
|
||||
assignedAgentId: null,
|
||||
prInfo: linkedPr,
|
||||
steps: [
|
||||
{ id: "s1", title: "Step 1", status: "done" },
|
||||
{ id: "s2", title: "Step 2", status: "pending" },
|
||||
],
|
||||
};
|
||||
const afterSteering = { ...inReviewTask, steeringComments: [{ id: "sc-1", body: "x", author: "user", createdAt: "2026-06-28T00:00:00.000Z" }] };
|
||||
const movedTask = { ...afterSteering, column: "in-progress", status: null, error: null, sessionFile: null };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(inReviewTask).mockResolvedValueOnce(afterSteering);
|
||||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "sc-1" });
|
||||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/pr/address-feedback", "{}", { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.addSteeringComment).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.stringContaining("Run /ce-resolve-pr-feedback to resolve open PR review feedback"),
|
||||
"user",
|
||||
);
|
||||
expect(store.addSteeringComment).toHaveBeenCalledWith("FN-001", expect.stringContaining("PR #42 https://github.com/acme/repo/pull/42"), "user");
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: null, error: null, sessionFile: null });
|
||||
expect(store.updateStep).toHaveBeenCalledWith("FN-001", 0, "pending");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress", { preserveProgress: true });
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Address PR feedback requested", expect.stringContaining("PR #42"));
|
||||
expect(res.body.task.column).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("wakes the assigned agent for in-progress tasks with no active session", async () => {
|
||||
const task = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-001",
|
||||
column: "in-progress",
|
||||
assignedAgentId: "agent-1",
|
||||
sessionFile: null,
|
||||
prInfo: linkedPr,
|
||||
};
|
||||
const executeHeartbeat = vi.fn().mockResolvedValue({ id: "run-1" });
|
||||
const initSpy = vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
|
||||
const getAgentSpy = vi.spyOn(AgentStore.prototype, "getAgent").mockResolvedValue({
|
||||
id: "agent-1",
|
||||
name: "Executor",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
runtimeConfig: { messageResponseMode: "immediate" },
|
||||
} as any);
|
||||
const activeRunSpy = vi.spyOn(AgentStore.prototype, "getActiveHeartbeatRun").mockResolvedValue(null);
|
||||
(store.getFusionDir as ReturnType<typeof vi.fn>).mockReturnValue("/fake/root/.fusion");
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task).mockResolvedValueOnce(task);
|
||||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "sc-1" });
|
||||
|
||||
try {
|
||||
const res = await REQUEST(
|
||||
buildApp({
|
||||
heartbeatMonitor: {
|
||||
rootDir: "/fake/root",
|
||||
startRun: vi.fn(),
|
||||
executeHeartbeat,
|
||||
stopRun: vi.fn(),
|
||||
},
|
||||
}),
|
||||
"POST",
|
||||
"/api/tasks/FN-001/pr/address-feedback",
|
||||
"{}",
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(executeHeartbeat).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agentId: "agent-1",
|
||||
source: "on_demand",
|
||||
taskId: "FN-001",
|
||||
triggerDetail: "pr-address-feedback",
|
||||
triggeringCommentIds: ["sc-1"],
|
||||
triggeringCommentType: "steering",
|
||||
}));
|
||||
} finally {
|
||||
initSpy.mockRestore();
|
||||
getAgentSpy.mockRestore();
|
||||
activeRunSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps in-progress tasks in place while adding the skill steering prompt", async () => {
|
||||
const task = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-001",
|
||||
column: "in-progress",
|
||||
assignedAgentId: "agent-1",
|
||||
sessionFile: "active.json",
|
||||
prInfos: [linkedPr],
|
||||
};
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task).mockResolvedValueOnce(task);
|
||||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "sc-1" });
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/pr/address-feedback", "{}", { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.addSteeringComment).toHaveBeenCalledWith("FN-001", expect.stringContaining("ce-resolve-pr-feedback"), "user");
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Address PR feedback requested", expect.stringContaining("ce-resolve-pr-feedback"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)
|
||||
const REVIEW_STEP_RE = /^(plan|code) review Step (\d+): (APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
||||
const DUPLICATE_STOPWORDS = new Set(["a", "an", "the", "and", "or", "of", "to", "for", "in", "is", "on", "with", "fn"]);
|
||||
const ARTIFACT_TYPES = new Set<ArtifactType>(["document", "image", "video", "audio", "other"]);
|
||||
const ADDRESS_PR_FEEDBACK_PROMPT = "Run /ce-resolve-pr-feedback to resolve open PR review feedback: evaluate each thread, fix valid issues, and reply.";
|
||||
|
||||
function isArtifactType(value: string): value is ArtifactType {
|
||||
return ARTIFACT_TYPES.has(value as ArtifactType);
|
||||
@@ -3749,6 +3750,72 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/tasks/:id/pr/address-feedback", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const task = await scopedStore.getTask(req.params.id);
|
||||
const prInfo = task.prInfo ?? task.prInfos?.[0];
|
||||
if (!prInfo) {
|
||||
throw badRequest("Task must have a linked pull request before PR feedback can be addressed");
|
||||
}
|
||||
if (task.column !== "in-review" && task.column !== "in-progress") {
|
||||
throw badRequest("PR feedback can only be addressed for in-review or in-progress tasks");
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskReview 2026-06-28-00:00:
|
||||
The manual Address PR feedback route must seed only Fusion-authored instructions plus PR identity. PR review text is untrusted and stays data fetched by ce-resolve-pr-feedback, so this lifecycle trigger cannot execute reviewer-provided directives while waking the assigned agent.
|
||||
|
||||
FNXC:TaskReview 2026-06-28-16:39:
|
||||
The route response and dashboard toasts say an AI session started. Reject unsupported columns before writing steering/log entries so todo, done, and archived tasks cannot report success while no session is scheduled.
|
||||
*/
|
||||
const prLabel = `PR #${prInfo.number}`;
|
||||
const steeringText = [
|
||||
ADDRESS_PR_FEEDBACK_PROMPT,
|
||||
"If the compound-engineering skill is unavailable, inspect the linked pull request, evaluate each unresolved review thread, fix valid issues, reply with what changed or why no change was made, and resolve threads that are fully addressed.",
|
||||
`Context: ${prLabel} ${prInfo.url}`,
|
||||
].join("\n\n");
|
||||
|
||||
const steeringComment = await scopedStore.addSteeringComment(task.id, steeringText, "user");
|
||||
const steeringCommentId = steeringComment.id;
|
||||
|
||||
let updatedTask: Task = await scopedStore.getTask(task.id);
|
||||
|
||||
if (task.column === "in-review") {
|
||||
await scopedStore.updateTask(task.id, {
|
||||
status: null,
|
||||
error: null,
|
||||
sessionFile: null,
|
||||
});
|
||||
const lastDoneStep = [...task.steps]
|
||||
.map((step, index) => ({ step, index }))
|
||||
.reverse()
|
||||
.find(({ step }) => step.status === "done" || step.status === "in-progress");
|
||||
if (lastDoneStep) {
|
||||
await scopedStore.updateStep(task.id, lastDoneStep.index, "pending");
|
||||
}
|
||||
updatedTask = await scopedStore.moveTask(task.id, "in-progress", { preserveProgress: true });
|
||||
}
|
||||
|
||||
const hasActiveSession = Boolean(updatedTask.sessionFile);
|
||||
if (updatedTask.column === "in-progress" && updatedTask.assignedAgentId && !hasActiveSession) {
|
||||
await triggerCommentWakeForAssignedAgent(scopedStore, updatedTask, {
|
||||
triggeringCommentType: "steering",
|
||||
triggeringCommentIds: [steeringCommentId],
|
||||
triggerDetail: "pr-address-feedback",
|
||||
});
|
||||
}
|
||||
|
||||
await scopedStore.logEntry(task.id, "Address PR feedback requested", `${prLabel} queued via ce-resolve-pr-feedback skill prompt`);
|
||||
res.json({ task: updatedTask });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Return task to agent - clear assignee and status, move to todo
|
||||
router.post("/tasks/:id/return-to-agent", async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -7799,6 +7799,10 @@
|
||||
"autoMergeOff": "Auto-merge off",
|
||||
"autoMergeOn": "Auto-merge on",
|
||||
"autoMergePreferenceUpdated": "Per-task auto-merge preference updated",
|
||||
"addressPrFeedback": "Address PR feedback",
|
||||
"addressPrFeedbackFailed": "Failed to start PR feedback session",
|
||||
"addressPrFeedbackStarted": "Addressing PR feedback — AI session started",
|
||||
"addressingPrFeedback": "Addressing…",
|
||||
"completedAtSep": " · Completed: {{timestamp}}",
|
||||
"createPr": "Create PR",
|
||||
"effective": "Effective: {{label}}",
|
||||
@@ -7836,6 +7840,12 @@
|
||||
},
|
||||
"tasks": {
|
||||
"addTaskPlaceholder": "Add a task...",
|
||||
"addressPrFeedback": "Address PR feedback",
|
||||
"addressPrFeedbackAriaLabel": "Address PR feedback",
|
||||
"addressPrFeedbackFailed": "Failed to start PR feedback session: {{error}}",
|
||||
"addressPrFeedbackStarted": "Addressing PR feedback — AI session started",
|
||||
"addressPrFeedbackTitle": "Start an AI session to address PR feedback",
|
||||
"addressingPrFeedback": "Addressing…",
|
||||
"agent": "Agent",
|
||||
"agentLabel": "Agent",
|
||||
"answerQuestions": "Answer questions",
|
||||
|
||||
@@ -4814,7 +4814,13 @@
|
||||
"reviewLabel": "",
|
||||
"threadFixed": "",
|
||||
"threadPending": "",
|
||||
"verifyingGithub": ""
|
||||
"verifyingGithub": "",
|
||||
"backToList": "",
|
||||
"listEmpty": "",
|
||||
"listError": "",
|
||||
"listItemLabel": "",
|
||||
"listLoading": "",
|
||||
"listTitle": ""
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
@@ -7816,7 +7822,11 @@
|
||||
"showRawText": "Mostrar texto sin procesar",
|
||||
"startedAtSep": " · Iniciado: {{timestamp}}",
|
||||
"updateFailed": "Error al actualizar {{taskId}}: {{error}}",
|
||||
"upToDate": "Actualizado"
|
||||
"upToDate": "Actualizado",
|
||||
"addressPrFeedback": "",
|
||||
"addressPrFeedbackFailed": "",
|
||||
"addressPrFeedbackStarted": "",
|
||||
"addressingPrFeedback": ""
|
||||
},
|
||||
"tasks": {
|
||||
"addTaskPlaceholder": "Añadir una tarea...",
|
||||
@@ -8014,7 +8024,13 @@
|
||||
"usingDefault": "Usando el predeterminado",
|
||||
"viewDependency": "Clic para ver {{depId}}",
|
||||
"workflow": "flujo de trabajo",
|
||||
"workflowCheck": "Verificación de flujo de trabajo"
|
||||
"workflowCheck": "Verificación de flujo de trabajo",
|
||||
"addressPrFeedback": "",
|
||||
"addressPrFeedbackAriaLabel": "",
|
||||
"addressPrFeedbackFailed": "",
|
||||
"addressPrFeedbackStarted": "",
|
||||
"addressPrFeedbackTitle": "",
|
||||
"addressingPrFeedback": ""
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "",
|
||||
|
||||
@@ -4814,7 +4814,13 @@
|
||||
"reviewLabel": "",
|
||||
"threadFixed": "",
|
||||
"threadPending": "",
|
||||
"verifyingGithub": ""
|
||||
"verifyingGithub": "",
|
||||
"backToList": "",
|
||||
"listEmpty": "",
|
||||
"listError": "",
|
||||
"listItemLabel": "",
|
||||
"listLoading": "",
|
||||
"listTitle": ""
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
@@ -7816,7 +7822,11 @@
|
||||
"showRawText": "Afficher le texte brut",
|
||||
"startedAtSep": " · Démarré : {{timestamp}}",
|
||||
"updateFailed": "Échec de la mise à jour {{taskId}} : {{error}}",
|
||||
"upToDate": "À jour"
|
||||
"upToDate": "À jour",
|
||||
"addressPrFeedback": "",
|
||||
"addressPrFeedbackFailed": "",
|
||||
"addressPrFeedbackStarted": "",
|
||||
"addressingPrFeedback": ""
|
||||
},
|
||||
"tasks": {
|
||||
"addTaskPlaceholder": "Ajouter une tâche…",
|
||||
@@ -8014,7 +8024,13 @@
|
||||
"usingDefault": "Valeur par défaut",
|
||||
"viewDependency": "Cliquer pour afficher {{depId}}",
|
||||
"workflow": "workflow",
|
||||
"workflowCheck": "Vérification du workflow"
|
||||
"workflowCheck": "Vérification du workflow",
|
||||
"addressPrFeedback": "",
|
||||
"addressPrFeedbackAriaLabel": "",
|
||||
"addressPrFeedbackFailed": "",
|
||||
"addressPrFeedbackStarted": "",
|
||||
"addressPrFeedbackTitle": "",
|
||||
"addressingPrFeedback": ""
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "",
|
||||
|
||||
@@ -4814,7 +4814,13 @@
|
||||
"reviewLabel": "",
|
||||
"threadFixed": "",
|
||||
"threadPending": "",
|
||||
"verifyingGithub": ""
|
||||
"verifyingGithub": "",
|
||||
"backToList": "",
|
||||
"listEmpty": "",
|
||||
"listError": "",
|
||||
"listItemLabel": "",
|
||||
"listLoading": "",
|
||||
"listTitle": ""
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
@@ -7816,7 +7822,11 @@
|
||||
"showRawText": "원시 텍스트 표시",
|
||||
"startedAtSep": " · 시작: {{timestamp}}",
|
||||
"updateFailed": "{{taskId}} 업데이트에 실패했습니다: {{error}}",
|
||||
"upToDate": "최신 상태"
|
||||
"upToDate": "최신 상태",
|
||||
"addressPrFeedback": "",
|
||||
"addressPrFeedbackFailed": "",
|
||||
"addressPrFeedbackStarted": "",
|
||||
"addressingPrFeedback": ""
|
||||
},
|
||||
"tasks": {
|
||||
"addTaskPlaceholder": "작업 추가...",
|
||||
@@ -8014,7 +8024,13 @@
|
||||
"usingDefault": "기본값 사용 중",
|
||||
"viewDependency": "클릭하여 {{depId}} 보기",
|
||||
"workflow": "워크플로우",
|
||||
"workflowCheck": "워크플로우 확인"
|
||||
"workflowCheck": "워크플로우 확인",
|
||||
"addressPrFeedback": "",
|
||||
"addressPrFeedbackAriaLabel": "",
|
||||
"addressPrFeedbackFailed": "",
|
||||
"addressPrFeedbackStarted": "",
|
||||
"addressPrFeedbackTitle": "",
|
||||
"addressingPrFeedback": ""
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "",
|
||||
|
||||
@@ -4814,7 +4814,13 @@
|
||||
"reviewLabel": "",
|
||||
"threadFixed": "",
|
||||
"threadPending": "",
|
||||
"verifyingGithub": ""
|
||||
"verifyingGithub": "",
|
||||
"backToList": "",
|
||||
"listEmpty": "",
|
||||
"listError": "",
|
||||
"listItemLabel": "",
|
||||
"listLoading": "",
|
||||
"listTitle": ""
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
@@ -7816,7 +7822,11 @@
|
||||
"showRawText": "显示原始文本",
|
||||
"startedAtSep": " · 已开始:{{timestamp}}",
|
||||
"updateFailed": "更新 {{taskId}} 失败:{{error}}",
|
||||
"upToDate": "最新"
|
||||
"upToDate": "最新",
|
||||
"addressPrFeedback": "",
|
||||
"addressPrFeedbackFailed": "",
|
||||
"addressPrFeedbackStarted": "",
|
||||
"addressingPrFeedback": ""
|
||||
},
|
||||
"tasks": {
|
||||
"addTaskPlaceholder": "添加任务……",
|
||||
@@ -8014,7 +8024,13 @@
|
||||
"usingDefault": "使用默认",
|
||||
"viewDependency": "点击查看 {{depId}}",
|
||||
"workflow": "工作流",
|
||||
"workflowCheck": "工作流检查"
|
||||
"workflowCheck": "工作流检查",
|
||||
"addressPrFeedback": "",
|
||||
"addressPrFeedbackAriaLabel": "",
|
||||
"addressPrFeedbackFailed": "",
|
||||
"addressPrFeedbackStarted": "",
|
||||
"addressPrFeedbackTitle": "",
|
||||
"addressingPrFeedback": ""
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "",
|
||||
|
||||
@@ -4814,7 +4814,13 @@
|
||||
"reviewLabel": "",
|
||||
"threadFixed": "",
|
||||
"threadPending": "",
|
||||
"verifyingGithub": ""
|
||||
"verifyingGithub": "",
|
||||
"backToList": "",
|
||||
"listEmpty": "",
|
||||
"listError": "",
|
||||
"listItemLabel": "",
|
||||
"listLoading": "",
|
||||
"listTitle": ""
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
@@ -7816,7 +7822,11 @@
|
||||
"showRawText": "顯示原始文字",
|
||||
"startedAtSep": " · 已開始:{{timestamp}}",
|
||||
"updateFailed": "更新 {{taskId}} 失敗:{{error}}",
|
||||
"upToDate": "最新"
|
||||
"upToDate": "最新",
|
||||
"addressPrFeedback": "",
|
||||
"addressPrFeedbackFailed": "",
|
||||
"addressPrFeedbackStarted": "",
|
||||
"addressingPrFeedback": ""
|
||||
},
|
||||
"tasks": {
|
||||
"addTaskPlaceholder": "新增任務……",
|
||||
@@ -8014,7 +8024,13 @@
|
||||
"usingDefault": "使用預設",
|
||||
"viewDependency": "點擊查看 {{depId}}",
|
||||
"workflow": "工作流程",
|
||||
"workflowCheck": "工作流程檢查"
|
||||
"workflowCheck": "工作流程檢查",
|
||||
"addressPrFeedback": "",
|
||||
"addressPrFeedbackAriaLabel": "",
|
||||
"addressPrFeedbackFailed": "",
|
||||
"addressPrFeedbackStarted": "",
|
||||
"addressPrFeedbackTitle": "",
|
||||
"addressingPrFeedback": ""
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "",
|
||||
|
||||
Reference in New Issue
Block a user