Kanban:补齐看板事件、链接与批量操作闭环 (#634)
* feat(kanban): add board-scoped event stream bridge * test(kanban): align event refresh expectation * feat(kanban): add links and partial bulk bridge * test(kanban): align links bulk refresh expectation * fix(kanban): treat mutation stderr as failed
This commit is contained in:
@@ -45,15 +45,70 @@ describe('hermes kanban service', () => {
|
||||
it('exposes capability metadata for WUI/canonical parity gaps', async () => {
|
||||
await expect(service.getCapabilities()).resolves.toMatchObject({
|
||||
source: 'hermes-cli',
|
||||
supports: { boardsList: true, boardCreate: true, commentsWrite: true, dispatch: true },
|
||||
missing: expect.arrayContaining(['cliCurrentSwitch', 'links', 'bulk', 'events', 'homeSubscriptions']),
|
||||
supports: { boardsList: true, boardCreate: true, commentsWrite: true, dispatch: true, links: true },
|
||||
missing: expect.arrayContaining(['cliCurrentSwitch', 'bulk', 'homeSubscriptions']),
|
||||
capabilities: expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'commentsWrite', status: 'supported', canonicalCommand: 'comment', requiresBoard: true }),
|
||||
expect.objectContaining({ key: 'events', status: 'missing', canonicalRoute: '/events', requiresBoard: true }),
|
||||
expect.objectContaining({ key: 'links', status: 'supported', canonicalRoute: '/links', canonicalCommand: 'link/unlink', requiresBoard: true }),
|
||||
expect.objectContaining({ key: 'bulk', status: 'partial', canonicalRoute: '/tasks/bulk', requiresBoard: true }),
|
||||
expect.objectContaining({ key: 'events', status: 'partial', canonicalRoute: '/events', canonicalCommand: 'watch', requiresBoard: true }),
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
it('builds board-scoped watch args for the kanban event bridge', () => {
|
||||
expect(service.buildWatchArgs({ board: 'Project_A', interval: 0.25 })).toEqual(['kanban', '--board', 'project_a', 'watch', '--interval', '0.25'])
|
||||
expect(service.buildWatchArgs()).toEqual(['kanban', '--board', 'default', 'watch', '--interval', '0.5'])
|
||||
})
|
||||
|
||||
it('builds link/unlink and bulk-equivalent task commands with explicit board', async () => {
|
||||
mockExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: 'linked\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'unlinked\n' })
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
.mockRejectedValueOnce(new Error('cannot complete task-2'))
|
||||
|
||||
await expect(service.linkTasks('task-1', 'task-2', { board: 'project-a' })).resolves.toEqual({ ok: true, output: 'linked\n' })
|
||||
await expect(service.unlinkTasks('task-1', 'task-2', { board: 'project-a' })).resolves.toEqual({ ok: true, output: 'unlinked\n' })
|
||||
await expect(service.bulkUpdateTasks({ board: 'project-a', ids: ['task-1', 'task-2'], status: 'done', assignee: 'alice', summary: 'closed' })).resolves.toEqual({
|
||||
results: [
|
||||
{ id: 'task-1', ok: true },
|
||||
{ id: 'task-2', ok: false, error: 'Failed to complete kanban tasks: cannot complete task-2' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'project-a', 'link', 'task-1', 'task-2'])
|
||||
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'project-a', 'unlink', 'task-1', 'task-2'])
|
||||
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'project-a', 'complete', 'task-1', '--summary', 'closed'])
|
||||
expect(mockExecFileAsync.mock.calls[3][1]).toEqual(['kanban', '--board', 'project-a', 'assign', 'task-1', 'alice'])
|
||||
expect(mockExecFileAsync.mock.calls[4][1]).toEqual(['kanban', '--board', 'project-a', 'complete', 'task-2', '--summary', 'closed'])
|
||||
})
|
||||
|
||||
it('treats zero-exit stderr from mutation CLI calls as failures', async () => {
|
||||
mockExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'kanban: unknown task(s): missing-a, missing-b\n' })
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'No such link: missing-a -> missing-b\n' })
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'kanban: unknown task(s): task-1\n' })
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'kanban: unknown task(s): task-2\n' })
|
||||
|
||||
await expect(service.linkTasks('missing-a', 'missing-b', { board: 'project-a' })).rejects.toThrow('Failed to link kanban tasks: kanban: unknown task(s): missing-a, missing-b')
|
||||
await expect(service.unlinkTasks('missing-a', 'missing-b', { board: 'project-a' })).rejects.toThrow('Failed to unlink kanban tasks: No such link: missing-a -> missing-b')
|
||||
await expect(service.bulkUpdateTasks({ board: 'project-a', ids: ['task-1', 'task-2'], status: 'done' })).resolves.toEqual({
|
||||
results: [
|
||||
{ id: 'task-1', ok: false, error: 'Failed to complete kanban tasks: kanban: unknown task(s): task-1' },
|
||||
{ id: 'task-2', ok: false, error: 'Failed to complete kanban tasks: kanban: unknown task(s): task-2' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns per-task bulk errors for unsupported direct status patches before shelling out', async () => {
|
||||
await expect(service.bulkUpdateTasks({ board: 'project-a', ids: ['task-1'], status: 'running' })).resolves.toEqual({
|
||||
results: [{ id: 'task-1', ok: false, error: 'Bulk status running is not supported by the CLI bridge' }],
|
||||
})
|
||||
expect(mockExecFileAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('builds comment/log/diagnostics commands with explicit board', async () => {
|
||||
mockExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: 'comment added\n' })
|
||||
|
||||
Reference in New Issue
Block a user