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' })
|
||||
|
||||
@@ -13,6 +13,9 @@ const mockBlockTask = vi.hoisted(() => vi.fn())
|
||||
const mockUnblockTasks = vi.hoisted(() => vi.fn())
|
||||
const mockAssignTask = vi.hoisted(() => vi.fn())
|
||||
const mockAddComment = vi.hoisted(() => vi.fn())
|
||||
const mockLinkTasks = vi.hoisted(() => vi.fn())
|
||||
const mockUnlinkTasks = vi.hoisted(() => vi.fn())
|
||||
const mockBulkUpdateTasks = vi.hoisted(() => vi.fn())
|
||||
const mockGetTaskLog = vi.hoisted(() => vi.fn())
|
||||
const mockGetDiagnostics = vi.hoisted(() => vi.fn())
|
||||
const mockReclaimTask = vi.hoisted(() => vi.fn())
|
||||
@@ -52,6 +55,9 @@ vi.mock('../../packages/server/src/services/hermes/hermes-kanban', () => ({
|
||||
unblockTasks: mockUnblockTasks,
|
||||
assignTask: mockAssignTask,
|
||||
addComment: mockAddComment,
|
||||
linkTasks: mockLinkTasks,
|
||||
unlinkTasks: mockUnlinkTasks,
|
||||
bulkUpdateTasks: mockBulkUpdateTasks,
|
||||
getTaskLog: mockGetTaskLog,
|
||||
getDiagnostics: mockGetDiagnostics,
|
||||
reclaimTask: mockReclaimTask,
|
||||
@@ -145,6 +151,27 @@ describe('kanban controller', () => {
|
||||
expect(diagnosticsCtx.body).toEqual({ diagnostics: [{ task_id: 'task-1' }] })
|
||||
})
|
||||
|
||||
it('proxies links and bulk actions with explicit board context', async () => {
|
||||
mockLinkTasks.mockResolvedValue({ ok: true, output: 'linked' })
|
||||
mockUnlinkTasks.mockResolvedValue({ ok: true, output: 'unlinked' })
|
||||
mockBulkUpdateTasks.mockResolvedValue({ results: [{ id: 'task-1', ok: true }] })
|
||||
|
||||
const linkCtx = ctx({ query: { board: 'project-a' }, request: { body: { parent_id: 'task-1', child_id: 'task-2' } } })
|
||||
await ctrl.linkTasks(linkCtx)
|
||||
expect(mockLinkTasks).toHaveBeenCalledWith('task-1', 'task-2', { board: 'project-a' })
|
||||
expect(linkCtx.body).toEqual({ ok: true, output: 'linked' })
|
||||
|
||||
const unlinkCtx = ctx({ query: { board: 'project-a', parent_id: 'task-1', child_id: 'task-2' } })
|
||||
await ctrl.unlinkTasks(unlinkCtx)
|
||||
expect(mockUnlinkTasks).toHaveBeenCalledWith('task-1', 'task-2', { board: 'project-a' })
|
||||
expect(unlinkCtx.body).toEqual({ ok: true, output: 'unlinked' })
|
||||
|
||||
const bulkCtx = ctx({ query: { board: 'project-a' }, request: { body: { ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' } } })
|
||||
await ctrl.bulkUpdateTasks(bulkCtx)
|
||||
expect(mockBulkUpdateTasks).toHaveBeenCalledWith({ board: 'project-a', ids: ['task-1'], status: 'done', assignee: null, archive: undefined, summary: 'closed', reason: undefined })
|
||||
expect(bulkCtx.body).toEqual({ results: [{ id: 'task-1', ok: true }] })
|
||||
})
|
||||
|
||||
it('validates canonical parity endpoint inputs before shelling out', async () => {
|
||||
const invalidTailCtx = ctx({ query: { board: 'default', tail: '0' }, params: { id: 'task-1' } })
|
||||
await ctrl.taskLog(invalidTailCtx)
|
||||
@@ -182,6 +209,12 @@ describe('kanban controller', () => {
|
||||
{ name: 'comment body object', invoke: ctrl.addComment, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { body: {}, author: 'han' } } }), mock: mockAddComment },
|
||||
{ name: 'comment request body array', invoke: ctrl.addComment, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: [] } }), mock: mockAddComment },
|
||||
{ name: 'comment author object', invoke: ctrl.addComment, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { body: 'ok', author: {} } } }), mock: mockAddComment },
|
||||
{ name: 'link missing child', invoke: ctrl.linkTasks, context: ctx({ query: { board: 'default' }, request: { body: { parent_id: 'task-1' } } }), mock: mockLinkTasks },
|
||||
{ name: 'unlink missing parent', invoke: ctrl.unlinkTasks, context: ctx({ query: { board: 'default', child_id: 'task-2' } }), mock: mockUnlinkTasks },
|
||||
{ name: 'bulk empty ids', invoke: ctrl.bulkUpdateTasks, context: ctx({ query: { board: 'default' }, request: { body: { ids: [], status: 'done' } } }), mock: mockBulkUpdateTasks },
|
||||
{ name: 'bulk invalid status', invoke: ctrl.bulkUpdateTasks, context: ctx({ query: { board: 'default' }, request: { body: { ids: ['task-1'], status: 'invalid' } } }), mock: mockBulkUpdateTasks },
|
||||
{ name: 'bulk archive with status', invoke: ctrl.bulkUpdateTasks, context: ctx({ query: { board: 'default' }, request: { body: { ids: ['task-1'], archive: true, status: 'done' } } }), mock: mockBulkUpdateTasks },
|
||||
{ name: 'bulk no action', invoke: ctrl.bulkUpdateTasks, context: ctx({ query: { board: 'default' }, request: { body: { ids: ['task-1'] } } }), mock: mockBulkUpdateTasks },
|
||||
{ name: 'reclaim request body string', invoke: ctrl.reclaim, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: 'bad' } }), mock: mockReclaimTask },
|
||||
{ name: 'reclaim reason array', invoke: ctrl.reclaim, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { reason: [] } } }), mock: mockReclaimTask },
|
||||
{ name: 'reassign reclaim string', invoke: ctrl.reassign, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { profile: 'bob', reclaim: 'false' } } }), mock: mockReassignTask },
|
||||
|
||||
@@ -9,6 +9,9 @@ const handlers = {
|
||||
assignees: vi.fn(async (ctx: any) => { ctx.body = { assignees: [] } }),
|
||||
readArtifact: vi.fn(async (ctx: any) => { ctx.body = { content: 'x' } }),
|
||||
searchSessions: vi.fn(async (ctx: any) => { ctx.body = { results: [] } }),
|
||||
linkTasks: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
|
||||
unlinkTasks: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
|
||||
bulkUpdateTasks: vi.fn(async (ctx: any) => { ctx.body = { results: [] } }),
|
||||
list: vi.fn(async (ctx: any) => { ctx.body = { tasks: [] } }),
|
||||
get: vi.fn(async (ctx: any) => { ctx.body = { task: {} } }),
|
||||
create: vi.fn(async (ctx: any) => { ctx.body = { task: {} } }),
|
||||
@@ -47,6 +50,8 @@ describe('kanban routes', () => {
|
||||
'/api/hermes/kanban/dispatch',
|
||||
'/api/hermes/kanban/artifact',
|
||||
'/api/hermes/kanban/search-sessions',
|
||||
'/api/hermes/kanban/links',
|
||||
'/api/hermes/kanban/tasks/bulk',
|
||||
'/api/hermes/kanban',
|
||||
'/api/hermes/kanban/:id',
|
||||
'/api/hermes/kanban/complete',
|
||||
|
||||
Reference in New Issue
Block a user