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:
gsxdsm
2026-03-29 20:03:43 -07:00
parent 6c151b6591
commit 37afb24d60
22 changed files with 793 additions and 58 deletions

View File

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

View File

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