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
+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 },