feat(FN-5139): wire lineage unlink delete and archive flows
Fusion-Task-Id: FN-5139 Fusion-Task-Lineage: c16f4245-bfda-4b9a-bd98-ffc44b45f5a0
This commit is contained in:
committed by
gsxdsm
parent
2d42476dac
commit
e6c7dcdd64
@@ -693,9 +693,15 @@ The dashboard server exposes a REST API at `/api`:
|
||||
- `POST /api/tasks/:id/move` - Move task to column
|
||||
- `POST /api/tasks/:id/pause` - Pause task
|
||||
- `POST /api/tasks/:id/unpause` - Unpause task
|
||||
- `DELETE /api/tasks/:id` - Delete task. Default mode is safe: if other tasks still reference this ID in `dependencies`, the route returns `409` with `{ error, details: { code: "TASK_HAS_DEPENDENTS", taskId, dependentIds } }`.
|
||||
- To explicitly remove those incoming dependency references and then delete, call `DELETE /api/tasks/:id?removeDependencyReferences=true`.
|
||||
- This opt-in path rewrites each dependent task's `dependencies` array atomically before deleting the target task, so no live task is left pointing at a missing task ID.
|
||||
- `DELETE /api/tasks/:id` - Delete task. Default mode is safe:
|
||||
- If other tasks still reference this ID in `dependencies`, the route returns `409` with `{ error, details: { code: "TASK_HAS_DEPENDENTS", taskId, dependentIds } }`.
|
||||
- If other live tasks still reference this ID as `sourceParentTaskId`, the route returns `409` with `{ error, details: { code: "TASK_HAS_LINEAGE_CHILDREN", taskId, lineageChildIds } }`.
|
||||
- To explicitly remove incoming dependency references and then delete, call `DELETE /api/tasks/:id?removeDependencyReferences=true`.
|
||||
- To explicitly remove incoming lineage references and then delete, call `DELETE /api/tasks/:id?removeLineageReferences=true`.
|
||||
- Both opt-in paths rewrite the referencing tasks atomically before deleting the target task, so no live task is left pointing at a missing task ID.
|
||||
- `POST /api/tasks/:id/archive` - Archive a done task.
|
||||
- Default mode is safe: if live lineage children still reference this task as `sourceParentTaskId`, the route returns `409` with `{ error, details: { code: "TASK_HAS_LINEAGE_CHILDREN", taskId, lineageChildIds } }`.
|
||||
- To unlink those lineage references first, call `POST /api/tasks/:id/archive?removeLineageReferences=true`.
|
||||
|
||||
### Git Operations
|
||||
- `GET /api/git/status` - Current branch and status
|
||||
|
||||
@@ -510,6 +510,18 @@ describe("Git Management API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends removeLineageReferences=true when archive lineage unlink is requested", async () => {
|
||||
const archivedTask: Task = { ...FAKE_DETAIL, column: "archived" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, archivedTask));
|
||||
|
||||
await archiveTask("FN-001", undefined, { removeLineageReferences: true });
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/archive?removeLineageReferences=true", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Task not in done" }, 400));
|
||||
|
||||
@@ -576,6 +588,18 @@ describe("Git Management API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends removeLineageReferences=true when lineage unlink is requested", async () => {
|
||||
const deletedTask: Task = { ...FAKE_DETAIL, column: "done" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, deletedTask));
|
||||
|
||||
await deleteTask("FN-001", undefined, { removeLineageReferences: true });
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001?removeLineageReferences=true", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws ApiRequestError on error and preserves details payload", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(
|
||||
|
||||
@@ -109,9 +109,14 @@ export class ApiRequestError extends Error {
|
||||
/** Options that shape the soft-delete request payload/query, not hard-delete behavior. */
|
||||
export interface DeleteTaskOptions {
|
||||
removeDependencyReferences?: boolean;
|
||||
removeLineageReferences?: boolean;
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
}
|
||||
|
||||
export interface ArchiveTaskOptions {
|
||||
removeLineageReferences?: boolean;
|
||||
}
|
||||
|
||||
function looksLikeHtml(body: string): boolean {
|
||||
const trimmed = body.trim();
|
||||
return trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
|
||||
@@ -517,6 +522,9 @@ export function deleteTask(id: string, projectId?: string, options?: DeleteTaskO
|
||||
if (options?.removeDependencyReferences) {
|
||||
search.set("removeDependencyReferences", "true");
|
||||
}
|
||||
if (options?.removeLineageReferences) {
|
||||
search.set("removeLineageReferences", "true");
|
||||
}
|
||||
if (options?.githubIssueAction) {
|
||||
search.set("githubIssueAction", options.githubIssueAction);
|
||||
}
|
||||
@@ -561,8 +569,14 @@ export function unpauseTask(id: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/unpause`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
export function archiveTask(id: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/archive`, projectId), { method: "POST" });
|
||||
export function archiveTask(id: string, projectId?: string, options?: ArchiveTaskOptions): Promise<Task> {
|
||||
const search = new URLSearchParams();
|
||||
if (options?.removeLineageReferences) {
|
||||
search.set("removeLineageReferences", "true");
|
||||
}
|
||||
|
||||
const suffix = search.size > 0 ? `?${search.toString()}` : "";
|
||||
return api<Task>(withProjectId(`/tasks/${id}/archive${suffix}`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
export function unarchiveTask(id: string, projectId?: string): Promise<Task> {
|
||||
|
||||
@@ -54,9 +54,13 @@ interface AppModalsProps {
|
||||
taskHandlers: Pick<UseTaskHandlersResult, "handleModalCreate" | "handlePlanningTaskCreated" | "handlePlanningTasksCreated" | "handleSubtaskTasksCreated" | "handleGitHubImport">;
|
||||
taskOperations: {
|
||||
moveTask: (taskId: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
||||
deleteTask: (taskId: string, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }) => Promise<Task>;
|
||||
deleteTask: (taskId: string, options?: {
|
||||
removeDependencyReferences?: boolean;
|
||||
removeLineageReferences?: boolean;
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
}) => Promise<Task>;
|
||||
mergeTask: (taskId: string) => Promise<MergeResult>;
|
||||
archiveTask: (taskId: string) => Promise<Task>;
|
||||
archiveTask: (taskId: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||
retryTask: (taskId: string) => Promise<Task>;
|
||||
resetTask: (taskId: string) => Promise<Task>;
|
||||
duplicateTask: (taskId: string) => Promise<Task>;
|
||||
|
||||
@@ -25,9 +25,13 @@ interface BoardProps {
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }) => Promise<Task>;
|
||||
onDeleteTask?: (id: string, options?: {
|
||||
removeDependencyReferences?: boolean;
|
||||
removeLineageReferences?: boolean;
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
}) => Promise<Task>;
|
||||
onArchiveAllDone?: () => Promise<Task[]>;
|
||||
/** Lazy-load archived tasks. Called the first time the user expands the archived column. */
|
||||
onLoadArchivedTasks?: () => Promise<void>;
|
||||
|
||||
@@ -36,9 +36,13 @@ interface ColumnProps {
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }) => Promise<Task>;
|
||||
onDeleteTask?: (id: string, options?: {
|
||||
removeDependencyReferences?: boolean;
|
||||
removeLineageReferences?: boolean;
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
}) => Promise<Task>;
|
||||
onArchiveAllDone?: () => Promise<Task[]>;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { getUnifiedTaskProgress } from "../utils/taskProgress";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
|
||||
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
triage: "var(--triage)",
|
||||
@@ -180,10 +180,14 @@ interface ListViewProps {
|
||||
tasks: Task[];
|
||||
onMoveTask: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }) => Promise<Task>;
|
||||
onDeleteTask: (id: string, options?: {
|
||||
removeDependencyReferences?: boolean;
|
||||
removeLineageReferences?: boolean;
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
}) => Promise<Task>;
|
||||
onPauseTask?: (id: string) => Promise<Task>;
|
||||
onUnpauseTask?: (id: string) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||
onResetTask?: (id: string) => Promise<Task>;
|
||||
onDuplicateTask?: (id: string) => Promise<Task>;
|
||||
@@ -745,8 +749,34 @@ export function ListView({
|
||||
try {
|
||||
await onArchiveTask(task.id);
|
||||
archivedIds.push(task.id);
|
||||
} catch {
|
||||
failedIds.push(task.id);
|
||||
} catch (err) {
|
||||
const lineageConflict = extractLineageDeleteConflict(err);
|
||||
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
|
||||
failedIds.push(task.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const confirmedArchive = await confirm({
|
||||
title: "Force Delete Task",
|
||||
message:
|
||||
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
|
||||
"Archive anyway by unlinking these references first?",
|
||||
confirmLabel: "Archive",
|
||||
cancelLabel: "Skip",
|
||||
danger: true,
|
||||
});
|
||||
|
||||
if (!confirmedArchive) {
|
||||
failedIds.push(task.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await onArchiveTask(task.id, { removeLineageReferences: true });
|
||||
archivedIds.push(task.id);
|
||||
} catch {
|
||||
failedIds.push(task.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -756,15 +786,73 @@ export function ListView({
|
||||
await onDeleteTask(task.id);
|
||||
deletedIds.push(task.id);
|
||||
} catch (err) {
|
||||
const conflict = extractDependencyDeleteConflict(err);
|
||||
if (!conflict) {
|
||||
const dependencyConflict = extractDependencyDeleteConflict(err);
|
||||
if (dependencyConflict) {
|
||||
const forceDelete = await confirm({
|
||||
title: "Force Delete Task",
|
||||
message: `Task ${task.id} has dependents: ${dependencyConflict.dependentIds.join(", ")}. Remove dependency references and force delete?`,
|
||||
confirmLabel: "Force Delete",
|
||||
cancelLabel: "Skip",
|
||||
danger: true,
|
||||
});
|
||||
|
||||
if (!forceDelete) {
|
||||
failedIds.push(task.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await onDeleteTask(task.id, {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
});
|
||||
deletedIds.push(task.id);
|
||||
} catch (retryErr) {
|
||||
const lineageConflict = extractLineageDeleteConflict(retryErr);
|
||||
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
|
||||
failedIds.push(task.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const forceLineageDelete = await confirm({
|
||||
title: "Force Delete Task",
|
||||
message:
|
||||
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
|
||||
"Delete anyway by unlinking these references first?",
|
||||
confirmLabel: "Force Delete",
|
||||
cancelLabel: "Skip",
|
||||
danger: true,
|
||||
});
|
||||
|
||||
if (!forceLineageDelete) {
|
||||
failedIds.push(task.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await onDeleteTask(task.id, {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
});
|
||||
deletedIds.push(task.id);
|
||||
} catch {
|
||||
failedIds.push(task.id);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const lineageConflict = extractLineageDeleteConflict(err);
|
||||
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
|
||||
failedIds.push(task.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const forceDelete = await confirm({
|
||||
title: "Force Delete Task",
|
||||
message: `Task ${task.id} has dependents: ${conflict.dependentIds.join(", ")}. Remove dependency references and force delete?`,
|
||||
message:
|
||||
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
|
||||
"Delete anyway by unlinking these references first?",
|
||||
confirmLabel: "Force Delete",
|
||||
cancelLabel: "Skip",
|
||||
danger: true,
|
||||
@@ -776,7 +864,10 @@ export function ListView({
|
||||
}
|
||||
|
||||
try {
|
||||
await onDeleteTask(task.id, { removeDependencyReferences: true });
|
||||
await onDeleteTask(task.id, {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
});
|
||||
deletedIds.push(task.id);
|
||||
} catch {
|
||||
failedIds.push(task.id);
|
||||
@@ -946,8 +1037,34 @@ export function ListView({
|
||||
try {
|
||||
await onArchiveTask(task.id);
|
||||
archivedIds.push(task.id);
|
||||
} catch {
|
||||
failedIds.push(task.id);
|
||||
} catch (err) {
|
||||
const lineageConflict = extractLineageDeleteConflict(err);
|
||||
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
|
||||
failedIds.push(task.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const confirmedArchive = await confirm({
|
||||
title: "Force Delete Task",
|
||||
message:
|
||||
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
|
||||
"Archive anyway by unlinking these references first?",
|
||||
confirmLabel: "Archive",
|
||||
cancelLabel: "Skip",
|
||||
danger: true,
|
||||
});
|
||||
|
||||
if (!confirmedArchive) {
|
||||
failedIds.push(task.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await onArchiveTask(task.id, { removeLineageReferences: true });
|
||||
archivedIds.push(task.id);
|
||||
} catch {
|
||||
failedIds.push(task.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -27,7 +27,7 @@ import { getUnifiedTaskProgress } from "../utils/taskProgress";
|
||||
import { getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
|
||||
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
|
||||
import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||
import { useRetryWarning } from "../context/RetryWarningContext";
|
||||
|
||||
@@ -276,9 +276,13 @@ interface TaskCardProps {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }) => Promise<Task>;
|
||||
onDeleteTask?: (id: string, options?: {
|
||||
removeDependencyReferences?: boolean;
|
||||
removeLineageReferences?: boolean;
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
}) => Promise<Task>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries") => void;
|
||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||
@@ -1214,10 +1218,32 @@ function TaskCardComponent({
|
||||
|
||||
void onArchiveTask(task.id).then(() => {
|
||||
addToast(`Archived ${task.id}`, "success");
|
||||
}).catch((err) => {
|
||||
addToast(`Failed to archive ${task.id}: ${getErrorMessage(err)}`, "error");
|
||||
}).catch(async (err) => {
|
||||
const lineageConflict = extractLineageDeleteConflict(err);
|
||||
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
|
||||
addToast(`Failed to archive ${task.id}: ${getErrorMessage(err)}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: "Force Delete Task",
|
||||
message:
|
||||
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
|
||||
"Archive anyway by unlinking these references first?",
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onArchiveTask(task.id, { removeLineageReferences: true });
|
||||
addToast(`Archived ${task.id} after unlinking lineage references`, "success");
|
||||
} catch (retryErr) {
|
||||
addToast(`Failed to archive ${task.id}: ${getErrorMessage(retryErr)}`, "error");
|
||||
}
|
||||
});
|
||||
}, [addToast, onArchiveTask, task.id]);
|
||||
}, [addToast, confirm, onArchiveTask, task.id]);
|
||||
|
||||
const handleUnarchiveClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
@@ -1279,18 +1305,70 @@ function TaskCardComponent({
|
||||
: "";
|
||||
addToast(`Deleted ${task.id}${issueSuffix}`, "success");
|
||||
} catch (err) {
|
||||
const conflict = extractDependencyDeleteConflict(err);
|
||||
if (!conflict || conflict.dependentIds.length === 0) {
|
||||
const dependencyConflict = extractDependencyDeleteConflict(err);
|
||||
if (dependencyConflict && dependencyConflict.dependentIds.length > 0) {
|
||||
const dependentList = dependencyConflict.dependentIds.join(", ");
|
||||
const confirmed = await confirm({
|
||||
title: "Force Delete Task",
|
||||
message:
|
||||
`${task.id} is a dependency of ${dependentList}.\n\n` +
|
||||
"Delete anyway by removing these dependency references first?",
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onDeleteTask(task.id, {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction,
|
||||
});
|
||||
addToast(`Deleted ${task.id} after removing dependency references`, "success");
|
||||
} catch (retryErr) {
|
||||
const lineageConflict = extractLineageDeleteConflict(retryErr);
|
||||
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
|
||||
addToast(`Failed to delete ${task.id}: ${getErrorMessage(retryErr)}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmedLineage = await confirm({
|
||||
title: "Force Delete Task",
|
||||
message:
|
||||
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
|
||||
"Delete anyway by unlinking these references first?",
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmedLineage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onDeleteTask(task.id, {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction,
|
||||
});
|
||||
addToast(`Deleted ${task.id} after unlinking lineage references`, "success");
|
||||
} catch (lineageRetryErr) {
|
||||
addToast(`Failed to delete ${task.id}: ${getErrorMessage(lineageRetryErr)}`, "error");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const lineageConflict = extractLineageDeleteConflict(err);
|
||||
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
|
||||
addToast(`Failed to delete ${task.id}: ${getErrorMessage(err)}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const dependentList = conflict.dependentIds.join(", ");
|
||||
const confirmed = await confirm({
|
||||
title: "Force Delete Task",
|
||||
message:
|
||||
`${task.id} is a dependency of ${dependentList}.\n\n` +
|
||||
"Delete anyway by removing these dependency references first?",
|
||||
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
|
||||
"Delete anyway by unlinking these references first?",
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
@@ -1298,8 +1376,12 @@ function TaskCardComponent({
|
||||
}
|
||||
|
||||
try {
|
||||
await onDeleteTask(task.id, { removeDependencyReferences: true, githubIssueAction });
|
||||
addToast(`Deleted ${task.id} after removing dependency references`, "success");
|
||||
await onDeleteTask(task.id, {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction,
|
||||
});
|
||||
addToast(`Deleted ${task.id} after unlinking lineage references`, "success");
|
||||
} catch (retryErr) {
|
||||
addToast(`Failed to delete ${task.id}: ${getErrorMessage(retryErr)}`, "error");
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ import { ProviderIcon } from "./ProviderIcon";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
||||
import { appendTokenQuery } from "../auth";
|
||||
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
|
||||
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
|
||||
import { MAX_AUTO_MERGE_RETRIES, computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
|
||||
import { resolveEffectiveGithubRepoDefault } from "./githubTracking";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
@@ -284,8 +284,12 @@ export interface TaskDetailModalProps {
|
||||
onClose: () => void;
|
||||
onOpenDetail: (task: Task | TaskDetail) => void; // For clicking dependencies
|
||||
onMoveTask: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
||||
onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onDeleteTask: (id: string, options?: {
|
||||
removeDependencyReferences?: boolean;
|
||||
removeLineageReferences?: boolean;
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
}) => Promise<Task>;
|
||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onResetTask?: (id: string) => Promise<Task>;
|
||||
@@ -1449,7 +1453,30 @@ export function TaskDetailContent({
|
||||
addToast(`Archived ${task.id}`, "success");
|
||||
requestClose();
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
const lineageConflict = extractLineageDeleteConflict(err);
|
||||
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmedArchive = await confirm({
|
||||
title: "Force Delete Task",
|
||||
message:
|
||||
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
|
||||
"Archive anyway by unlinking these references first?",
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmedArchive) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onArchiveTask(task.id, { removeLineageReferences: true });
|
||||
addToast(`Archived ${task.id} after unlinking lineage references`, "success");
|
||||
requestClose();
|
||||
} catch (retryErr) {
|
||||
addToast(getErrorMessage(retryErr), "error");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1502,18 +1529,72 @@ export function TaskDetailContent({
|
||||
: "";
|
||||
addToast(`Deleted ${task.id}${issueSuffix}`, "info");
|
||||
} catch (err) {
|
||||
const conflict = extractDependencyDeleteConflict(err);
|
||||
if (!conflict || conflict.dependentIds.length === 0) {
|
||||
const dependencyConflict = extractDependencyDeleteConflict(err);
|
||||
if (dependencyConflict && dependencyConflict.dependentIds.length > 0) {
|
||||
const dependentList = dependencyConflict.dependentIds.join(", ");
|
||||
const confirmed = await confirm({
|
||||
title: "Force Delete Task",
|
||||
message:
|
||||
`${task.id} is a dependency of ${dependentList}.\n\n` +
|
||||
"Delete anyway by removing these dependency references first?",
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onDeleteTask(task.id, {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction,
|
||||
});
|
||||
requestClose();
|
||||
addToast(`Deleted ${task.id} after removing dependency references`, "info");
|
||||
} catch (retryErr) {
|
||||
const lineageConflict = extractLineageDeleteConflict(retryErr);
|
||||
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
|
||||
addToast(getErrorMessage(retryErr), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmedLineage = await confirm({
|
||||
title: "Force Delete Task",
|
||||
message:
|
||||
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
|
||||
"Delete anyway by unlinking these references first?",
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmedLineage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onDeleteTask(task.id, {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction,
|
||||
});
|
||||
requestClose();
|
||||
addToast(`Deleted ${task.id} after unlinking lineage references`, "info");
|
||||
} catch (lineageRetryErr) {
|
||||
addToast(getErrorMessage(lineageRetryErr), "error");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const lineageConflict = extractLineageDeleteConflict(err);
|
||||
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const dependentList = conflict.dependentIds.join(", ");
|
||||
const confirmed = await confirm({
|
||||
title: "Force Delete Task",
|
||||
message:
|
||||
`${task.id} is a dependency of ${dependentList}.\n\n` +
|
||||
"Delete anyway by removing these dependency references first?",
|
||||
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
|
||||
"Delete anyway by unlinking these references first?",
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
@@ -1521,9 +1602,13 @@ export function TaskDetailContent({
|
||||
}
|
||||
|
||||
try {
|
||||
await onDeleteTask(task.id, { removeDependencyReferences: true, githubIssueAction });
|
||||
await onDeleteTask(task.id, {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction,
|
||||
});
|
||||
requestClose();
|
||||
addToast(`Deleted ${task.id} after removing dependency references`, "info");
|
||||
addToast(`Deleted ${task.id} after unlinking lineage references`, "info");
|
||||
} catch (retryErr) {
|
||||
addToast(getErrorMessage(retryErr), "error");
|
||||
}
|
||||
|
||||
@@ -2871,7 +2871,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
details: { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-100"] },
|
||||
});
|
||||
const onDeleteTask = vi
|
||||
.fn<(...args: [string, { removeDependencyReferences?: boolean }?]) => Promise<Task>>()
|
||||
.fn<(...args: [string, { removeDependencyReferences?: boolean; removeLineageReferences?: boolean }?]) => Promise<Task>>()
|
||||
.mockRejectedValueOnce(conflictError)
|
||||
.mockResolvedValueOnce(createMockTask());
|
||||
mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
|
||||
@@ -2885,7 +2885,37 @@ describe("ListView - Bulk Selection", () => {
|
||||
expect(onDeleteTask).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(1, "FN-001");
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", { removeDependencyReferences: true });
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
});
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Deleted 1 task · 0 archived skipped · 0 failed", "success");
|
||||
});
|
||||
|
||||
it("force deletes when lineage conflict is confirmed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const tasks = [createMockTask({ id: "FN-001" })];
|
||||
const conflictError = Object.assign(new Error("lineage conflict"), {
|
||||
details: { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-200"] },
|
||||
});
|
||||
const onDeleteTask = vi
|
||||
.fn<(...args: [string, { removeDependencyReferences?: boolean; removeLineageReferences?: boolean }?]) => Promise<Task>>()
|
||||
.mockRejectedValueOnce(conflictError)
|
||||
.mockResolvedValueOnce(createMockTask());
|
||||
mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
|
||||
|
||||
renderListView({ tasks, onDeleteTask });
|
||||
enterBulkEditMode();
|
||||
await user.click(screen.getByLabelText("Select FN-001"));
|
||||
await user.click(screen.getByRole("button", { name: /delete selected/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDeleteTask).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
});
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Deleted 1 task · 0 archived skipped · 0 failed", "success");
|
||||
});
|
||||
|
||||
@@ -2912,6 +2942,30 @@ describe("ListView - Bulk Selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retries archive after lineage-conflict confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const tasks = [createMockTask({ id: "FN-001", column: "done" })];
|
||||
const conflictError = Object.assign(new Error("lineage conflict"), {
|
||||
details: { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-300"] },
|
||||
});
|
||||
const onArchiveTask = vi
|
||||
.fn<(...args: [string, { removeLineageReferences?: boolean }?]) => Promise<Task>>()
|
||||
.mockRejectedValueOnce(conflictError)
|
||||
.mockResolvedValueOnce(createMockTask({ id: "FN-001", column: "archived" }));
|
||||
mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
|
||||
|
||||
renderListView({ tasks, onArchiveTask });
|
||||
enterBulkEditMode();
|
||||
await user.click(screen.getByLabelText("Select FN-001"));
|
||||
await user.click(screen.getByRole("button", { name: /archive selected/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onArchiveTask).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(onArchiveTask).toHaveBeenNthCalledWith(2, "FN-001", { removeLineageReferences: true });
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Archived 1 · 0 skipped · 0 failed", "success");
|
||||
});
|
||||
|
||||
it("persists selection to localStorage", () => {
|
||||
const tasks = [createMockTask({ id: "FN-001" })];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} />);
|
||||
|
||||
@@ -267,7 +267,56 @@ describe("TaskCard", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", { removeDependencyReferences: true, githubIssueAction: "delete" });
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction: "delete",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("retries delete after lineage-conflict confirmation", async () => {
|
||||
const conflict = new Error("Cannot delete task FN-001: still referenced as a lineage parent by FN-010.") as Error & {
|
||||
status: number;
|
||||
details: { code: string; lineageChildIds: string[] };
|
||||
};
|
||||
conflict.status = 409;
|
||||
conflict.details = { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-010", "FN-011"] };
|
||||
const onDeleteTask = vi.fn()
|
||||
.mockRejectedValueOnce(conflict)
|
||||
.mockResolvedValueOnce(makeTask());
|
||||
|
||||
mockConfirm
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
column: "triage",
|
||||
githubTracking: {
|
||||
enabled: true,
|
||||
issue: { owner: "owner", repo: "repo", number: 42, url: "https://github.com/owner/repo/issues/42", createdAt: "2026-01-01T00:00:00Z" },
|
||||
},
|
||||
} as any)}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onDeleteTask={onDeleteTask}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText("Delete task"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction: "delete",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
noopMove,
|
||||
noopOpenDetail,
|
||||
mockConfirm,
|
||||
mockConfirmWithChoice,
|
||||
mockUsePluginUiSlots,
|
||||
expectBaseRule,
|
||||
readDashboardStylesSource,
|
||||
@@ -269,6 +270,7 @@ describe("TaskDetailModal", () => {
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(1, "FN-099", { githubIssueAction: "delete" });
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction: "delete",
|
||||
});
|
||||
expect(noop).toHaveBeenCalledWith("Deleted FN-099 after removing dependency references", "info");
|
||||
@@ -341,11 +343,90 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", { removeDependencyReferences: true });
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
});
|
||||
expect(noop).toHaveBeenCalledWith("Retry failed", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("retries delete after lineage-conflict confirmation", async () => {
|
||||
const onDeleteTask = vi.fn();
|
||||
const conflict = new Error("Cannot delete task FN-099: still referenced as a lineage parent by FN-103.") as Error & {
|
||||
status: number;
|
||||
details: { code: string; lineageChildIds: string[] };
|
||||
};
|
||||
conflict.status = 409;
|
||||
conflict.details = { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-103"] };
|
||||
onDeleteTask
|
||||
.mockRejectedValueOnce(conflict)
|
||||
.mockResolvedValueOnce({} as Task);
|
||||
|
||||
mockConfirm
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ githubTracking: { enabled: true, issue: { owner: "owner", repo: "repo", number: 42, url: "https://github.com/owner/repo/issues/42", createdAt: "2026-01-01T00:00:00.000Z" } } })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction: "close",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("retries archive after lineage-conflict confirmation", async () => {
|
||||
const onArchiveTask = vi.fn();
|
||||
const conflict = new Error("Cannot archive task FN-099: still referenced as a lineage parent by FN-201.") as Error & {
|
||||
status: number;
|
||||
details: { code: string; lineageChildIds: string[] };
|
||||
};
|
||||
conflict.status = 409;
|
||||
conflict.details = { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-201"] };
|
||||
onArchiveTask
|
||||
.mockRejectedValueOnce(conflict)
|
||||
.mockResolvedValueOnce({} as Task);
|
||||
mockConfirmWithChoice.mockResolvedValueOnce("tertiary");
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ column: "done" as any })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onArchiveTask).toHaveBeenNthCalledWith(2, "FN-099", { removeLineageReferences: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("in-review modal-actions contains Merge & Close and Back to In Progress buttons", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
|
||||
@@ -491,7 +491,11 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
|
||||
const deleteTask = useCallback(async (
|
||||
id: string,
|
||||
options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction },
|
||||
options?: {
|
||||
removeDependencyReferences?: boolean;
|
||||
removeLineageReferences?: boolean;
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
},
|
||||
): Promise<Task> => {
|
||||
return normalizeTask(await api.deleteTask(id, projectId, options));
|
||||
}, [projectId]);
|
||||
@@ -548,8 +552,11 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const archiveTask = useCallback(async (id: string): Promise<Task> => {
|
||||
const task = normalizeTask(await api.archiveTask(id, projectId));
|
||||
const archiveTask = useCallback(async (
|
||||
id: string,
|
||||
options?: { removeLineageReferences?: boolean },
|
||||
): Promise<Task> => {
|
||||
const task = normalizeTask(await api.archiveTask(id, projectId, options));
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? task : t))
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractDependencyDeleteConflict } from "../taskDelete";
|
||||
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../taskDelete";
|
||||
|
||||
describe("extractDependencyDeleteConflict", () => {
|
||||
it("returns dependent ids from details.code payload", () => {
|
||||
@@ -32,3 +32,39 @@ describe("extractDependencyDeleteConflict", () => {
|
||||
expect(extractDependencyDeleteConflict("boom")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractLineageDeleteConflict", () => {
|
||||
it("returns lineage child ids from details.code payload", () => {
|
||||
const err = Object.assign(new Error("conflict"), {
|
||||
details: { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-3", "FN-4", 5] },
|
||||
});
|
||||
|
||||
expect(extractLineageDeleteConflict(err)).toEqual({ lineageChildIds: ["FN-3", "FN-4"] });
|
||||
});
|
||||
|
||||
it("returns null for missing details", () => {
|
||||
expect(extractLineageDeleteConflict(new Error("failed"))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for the wrong conflict code", () => {
|
||||
const err = Object.assign(new Error("conflict"), {
|
||||
details: { code: "TASK_HAS_DEPENDENTS", lineageChildIds: ["FN-3"] },
|
||||
});
|
||||
|
||||
expect(extractLineageDeleteConflict(err)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when lineageChildIds is not an array", () => {
|
||||
const err = Object.assign(new Error("conflict"), {
|
||||
details: { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: "FN-3" },
|
||||
});
|
||||
|
||||
expect(extractLineageDeleteConflict(err)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-Error inputs", () => {
|
||||
expect(extractLineageDeleteConflict(null)).toBeNull();
|
||||
expect(extractLineageDeleteConflict({ details: { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-3"] } })).toBeNull();
|
||||
expect(extractLineageDeleteConflict("boom")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@ export interface DependencyDeleteConflict {
|
||||
dependentIds: string[];
|
||||
}
|
||||
|
||||
export interface LineageDeleteConflict {
|
||||
lineageChildIds: string[];
|
||||
}
|
||||
|
||||
export function extractDependencyDeleteConflict(err: unknown): DependencyDeleteConflict | null {
|
||||
if (!(err instanceof Error)) {
|
||||
return null;
|
||||
@@ -19,3 +23,18 @@ export function extractDependencyDeleteConflict(err: unknown): DependencyDeleteC
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractLineageDeleteConflict(err: unknown): LineageDeleteConflict | null {
|
||||
if (!(err instanceof Error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const details = (err as { details?: { code?: string; lineageChildIds?: unknown } }).details;
|
||||
if (details?.code !== "TASK_HAS_LINEAGE_CHILDREN" || !Array.isArray(details.lineageChildIds)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
lineageChildIds: details.lineageChildIds.filter((id): id is string => typeof id === "string"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -988,6 +988,7 @@ describe("DELETE /tasks/:id", () => {
|
||||
expect(res.body.id).toBe("KB-001");
|
||||
expect(store.deleteTask).toHaveBeenCalledWith("KB-001", {
|
||||
removeDependencyReferences: false,
|
||||
removeLineageReferences: false,
|
||||
githubIssueAction: undefined,
|
||||
});
|
||||
});
|
||||
@@ -1018,6 +1019,21 @@ describe("DELETE /tasks/:id", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.deleteTask).toHaveBeenCalledWith("KB-001", {
|
||||
removeDependencyReferences: true,
|
||||
removeLineageReferences: false,
|
||||
githubIssueAction: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the removeLineageReferences flag when explicitly requested", async () => {
|
||||
const deletedTask = { ...FAKE_TASK_DETAIL, id: "KB-001" };
|
||||
(store.deleteTask as ReturnType<typeof vi.fn>).mockResolvedValue(deletedTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001?removeLineageReferences=true");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.deleteTask).toHaveBeenCalledWith("KB-001", {
|
||||
removeDependencyReferences: false,
|
||||
removeLineageReferences: true,
|
||||
githubIssueAction: undefined,
|
||||
});
|
||||
});
|
||||
@@ -1031,10 +1047,28 @@ describe("DELETE /tasks/:id", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.deleteTask).toHaveBeenCalledWith("KB-001", {
|
||||
removeDependencyReferences: false,
|
||||
removeLineageReferences: false,
|
||||
githubIssueAction,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns structured 409 conflict when delete is blocked by lineage children", async () => {
|
||||
const err = new Error("Cannot delete task KB-001: still referenced as a lineage parent by KB-002.");
|
||||
err.name = "TaskHasLineageChildrenError";
|
||||
(err as Error & { childIds: string[] }).childIds = ["KB-002", "KB-003"];
|
||||
(store.deleteTask as ReturnType<typeof vi.fn>).mockRejectedValue(err);
|
||||
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001");
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toContain("Cannot delete task KB-001");
|
||||
expect(res.body.details).toEqual({
|
||||
code: "TASK_HAS_LINEAGE_CHILDREN",
|
||||
taskId: "KB-001",
|
||||
lineageChildIds: ["KB-002", "KB-003"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid githubIssueAction values", async () => {
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001?githubIssueAction=bad-value");
|
||||
|
||||
@@ -1070,7 +1104,44 @@ describe("POST /tasks/:id/archive", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.column).toBe("archived");
|
||||
expect(store.archiveTask).toHaveBeenCalledWith("KB-001");
|
||||
expect(store.archiveTask).toHaveBeenCalledWith("KB-001", {
|
||||
cleanup: true,
|
||||
removeLineageReferences: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes removeLineageReferences to archive when explicitly requested", async () => {
|
||||
const archivedTask = { ...FAKE_TASK_DETAIL, column: "archived" };
|
||||
(store.archiveTask as ReturnType<typeof vi.fn>).mockResolvedValue(archivedTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive?removeLineageReferences=true", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.archiveTask).toHaveBeenCalledWith("KB-001", {
|
||||
cleanup: true,
|
||||
removeLineageReferences: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns structured 409 conflict when archive is blocked by lineage children", async () => {
|
||||
const err = new Error("Cannot archive task KB-001: still referenced as a lineage parent by KB-002.");
|
||||
err.name = "TaskHasLineageChildrenError";
|
||||
(err as Error & { childIds: string[] }).childIds = ["KB-002", "KB-003"];
|
||||
(store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(err);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toContain("Cannot archive task KB-001");
|
||||
expect(res.body.details).toEqual({
|
||||
code: "TASK_HAS_LINEAGE_CHILDREN",
|
||||
taskId: "KB-001",
|
||||
lineageChildIds: ["KB-002", "KB-003"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in done column", async () => {
|
||||
|
||||
@@ -973,12 +973,31 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
router.post("/tasks/:id/archive", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const task = await scopedStore.archiveTask(req.params.id);
|
||||
const removeLineageReferences = req.query.removeLineageReferences === "1"
|
||||
|| req.query.removeLineageReferences === "true";
|
||||
const task = await scopedStore.archiveTask(req.params.id, {
|
||||
cleanup: true,
|
||||
removeLineageReferences,
|
||||
});
|
||||
res.json(task);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
const isTaskHasLineageChildrenError =
|
||||
err instanceof Error
|
||||
&& err.name === "TaskHasLineageChildrenError"
|
||||
&& Array.isArray((err as { childIds?: unknown }).childIds);
|
||||
|
||||
if (isTaskHasLineageChildrenError) {
|
||||
const childIds = (err as { childIds: string[] }).childIds;
|
||||
throw new ApiError(409, err instanceof Error ? err.message : "Task has lineage children", {
|
||||
code: "TASK_HAS_LINEAGE_CHILDREN",
|
||||
taskId: req.params.id,
|
||||
lineageChildIds: childIds,
|
||||
});
|
||||
}
|
||||
|
||||
const status = (err instanceof Error ? err.message : String(err)).includes("must be in") ? 400 : 500;
|
||||
throw new ApiError(status, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -2744,6 +2763,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const removeDependencyReferences = req.query.removeDependencyReferences === "1"
|
||||
|| req.query.removeDependencyReferences === "true";
|
||||
const removeLineageReferences = req.query.removeLineageReferences === "1"
|
||||
|| req.query.removeLineageReferences === "true";
|
||||
const githubIssueActionRaw = req.query.githubIssueAction;
|
||||
const githubIssueActionValues: readonly GithubIssueAction[] = ["close", "delete", "leave", "auto"];
|
||||
let githubIssueAction: GithubIssueAction | undefined;
|
||||
@@ -2753,7 +2774,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
githubIssueAction = githubIssueActionRaw as GithubIssueAction;
|
||||
}
|
||||
const task = await scopedStore.deleteTask(req.params.id, { removeDependencyReferences, githubIssueAction });
|
||||
const task = await scopedStore.deleteTask(req.params.id, {
|
||||
removeDependencyReferences,
|
||||
removeLineageReferences,
|
||||
githubIssueAction,
|
||||
});
|
||||
res.json(task);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -2773,6 +2798,20 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
});
|
||||
}
|
||||
|
||||
const isTaskHasLineageChildrenError =
|
||||
err instanceof Error
|
||||
&& err.name === "TaskHasLineageChildrenError"
|
||||
&& Array.isArray((err as { childIds?: unknown }).childIds);
|
||||
|
||||
if (isTaskHasLineageChildrenError) {
|
||||
const childIds = (err as { childIds: string[] }).childIds;
|
||||
throw new ApiError(409, err instanceof Error ? err.message : "Task has lineage children", {
|
||||
code: "TASK_HAS_LINEAGE_CHILDREN",
|
||||
taskId: req.params.id,
|
||||
lineageChildIds: childIds,
|
||||
});
|
||||
}
|
||||
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user