feat(KB-034): add task archive/unarchive functionality
- Add 'archived' column to task store with archiveTask and unarchiveTask methods - Add CLI commands: kb task archive <id> and kb task unarchive <id> - Add pi extension tools for archive and unarchive operations - Add dashboard API endpoints POST /api/tasks/:id/archive and /unarchive - Add Archived column to board UI with archive/unarchive buttons - Prevent drag-drop into archived column, add visual distinction - Include duplicateTask from concurrent branch in merge resolution
This commit is contained in:
@@ -84,6 +84,8 @@ describe("kb pi extension", () => {
|
||||
"kb_task_import_github",
|
||||
"kb_task_import_github_issue",
|
||||
"kb_task_browse_github_issues",
|
||||
"kb_task_archive",
|
||||
"kb_task_unarchive",
|
||||
];
|
||||
|
||||
for (const name of expected) {
|
||||
|
||||
@@ -39,7 +39,7 @@ if (isBunBinary) {
|
||||
|
||||
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
|
||||
const { runDashboard } = await import("./commands/dashboard.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate } = await import("./commands/task.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive } = await import("./commands/task.js");
|
||||
|
||||
const HELP = `
|
||||
kb — AI-orchestrated task board
|
||||
@@ -56,6 +56,8 @@ Usage:
|
||||
kb task log <id> <message> Add a log entry
|
||||
kb task merge <id> Merge an in-review task and close it
|
||||
kb task duplicate <id> Duplicate a task (creates copy in triage)
|
||||
kb task archive <id> Archive a done task
|
||||
kb task unarchive <id> Unarchive an archived task
|
||||
kb task attach <id> <file> Attach a file to a task
|
||||
kb task pause <id> Pause a task (stops all automation)
|
||||
kb task unpause <id> Unpause a task (resumes automation)
|
||||
@@ -72,7 +74,7 @@ Options:
|
||||
--interactive, -i Interactive mode for issue selection
|
||||
--help, -h Show this help
|
||||
|
||||
Columns: triage, todo, in-progress, in-review, done
|
||||
Columns: triage, todo, in-progress, in-review, done, archived
|
||||
Supported file types: png, jpg, gif, webp, txt, log, json, yaml, yml, toml, csv, xml
|
||||
|
||||
The AI engine uses pi (github.com/badlogic/pi-mono) for agent sessions.
|
||||
@@ -174,6 +176,18 @@ async function main() {
|
||||
await runTaskDuplicate(id);
|
||||
break;
|
||||
}
|
||||
case "archive": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: kb task archive <id>"); process.exit(1); }
|
||||
await runTaskArchive(id);
|
||||
break;
|
||||
}
|
||||
case "unarchive": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: kb task unarchive <id>"); process.exit(1); }
|
||||
await runTaskUnarchive(id);
|
||||
break;
|
||||
}
|
||||
case "attach": {
|
||||
const id = args[2], file = args[3];
|
||||
if (!id || !file) {
|
||||
|
||||
@@ -296,6 +296,24 @@ export async function runTaskDuplicate(id: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskArchive(id: string) {
|
||||
const store = await getStore();
|
||||
const task = await store.archiveTask(id);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Archived ${task.id} → ${COLUMN_LABELS[task.column]}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskUnarchive(id: string) {
|
||||
const store = await getStore();
|
||||
const task = await store.unarchiveTask(id);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskImportGitHubInteractive(
|
||||
ownerRepo: string,
|
||||
options: TaskImportOptions = {}
|
||||
|
||||
@@ -371,6 +371,63 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_task_archive ───────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "kb_task_archive",
|
||||
label: "KB: Archive Task",
|
||||
description:
|
||||
"Archive a done task (move from done → archived). " +
|
||||
"Archived tasks are preserved for historical reference but moved out of the main board view.",
|
||||
promptSnippet: "Archive a done kb task (moves to archived column)",
|
||||
promptGuidelines: [
|
||||
"Use to clean up old completed tasks from the done column",
|
||||
"Only tasks in the 'done' column can be archived",
|
||||
"Archived tasks can be unarchived later if needed",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Task ID to archive (e.g. KB-001). Must be in 'done' column." }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const task = await store.archiveTask(params.id);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Archived ${task.id} → ${COLUMN_LABELS[task.column]}` }],
|
||||
details: { taskId: task.id, column: task.column },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_task_unarchive ─────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "kb_task_unarchive",
|
||||
label: "KB: Unarchive Task",
|
||||
description:
|
||||
"Unarchive an archived task (move from archived → done). " +
|
||||
"Restores the task to the done column.",
|
||||
promptSnippet: "Unarchive a kb task (restores to done column)",
|
||||
promptGuidelines: [
|
||||
"Use to restore an archived task back to the done column",
|
||||
"Only tasks in the 'archived' column can be unarchived",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Task ID to unarchive (e.g. KB-001). Must be in 'archived' column." }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const task = await store.unarchiveTask(params.id);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}` }],
|
||||
details: { taskId: task.id, column: task.column },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_task_import_github ─────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
|
||||
@@ -1563,4 +1563,230 @@ describe("TaskStore", () => {
|
||||
expect(duplicated.baseBranch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ── Archive/Unarchive Tests ──────────────────────────────────────
|
||||
|
||||
describe("archiveTask", () => {
|
||||
it("archives a done task (moves done → archived)", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const archived = await store.archiveTask(task.id);
|
||||
|
||||
expect(archived.column).toBe("archived");
|
||||
});
|
||||
|
||||
it("adds log entry 'Task archived'", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const archived = await store.archiveTask(task.id);
|
||||
|
||||
expect(archived.log.some((l) => l.action === "Task archived")).toBe(true);
|
||||
});
|
||||
|
||||
it("emits task:moved event with correct from/to columns", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const events: any[] = [];
|
||||
store.on("task:moved", (data) => events.push(data));
|
||||
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].from).toBe("done");
|
||||
expect(events[0].to).toBe("archived");
|
||||
});
|
||||
|
||||
it("persists to disk and round-trips correctly", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
await store.archiveTask(task.id);
|
||||
const fetched = await store.getTask(task.id);
|
||||
|
||||
expect(fetched.column).toBe("archived");
|
||||
});
|
||||
|
||||
it("throws error when task is not in 'done' column", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
// Task starts in triage, not done
|
||||
|
||||
await expect(store.archiveTask(task.id)).rejects.toThrow("must be in 'done'");
|
||||
});
|
||||
|
||||
it("updates columnMovedAt timestamp", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
const beforeArchive = (await store.getTask(task.id)).columnMovedAt;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const archived = await store.archiveTask(task.id);
|
||||
|
||||
expect(archived.columnMovedAt).not.toBe(beforeArchive);
|
||||
expect(new Date(archived.columnMovedAt!).getTime()).toBeGreaterThan(new Date(beforeArchive!).getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("unarchiveTask", () => {
|
||||
it("unarchives an archived task (moves archived → done)", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
const unarchived = await store.unarchiveTask(task.id);
|
||||
|
||||
expect(unarchived.column).toBe("done");
|
||||
});
|
||||
|
||||
it("adds log entry 'Task unarchived'", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
const unarchived = await store.unarchiveTask(task.id);
|
||||
|
||||
expect(unarchived.log.some((l) => l.action === "Task unarchived")).toBe(true);
|
||||
});
|
||||
|
||||
it("emits task:moved event with correct from/to columns", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
const events: any[] = [];
|
||||
store.on("task:moved", (data) => events.push(data));
|
||||
|
||||
await store.unarchiveTask(task.id);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].from).toBe("archived");
|
||||
expect(events[0].to).toBe("done");
|
||||
});
|
||||
|
||||
it("persists to disk and round-trips correctly", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
await store.unarchiveTask(task.id);
|
||||
const fetched = await store.getTask(task.id);
|
||||
|
||||
expect(fetched.column).toBe("done");
|
||||
});
|
||||
|
||||
it("throws error when task is not in 'archived' column", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
// Task starts in triage, not archived
|
||||
|
||||
await expect(store.unarchiveTask(task.id)).rejects.toThrow("must be in 'archived'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("VALID_TRANSITIONS — invalid archived transitions via moveTask", () => {
|
||||
it("moveTask from archived → in-progress should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
await expect(store.moveTask(task.id, "in-progress")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
|
||||
it("moveTask from archived → triage should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
await expect(store.moveTask(task.id, "triage")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
|
||||
it("moveTask from archived → todo should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
await expect(store.moveTask(task.id, "todo")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
|
||||
it("moveTask from archived → in-review should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id);
|
||||
|
||||
await expect(store.moveTask(task.id, "in-review")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
|
||||
it("moveTask from triage → archived should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
// Task starts in triage
|
||||
|
||||
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
|
||||
it("moveTask from todo → archived should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
|
||||
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
|
||||
it("moveTask from in-progress → archived should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
|
||||
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
|
||||
it("moveTask from in-review → archived should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
|
||||
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -742,6 +742,72 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a done task (move from done → archived).
|
||||
* Logs the action and emits `task:moved` event.
|
||||
*/
|
||||
async archiveTask(id: string): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
|
||||
if (task.column !== "done") {
|
||||
throw new Error(
|
||||
`Cannot archive ${id}: task is in '${task.column}', must be in 'done'`,
|
||||
);
|
||||
}
|
||||
|
||||
task.column = "archived";
|
||||
task.columnMovedAt = new Date().toISOString();
|
||||
task.updatedAt = task.columnMovedAt;
|
||||
task.log.push({
|
||||
timestamp: task.columnMovedAt,
|
||||
action: "Task archived",
|
||||
});
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
|
||||
// Update cache if watcher is active
|
||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||
|
||||
this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column });
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unarchive an archived task (move from archived → done).
|
||||
* Logs the action and emits `task:moved` event.
|
||||
*/
|
||||
async unarchiveTask(id: string): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
|
||||
if (task.column !== "archived") {
|
||||
throw new Error(
|
||||
`Cannot unarchive ${id}: task is in '${task.column}', must be in 'archived'`,
|
||||
);
|
||||
}
|
||||
|
||||
task.column = "done";
|
||||
task.columnMovedAt = new Date().toISOString();
|
||||
task.updatedAt = task.columnMovedAt;
|
||||
task.log.push({
|
||||
timestamp: task.columnMovedAt,
|
||||
action: "Task unarchived",
|
||||
});
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
|
||||
// Update cache if watcher is active
|
||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||
|
||||
this.emit("task:moved", { task, from: "archived" as Column, to: "done" as Column });
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
private async moveToDone(task: Task, dir: string): Promise<void> {
|
||||
task.column = "done";
|
||||
task.worktree = undefined;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const;
|
||||
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
||||
|
||||
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"] as const;
|
||||
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done", "archived"] as const;
|
||||
export type Column = (typeof COLUMNS)[number];
|
||||
|
||||
export type PrStatus = "open" | "closed" | "merged";
|
||||
@@ -267,6 +267,7 @@ export const COLUMN_LABELS: Record<Column, string> = {
|
||||
"in-progress": "In Progress",
|
||||
"in-review": "In Review",
|
||||
done: "Done",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
export const COLUMN_DESCRIPTIONS: Record<Column, string> = {
|
||||
@@ -275,6 +276,7 @@ export const COLUMN_DESCRIPTIONS: Record<Column, string> = {
|
||||
"in-progress": "AI is working on this in a worktree",
|
||||
"in-review": "Complete — ready to merge",
|
||||
done: "Merged and closed",
|
||||
archived: "Completed and archived",
|
||||
};
|
||||
|
||||
export const VALID_TRANSITIONS: Record<Column, Column[]> = {
|
||||
@@ -282,5 +284,6 @@ export const VALID_TRANSITIONS: Record<Column, Column[]> = {
|
||||
todo: ["in-progress", "triage"],
|
||||
"in-progress": ["in-review", "todo", "triage"],
|
||||
"in-review": ["done", "in-progress"],
|
||||
done: [],
|
||||
done: ["archived"],
|
||||
archived: ["done"],
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ function AppInner() {
|
||||
return "board";
|
||||
});
|
||||
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask } = useTasks();
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask } = useTasks();
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
@@ -161,6 +161,8 @@ function AppInner() {
|
||||
onToggleAutoMerge={handleToggleAutoMerge}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={updateTask}
|
||||
onArchiveTask={archiveTask}
|
||||
onUnarchiveTask={unarchiveTask}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
|
||||
@@ -35,9 +35,9 @@ describe("column fixed-width CSS", () => {
|
||||
});
|
||||
|
||||
describe("desktop .board grid template", () => {
|
||||
it("still uses repeat(5, minmax(260px, 1fr))", () => {
|
||||
it("uses repeat(6, minmax(260px, 1fr)) for 6 columns", () => {
|
||||
expect(css).toContain(
|
||||
"grid-template-columns: repeat(5, minmax(260px, 1fr))",
|
||||
"grid-template-columns: repeat(6, minmax(260px, 1fr))",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fetchTaskDetail, updateTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, addSteeringComment, fetchGitRemotes } from "./api";
|
||||
import { fetchTaskDetail, updateTask, archiveTask, unarchiveTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, addSteeringComment, fetchGitRemotes } from "./api";
|
||||
import type { Task, TaskDetail } from "@kb/core";
|
||||
|
||||
const FAKE_DETAIL: TaskDetail = {
|
||||
@@ -637,4 +637,46 @@ describe("Git Management API", () => {
|
||||
await expect(pushBranch()).rejects.toThrow("Push rejected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("archiveTask", () => {
|
||||
it("sends POST to archive endpoint", async () => {
|
||||
const archivedTask: Task = { ...FAKE_DETAIL, column: "archived" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, archivedTask));
|
||||
|
||||
const response = await archiveTask("KB-001");
|
||||
|
||||
expect(response.column).toBe("archived");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/archive", {
|
||||
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));
|
||||
|
||||
await expect(archiveTask("KB-001")).rejects.toThrow("Task not in done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unarchiveTask", () => {
|
||||
it("sends POST to unarchive endpoint", async () => {
|
||||
const unarchivedTask: Task = { ...FAKE_DETAIL, column: "done" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, unarchivedTask));
|
||||
|
||||
const response = await unarchiveTask("KB-001");
|
||||
|
||||
expect(response.column).toBe("done");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/unarchive", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Task not in archived" }, 400));
|
||||
|
||||
await expect(unarchiveTask("KB-001")).rejects.toThrow("Task not in archived");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,14 @@ export function unpauseTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/unpause`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function archiveTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/archive`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function unarchiveTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/unarchive`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function approvePlan(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/approve-plan`, { method: "POST" });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@k
|
||||
import { COLUMNS } from "@kb/core";
|
||||
import { Column } from "./Column";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useState } from "react";
|
||||
|
||||
interface BoardProps {
|
||||
tasks: Task[];
|
||||
@@ -20,9 +21,13 @@ interface BoardProps {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
}
|
||||
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask }: BoardProps) {
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask }: BoardProps) {
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
|
||||
return (
|
||||
<main className="board" id="board">
|
||||
{COLUMNS.map((col) => (
|
||||
@@ -48,8 +53,11 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
{...(col === "triage" ? { isCreating, onCancelCreate, onCreateTask, onNewTask } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: () => setArchivedCollapsed(!archivedCollapsed) } : {})}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { WorktreeGroup } from "./WorktreeGroup";
|
||||
import { InlineCreateCard } from "./InlineCreateCard";
|
||||
import { groupByWorktree } from "../utils/worktreeGrouping";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
interface ColumnProps {
|
||||
column: ColumnType;
|
||||
@@ -27,17 +28,27 @@ interface ColumnProps {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask }: ColumnProps) {
|
||||
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, collapsed, onToggleCollapse }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||
|
||||
// Archived column is collapsed by default - don't show drag state when collapsed
|
||||
const isArchived = column === "archived";
|
||||
const isCollapsed = isArchived && collapsed;
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
// Don't allow dropping into archived column via drag-drop
|
||||
if (isArchived) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOver(true);
|
||||
}, []);
|
||||
}, [isArchived]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
@@ -61,7 +72,7 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`column${dragOver ? " drag-over" : ""}`}
|
||||
className={`column${dragOver ? " drag-over" : ""}${isArchived ? " column-archived" : ""}${isCollapsed ? " column-collapsed" : ""}`}
|
||||
data-column={column}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -87,46 +98,68 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
|
||||
+ New Task
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>
|
||||
<div className="column-body">
|
||||
{column === "triage" && isCreating && onCancelCreate && onCreateTask && (
|
||||
<InlineCreateCard
|
||||
tasks={allTasks}
|
||||
onSubmit={onCreateTask}
|
||||
onCancel={onCancelCreate}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
{column === "in-progress" ? (
|
||||
(() => {
|
||||
const groups = groupByWorktree(tasks, allTasks, maxConcurrent);
|
||||
return groups.length === 0 ? (
|
||||
<div className="empty-column">No tasks</div>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<WorktreeGroup
|
||||
key={group.label}
|
||||
label={group.label}
|
||||
activeTasks={group.activeTasks}
|
||||
queuedTasks={group.queuedTasks}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
tasks={allTasks}
|
||||
onUpdateTask={onUpdateTask}
|
||||
/>
|
||||
))
|
||||
);
|
||||
})()
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="empty-column">No tasks</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} tasks={allTasks} onUpdateTask={onUpdateTask} />
|
||||
))
|
||||
{isArchived && onToggleCollapse && (
|
||||
<button
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={onToggleCollapse}
|
||||
title={collapsed ? "Expand archived tasks" : "Collapse archived tasks"}
|
||||
aria-label={collapsed ? "Expand archived tasks" : "Collapse archived tasks"}
|
||||
>
|
||||
{collapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && <p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>}
|
||||
{!isCollapsed && (
|
||||
<div className="column-body">
|
||||
{column === "triage" && isCreating && onCancelCreate && onCreateTask && (
|
||||
<InlineCreateCard
|
||||
tasks={allTasks}
|
||||
onSubmit={onCreateTask}
|
||||
onCancel={onCancelCreate}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
{column === "in-progress" ? (
|
||||
(() => {
|
||||
const groups = groupByWorktree(tasks, allTasks, maxConcurrent);
|
||||
return groups.length === 0 ? (
|
||||
<div className="empty-column">No tasks</div>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<WorktreeGroup
|
||||
key={group.label}
|
||||
label={group.label}
|
||||
activeTasks={group.activeTasks}
|
||||
queuedTasks={group.queuedTasks}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
tasks={allTasks}
|
||||
onUpdateTask={onUpdateTask}
|
||||
/>
|
||||
))
|
||||
);
|
||||
})()
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="empty-column">No tasks</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
tasks={allTasks}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
"in-progress": "var(--in-progress)",
|
||||
"in-review": "var(--in-review)",
|
||||
done: "var(--done)",
|
||||
archived: "var(--text-secondary)",
|
||||
};
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
|
||||
@@ -247,7 +248,8 @@ export function ListView({
|
||||
todo: [],
|
||||
"in-progress": [],
|
||||
"in-review": [],
|
||||
done: []
|
||||
done: [],
|
||||
archived: []
|
||||
};
|
||||
sorted.forEach(task => groups[task.column].push(task));
|
||||
return groups;
|
||||
@@ -318,6 +320,12 @@ export function ListView({
|
||||
const taskId = e.dataTransfer.getData("text/plain");
|
||||
if (!taskId) return;
|
||||
|
||||
// Prevent dropping into archived column
|
||||
if (column === "archived") {
|
||||
addToast("Tasks can only be archived via the archive button", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onMoveTask(taskId, column);
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -10,6 +10,7 @@ const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
"in-progress": "rgba(188,140,255,0.15)",
|
||||
"in-review": "rgba(63,185,80,0.15)",
|
||||
done: "rgba(139,148,158,0.15)",
|
||||
archived: "rgba(120,120,120,0.1)",
|
||||
};
|
||||
|
||||
const COLUMN_TEXT_COLOR_MAP: Record<Column, string> = {
|
||||
@@ -18,6 +19,7 @@ const COLUMN_TEXT_COLOR_MAP: Record<Column, string> = {
|
||||
"in-progress": "var(--in-progress)",
|
||||
"in-review": "var(--in-review)",
|
||||
done: "var(--done)",
|
||||
archived: "var(--text-secondary)",
|
||||
};
|
||||
|
||||
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
|
||||
@@ -35,6 +37,8 @@ interface TaskCardProps {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
}
|
||||
|
||||
export function TaskCard({
|
||||
@@ -45,6 +49,8 @@ export function TaskCard({
|
||||
globalPaused,
|
||||
tasks = [],
|
||||
onUpdateTask,
|
||||
onArchiveTask,
|
||||
onUnarchiveTask,
|
||||
}: TaskCardProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [fileDragOver, setFileDragOver] = useState(false);
|
||||
@@ -139,8 +145,9 @@ export function TaskCard({
|
||||
|
||||
const isFailed = task.status === "failed";
|
||||
const isPaused = task.paused === true;
|
||||
const isArchived = task.column === "archived";
|
||||
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||
const isDraggable = !queued && !isPaused && !isEditing; // Disable drag during edit
|
||||
const isDraggable = !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit or if archived
|
||||
|
||||
// Check if this card can be edited inline
|
||||
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;
|
||||
@@ -369,6 +376,42 @@ export function TaskCard({
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
{/* Archive button for done column tasks */}
|
||||
{task.column === "done" && onArchiveTask && (
|
||||
<button
|
||||
className="card-archive-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onArchiveTask(task.id).then(() => {
|
||||
addToast(`Archived ${task.id}`, "success");
|
||||
}).catch((err: any) => {
|
||||
addToast(`Failed to archive ${task.id}: ${err.message}`, "error");
|
||||
});
|
||||
}}
|
||||
title="Archive task"
|
||||
aria-label="Archive task"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
)}
|
||||
{/* Unarchive button for archived column tasks */}
|
||||
{task.column === "archived" && onUnarchiveTask && (
|
||||
<button
|
||||
className="card-unarchive-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnarchiveTask(task.id).then(() => {
|
||||
addToast(`Unarchived ${task.id}`, "success");
|
||||
}).catch((err: any) => {
|
||||
addToast(`Failed to unarchive ${task.id}: ${err.message}`, "error");
|
||||
});
|
||||
}}
|
||||
title="Unarchive task"
|
||||
aria-label="Unarchive task"
|
||||
>
|
||||
Unarchive
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-title">
|
||||
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}
|
||||
|
||||
@@ -46,14 +46,14 @@ describe("Board", () => {
|
||||
expect(main.id).toBe("board");
|
||||
});
|
||||
|
||||
it("renders all 5 columns", () => {
|
||||
it("renders all 6 columns", () => {
|
||||
renderBoard();
|
||||
for (const col of COLUMNS) {
|
||||
expect(screen.getByTestId(`column-${col}`)).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders all 5 columns as direct children of .board (CSS selector target)", () => {
|
||||
it("renders all 6 columns as direct children of .board (CSS selector target)", () => {
|
||||
renderBoard();
|
||||
const board = screen.getByRole("main");
|
||||
// The mock Column renders <div data-testid="column-{col}" />, which are direct children
|
||||
|
||||
@@ -598,7 +598,7 @@ describe("ListView", () => {
|
||||
|
||||
// Find section headers by their structure
|
||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeaders.length).toBe(5); // One for each column
|
||||
expect(sectionHeaders.length).toBe(6); // One for each column
|
||||
|
||||
// Check that triage section shows count of 2
|
||||
const triageHeader = sectionHeaders.find(h => h.textContent?.includes("Triage"));
|
||||
@@ -1152,7 +1152,7 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
// All section headers should be visible initially
|
||||
const sectionHeadersBefore = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeadersBefore.length).toBe(5); // All 5 columns
|
||||
expect(sectionHeadersBefore.length).toBe(6); // All 6 columns
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
|
||||
@@ -156,5 +156,21 @@ export function useTasks() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask };
|
||||
const archiveTask = useCallback(async (id: string): Promise<Task> => {
|
||||
const task = await api.archiveTask(id);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? task : t))
|
||||
);
|
||||
return task;
|
||||
}, []);
|
||||
|
||||
const unarchiveTask = useCallback(async (id: string): Promise<Task> => {
|
||||
const task = await api.unarchiveTask(id);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? task : t))
|
||||
);
|
||||
return task;
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask };
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ body {
|
||||
/* === Board === */
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(260px, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
height: calc(100vh - 57px);
|
||||
@@ -310,6 +310,9 @@ body {
|
||||
.dot-done {
|
||||
background: var(--done);
|
||||
}
|
||||
.dot-archived {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.column-header h2 {
|
||||
font-size: 14px;
|
||||
@@ -1640,6 +1643,44 @@ body {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Archive/Unarchive buttons */
|
||||
.card-archive-btn,
|
||||
.card-unarchive-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.card:hover .card-archive-btn,
|
||||
.card:hover .card-unarchive-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-archive-btn:hover,
|
||||
.card-unarchive-btn:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.card-archive-btn:focus,
|
||||
.card-unarchive-btn:focus {
|
||||
opacity: 1;
|
||||
outline: 1px solid var(--todo);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Loading state during save */
|
||||
.card-edit-loading {
|
||||
display: flex;
|
||||
|
||||
@@ -15,6 +15,8 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn(),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -261,6 +263,110 @@ describe("POST /tasks/:id/duplicate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/archive", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore({
|
||||
archiveTask: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("archives a done task and returns the updated task", 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", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.column).toBe("archived");
|
||||
expect(store.archiveTask).toHaveBeenCalledWith("KB-001");
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in done column", async () => {
|
||||
(store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot archive KB-001: task is in 'triage', must be in 'done'"));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("must be in 'done'");
|
||||
});
|
||||
|
||||
it("returns 500 on unexpected errors", async () => {
|
||||
(store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toContain("Database error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/unarchive", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore({
|
||||
unarchiveTask: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("unarchives an archived task and returns the updated task", async () => {
|
||||
const unarchivedTask = { ...FAKE_TASK_DETAIL, column: "done" };
|
||||
(store.unarchiveTask as ReturnType<typeof vi.fn>).mockResolvedValue(unarchivedTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unarchive", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.column).toBe("done");
|
||||
expect(store.unarchiveTask).toHaveBeenCalledWith("KB-001");
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in archived column", async () => {
|
||||
(store.unarchiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot unarchive KB-001: task is in 'done', must be in 'archived'"));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unarchive", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("must be in 'archived'");
|
||||
});
|
||||
|
||||
it("returns 500 on unexpected errors", async () => {
|
||||
(store.unarchiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unarchive", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toContain("Database error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /tasks/:id", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
|
||||
@@ -685,6 +685,28 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// Archive task (done → archived)
|
||||
router.post("/tasks/:id/archive", async (req, res) => {
|
||||
try {
|
||||
const task = await store.archiveTask(req.params.id);
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("must be in") ? 400 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Unarchive task (archived → done)
|
||||
router.post("/tasks/:id/unarchive", async (req, res) => {
|
||||
try {
|
||||
const task = await store.unarchiveTask(req.params.id);
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("must be in") ? 400 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload attachment
|
||||
router.post("/tasks/:id/attachments", upload.single("file"), async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user