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:
Zhicheng Han
2026-05-13 01:32:38 +02:00
committed by GitHub
parent 44d1b13741
commit 57cdf87bef
14 changed files with 758 additions and 50 deletions
+58 -3
View File
@@ -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' })
+33
View File
@@ -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 },
+5
View File
@@ -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',