From aedaba4d69a2739d7eea7f5463024e3bce635f95 Mon Sep 17 00:00:00 2001 From: Zhicheng Han Date: Sun, 10 May 2026 22:14:33 +0200 Subject: [PATCH] fix(kanban): include archived tasks in board counts --- packages/client/src/api/hermes/kanban.ts | 2 ++ packages/client/src/stores/hermes/kanban.ts | 1 + packages/server/src/controllers/hermes/kanban.ts | 7 +++++-- packages/server/src/services/hermes/hermes-kanban.ts | 10 +++++++++- tests/client/kanban-api.test.ts | 6 +++--- tests/client/kanban-store.test.ts | 4 ++-- tests/server/hermes-kanban-service.test.ts | 10 +++++++--- tests/server/kanban-controller.test.ts | 6 +++--- 8 files changed, 32 insertions(+), 14 deletions(-) diff --git a/packages/client/src/api/hermes/kanban.ts b/packages/client/src/api/hermes/kanban.ts index 169121d2..e058191b 100644 --- a/packages/client/src/api/hermes/kanban.ts +++ b/packages/client/src/api/hermes/kanban.ts @@ -143,6 +143,7 @@ export interface KanbanListOptions extends KanbanBoardOptions { status?: string assignee?: string tenant?: string + includeArchived?: boolean } function normalizedBoard(board?: string): string { @@ -194,6 +195,7 @@ export async function listTasks(opts?: KanbanListOptions): Promise if (opts?.status) params.set('status', opts.status) if (opts?.assignee) params.set('assignee', opts.assignee) if (opts?.tenant) params.set('tenant', opts.tenant) + if (opts?.includeArchived) params.set('includeArchived', 'true') const res = await request<{ tasks: KanbanTask[] }>(appendQuery('/api/hermes/kanban', params)) return res.tasks } diff --git a/packages/client/src/stores/hermes/kanban.ts b/packages/client/src/stores/hermes/kanban.ts index ee731c35..81eabb51 100644 --- a/packages/client/src/stores/hermes/kanban.ts +++ b/packages/client/src/stores/hermes/kanban.ts @@ -173,6 +173,7 @@ export const useKanbanStore = defineStore('kanban', () => { board, status: filterStatus.value || undefined, assignee: filterAssignee.value || undefined, + includeArchived: true, }) if (isCurrentRequest(seq, generation, board, tasksRequestSeq)) tasks.value = nextTasks } catch (err) { diff --git a/packages/server/src/controllers/hermes/kanban.ts b/packages/server/src/controllers/hermes/kanban.ts index 185a5ec9..81f31820 100644 --- a/packages/server/src/controllers/hermes/kanban.ts +++ b/packages/server/src/controllers/hermes/kanban.ts @@ -89,11 +89,14 @@ export async function capabilities(ctx: Context) { } export async function list(ctx: Context) { - const { status, assignee, tenant } = ctx.query as Record + const status = firstQueryValue(ctx.query.status as string | string[] | undefined) + const assignee = firstQueryValue(ctx.query.assignee as string | string[] | undefined) + const tenant = firstQueryValue(ctx.query.tenant as string | string[] | undefined) + const includeArchived = firstQueryValue(ctx.query.includeArchived as string | string[] | undefined) === 'true' const board = requestBoard(ctx) if (!board) return try { - const tasks = await kanbanCli.listTasks({ board, status, assignee, tenant }) + const tasks = await kanbanCli.listTasks({ board, status, assignee, tenant, includeArchived }) ctx.body = { tasks } } catch (err: any) { ctx.status = 500 diff --git a/packages/server/src/services/hermes/hermes-kanban.ts b/packages/server/src/services/hermes/hermes-kanban.ts index d0b9f6d6..6ef03d66 100644 --- a/packages/server/src/services/hermes/hermes-kanban.ts +++ b/packages/server/src/services/hermes/hermes-kanban.ts @@ -221,8 +221,10 @@ export async function listTasks(opts?: { status?: string assignee?: string tenant?: string + includeArchived?: boolean }): Promise { const args = [...boardArgs(opts?.board), 'list', '--json'] + if (opts?.includeArchived) args.push('--archived') if (opts?.status) args.push('--status', opts.status) if (opts?.assignee) args.push('--assignee', opts.assignee) if (opts?.tenant) args.push('--tenant', opts.tenant) @@ -346,7 +348,13 @@ export async function getStats(opts?: KanbanBoardOptions): Promise timeout: 30000, ...execOpts, }) - return JSON.parse(stdout) + const stats = JSON.parse(stdout) as KanbanStats + const archivedTasks = await listTasks({ board: opts?.board, status: 'archived', includeArchived: true }) + const existingArchived = stats.by_status?.archived || 0 + const archivedCount = archivedTasks.length + stats.by_status = { ...(stats.by_status || {}), archived: archivedCount } + stats.total = (stats.total || 0) + Math.max(0, archivedCount - existingArchived) + return stats } catch (err: any) { logger.error(err, 'Hermes CLI: kanban stats failed') throw new Error(`Failed to get kanban stats: ${err.message}`) diff --git a/tests/client/kanban-api.test.ts b/tests/client/kanban-api.test.ts index ae607efa..9798c0b0 100644 --- a/tests/client/kanban-api.test.ts +++ b/tests/client/kanban-api.test.ts @@ -28,12 +28,12 @@ describe('Kanban API', () => { vi.clearAllMocks() }) - it('serializes board and list filters into query params', async () => { + it('serializes board, list filters, and archived inclusion into query params', async () => { mockRequest.mockResolvedValue({ tasks: [{ id: 'task-1' }] }) - const result = await listTasks({ board: 'default', status: 'blocked', assignee: 'alice', tenant: 'ops' }) + const result = await listTasks({ board: 'default', status: 'blocked', assignee: 'alice', tenant: 'ops', includeArchived: true }) - expect(mockRequest).toHaveBeenCalledWith('/api/hermes/kanban?board=default&status=blocked&assignee=alice&tenant=ops') + expect(mockRequest).toHaveBeenCalledWith('/api/hermes/kanban?board=default&status=blocked&assignee=alice&tenant=ops&includeArchived=true') expect(result).toEqual([{ id: 'task-1' }]) }) diff --git a/tests/client/kanban-store.test.ts b/tests/client/kanban-store.test.ts index e4dc116d..850b71cc 100644 --- a/tests/client/kanban-store.test.ts +++ b/tests/client/kanban-store.test.ts @@ -63,7 +63,7 @@ describe('Kanban store', () => { expect(store.loading).toBe(true) await promise - expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: 'blocked', assignee: 'alice' }) + expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: 'blocked', assignee: 'alice', includeArchived: true }) expect(store.tasks).toEqual([{ id: 'task-1', status: 'todo' }]) expect(store.loading).toBe(false) }) @@ -128,7 +128,7 @@ describe('Kanban store', () => { store.setSelectedBoard('project-a') await store.refreshAll() - expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: undefined, assignee: undefined }) + expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: undefined, assignee: undefined, includeArchived: true }) expect(mockKanbanApi.getStats).toHaveBeenCalledWith({ board: 'project-a' }) expect(mockKanbanApi.getAssignees).toHaveBeenCalledWith({ board: 'project-a' }) expect(mockKanbanApi.listBoards).toHaveBeenCalledWith({ includeArchived: false }) diff --git a/tests/server/hermes-kanban-service.test.ts b/tests/server/hermes-kanban-service.test.ts index 9e518da3..f19eb16b 100644 --- a/tests/server/hermes-kanban-service.test.ts +++ b/tests/server/hermes-kanban-service.test.ts @@ -55,26 +55,30 @@ describe('hermes kanban service', () => { .mockResolvedValueOnce({ stdout: JSON.stringify([{ id: 'task-1' }]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'task-2' }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ total: 1, by_status: {}, by_assignee: {} }) }) + .mockResolvedValueOnce({ stdout: JSON.stringify([{ id: 'archived-1', status: 'archived' }, { id: 'archived-2', status: 'archived' }]) }) - await expect(service.listTasks({ board: 'project-a', status: 'todo', assignee: 'alice', tenant: 'ops' })).resolves.toEqual([{ id: 'task-1' }]) + await expect(service.listTasks({ board: 'project-a', status: 'todo', assignee: 'alice', tenant: 'ops', includeArchived: true })).resolves.toEqual([{ id: 'task-1' }]) await expect(service.createTask('Ship', { board: 'project-a', body: 'write', assignee: 'alice', priority: 3, tenant: 'ops' })).resolves.toEqual({ id: 'task-2' }) - await expect(service.getStats({ board: 'project-a' })).resolves.toEqual({ total: 1, by_status: {}, by_assignee: {} }) + await expect(service.getStats({ board: 'project-a' })).resolves.toEqual({ total: 3, by_status: { archived: 2 }, by_assignee: {} }) - expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'project-a', 'list', '--json', '--status', 'todo', '--assignee', 'alice', '--tenant', 'ops']) + expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'project-a', 'list', '--json', '--archived', '--status', 'todo', '--assignee', 'alice', '--tenant', 'ops']) expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'project-a', 'create', 'Ship', '--json', '--body', 'write', '--assignee', 'alice', '--priority', '3', '--tenant', 'ops']) expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'project-a', 'stats', '--json']) + expect(mockExecFileAsync.mock.calls[3][1]).toEqual(['kanban', '--board', 'project-a', 'list', '--json', '--archived', '--status', 'archived']) }) it('normalizes omitted board to default instead of falling through to CLI current', async () => { mockExecFileAsync .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ total: 0, by_status: {}, by_assignee: {} }) }) + .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) await service.listTasks() await service.getStats() expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'default', 'list', '--json']) expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'default', 'stats', '--json']) + expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'default', 'list', '--json', '--archived', '--status', 'archived']) }) it('builds action CLI calls and maps not-found show to null', async () => { diff --git a/tests/server/kanban-controller.test.ts b/tests/server/kanban-controller.test.ts index 3cfca034..6530b1c1 100644 --- a/tests/server/kanban-controller.test.ts +++ b/tests/server/kanban-controller.test.ts @@ -82,9 +82,9 @@ describe('kanban controller', () => { expect(mockListBoards).toHaveBeenCalledWith({ includeArchived: true }) expect(boardsCtx.body).toEqual({ boards: [{ slug: 'default' }] }) - const c = ctx({ query: { board: 'project-a', status: 'todo', assignee: 'alice', tenant: 'ops' } }) + const c = ctx({ query: { board: 'project-a', status: 'todo', assignee: 'alice', tenant: 'ops', includeArchived: 'true' } }) await ctrl.list(c) - expect(mockListTasks).toHaveBeenCalledWith({ board: 'project-a', status: 'todo', assignee: 'alice', tenant: 'ops' }) + expect(mockListTasks).toHaveBeenCalledWith({ board: 'project-a', status: 'todo', assignee: 'alice', tenant: 'ops', includeArchived: true }) expect(c.body).toEqual({ tasks: [{ id: 'task-1' }] }) mockCreateBoard.mockResolvedValue({ slug: 'project-b' }) @@ -106,7 +106,7 @@ describe('kanban controller', () => { const defaultCtx = ctx({ query: { status: 'ready' } }) await ctrl.list(defaultCtx) - expect(mockListTasks).toHaveBeenLastCalledWith({ board: 'default', status: 'ready', assignee: undefined, tenant: undefined }) + expect(mockListTasks).toHaveBeenLastCalledWith({ board: 'default', status: 'ready', assignee: undefined, tenant: undefined, includeArchived: false }) }) it('enriches completed task details using the latest run profile', async () => {