fix(kanban): include archived tasks in board counts (#619)
This commit is contained in:
@@ -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<KanbanTask[]>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -89,11 +89,14 @@ export async function capabilities(ctx: Context) {
|
||||
}
|
||||
|
||||
export async function list(ctx: Context) {
|
||||
const { status, assignee, tenant } = ctx.query as Record<string, string | undefined>
|
||||
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
|
||||
|
||||
@@ -221,8 +221,10 @@ export async function listTasks(opts?: {
|
||||
status?: string
|
||||
assignee?: string
|
||||
tenant?: string
|
||||
includeArchived?: boolean
|
||||
}): Promise<KanbanTask[]> {
|
||||
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<KanbanStats>
|
||||
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}`)
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user