修复 WUI Kanban 看板选择与隔离 (#594)
* fix: add explicit kanban board selection * fix: tighten kanban board counts and management
This commit is contained in:
@@ -14,10 +14,86 @@ function getLatestRunProfile(detail: { runs: Array<{ profile: string | null }> }
|
||||
return [...detail.runs].reverse().find(run => run.profile)?.profile || null
|
||||
}
|
||||
|
||||
function firstQueryValue(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value
|
||||
}
|
||||
|
||||
function requestBoard(ctx: Context): string | null {
|
||||
try {
|
||||
return kanbanCli.normalizeBoardSlug(firstQueryValue(ctx.query.board as string | string[] | undefined))
|
||||
} catch {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'invalid board slug' }
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function listBoards(ctx: Context) {
|
||||
const includeArchived = firstQueryValue(ctx.query.includeArchived as string | string[] | undefined) === 'true'
|
||||
try {
|
||||
const boards = await kanbanCli.listBoards({ includeArchived })
|
||||
ctx.body = { boards }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBoard(ctx: Context) {
|
||||
const { slug, name, description, icon, color, switchCurrent } = ctx.request.body as {
|
||||
slug?: string
|
||||
name?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
color?: string
|
||||
switchCurrent?: boolean
|
||||
}
|
||||
if (!slug?.trim()) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'slug is required' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const board = await kanbanCli.createBoard({ slug, name, description, icon, color, switchCurrent })
|
||||
ctx.body = { board }
|
||||
} catch (err: any) {
|
||||
ctx.status = err.message?.includes('Invalid kanban board slug') ? 400 : 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveBoard(ctx: Context) {
|
||||
const slug = ctx.params.slug
|
||||
if (!slug?.trim()) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'slug is required' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
await kanbanCli.archiveBoard(slug)
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = err.message?.includes('default') || err.message?.includes('Invalid kanban board slug') ? 400 : 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function capabilities(ctx: Context) {
|
||||
try {
|
||||
const capabilities = await kanbanCli.getCapabilities()
|
||||
ctx.body = { capabilities }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function list(ctx: Context) {
|
||||
const { status, assignee, tenant } = ctx.query as Record<string, string | undefined>
|
||||
const board = requestBoard(ctx)
|
||||
if (!board) return
|
||||
try {
|
||||
const tasks = await kanbanCli.listTasks({ status, assignee, tenant })
|
||||
const tasks = await kanbanCli.listTasks({ board, status, assignee, tenant })
|
||||
ctx.body = { tasks }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
@@ -26,8 +102,10 @@ export async function list(ctx: Context) {
|
||||
}
|
||||
|
||||
export async function get(ctx: Context) {
|
||||
const board = requestBoard(ctx)
|
||||
if (!board) return
|
||||
try {
|
||||
const detail = await kanbanCli.getTask(ctx.params.id)
|
||||
const detail = await kanbanCli.getTask(ctx.params.id, { board })
|
||||
if (!detail) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Task not found' }
|
||||
@@ -97,8 +175,10 @@ export async function create(ctx: Context) {
|
||||
ctx.body = { error: 'title is required' }
|
||||
return
|
||||
}
|
||||
const board = requestBoard(ctx)
|
||||
if (!board) return
|
||||
try {
|
||||
const task = await kanbanCli.createTask(title, { body, assignee, priority, tenant })
|
||||
const task = await kanbanCli.createTask(title, { board, body, assignee, priority, tenant })
|
||||
ctx.body = { task }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
@@ -116,8 +196,10 @@ export async function complete(ctx: Context) {
|
||||
ctx.body = { error: 'task_ids is required' }
|
||||
return
|
||||
}
|
||||
const board = requestBoard(ctx)
|
||||
if (!board) return
|
||||
try {
|
||||
await kanbanCli.completeTasks(task_ids, summary)
|
||||
await kanbanCli.completeTasks(task_ids, summary, { board })
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
@@ -132,8 +214,10 @@ export async function block(ctx: Context) {
|
||||
ctx.body = { error: 'reason is required' }
|
||||
return
|
||||
}
|
||||
const board = requestBoard(ctx)
|
||||
if (!board) return
|
||||
try {
|
||||
await kanbanCli.blockTask(ctx.params.id, reason)
|
||||
await kanbanCli.blockTask(ctx.params.id, reason, { board })
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
@@ -148,8 +232,10 @@ export async function unblock(ctx: Context) {
|
||||
ctx.body = { error: 'task_ids is required' }
|
||||
return
|
||||
}
|
||||
const board = requestBoard(ctx)
|
||||
if (!board) return
|
||||
try {
|
||||
await kanbanCli.unblockTasks(task_ids)
|
||||
await kanbanCli.unblockTasks(task_ids, { board })
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
@@ -164,8 +250,10 @@ export async function assign(ctx: Context) {
|
||||
ctx.body = { error: 'profile is required' }
|
||||
return
|
||||
}
|
||||
const board = requestBoard(ctx)
|
||||
if (!board) return
|
||||
try {
|
||||
await kanbanCli.assignTask(ctx.params.id, profile)
|
||||
await kanbanCli.assignTask(ctx.params.id, profile, { board })
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
@@ -174,8 +262,10 @@ export async function assign(ctx: Context) {
|
||||
}
|
||||
|
||||
export async function stats(ctx: Context) {
|
||||
const board = requestBoard(ctx)
|
||||
if (!board) return
|
||||
try {
|
||||
const stats = await kanbanCli.getStats()
|
||||
const stats = await kanbanCli.getStats({ board })
|
||||
ctx.body = { stats }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
@@ -184,8 +274,10 @@ export async function stats(ctx: Context) {
|
||||
}
|
||||
|
||||
export async function assignees(ctx: Context) {
|
||||
const board = requestBoard(ctx)
|
||||
if (!board) return
|
||||
try {
|
||||
const assignees = await kanbanCli.getAssignees()
|
||||
const assignees = await kanbanCli.getAssignees({ board })
|
||||
ctx.body = { assignees }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
|
||||
@@ -3,6 +3,10 @@ import * as ctrl from '../../controllers/hermes/kanban'
|
||||
|
||||
export const kanbanRoutes = new Router()
|
||||
|
||||
kanbanRoutes.get('/api/hermes/kanban/boards', ctrl.listBoards)
|
||||
kanbanRoutes.post('/api/hermes/kanban/boards', ctrl.createBoard)
|
||||
kanbanRoutes.delete('/api/hermes/kanban/boards/:slug', ctrl.archiveBoard)
|
||||
kanbanRoutes.get('/api/hermes/kanban/capabilities', ctrl.capabilities)
|
||||
kanbanRoutes.get('/api/hermes/kanban/stats', ctrl.stats)
|
||||
kanbanRoutes.get('/api/hermes/kanban/assignees', ctrl.assignees)
|
||||
kanbanRoutes.get('/api/hermes/kanban/artifact', ctrl.readArtifact)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { logger } from '../logger'
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const execOpts = { windowsHide: true }
|
||||
const BOARD_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/
|
||||
|
||||
function resolveHermesBin(): string {
|
||||
const envBin = process.env.HERMES_BIN?.trim()
|
||||
@@ -14,6 +15,19 @@ function resolveHermesBin(): string {
|
||||
|
||||
const HERMES_BIN = resolveHermesBin()
|
||||
|
||||
export function normalizeBoardSlug(board?: string | null): string {
|
||||
const trimmed = board?.trim()
|
||||
if (!trimmed) return 'default'
|
||||
if (!BOARD_SLUG_RE.test(trimmed)) {
|
||||
throw new Error('Invalid kanban board slug')
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function boardArgs(board?: string | null): string[] {
|
||||
return ['kanban', '--board', normalizeBoardSlug(board)]
|
||||
}
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
export type KanbanTaskStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'archived'
|
||||
@@ -84,14 +98,131 @@ export interface KanbanAssignee {
|
||||
counts: Record<string, number> | null
|
||||
}
|
||||
|
||||
export interface KanbanBoard {
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
color: string
|
||||
created_at: number | null
|
||||
archived: boolean
|
||||
db_path?: string
|
||||
is_current?: boolean
|
||||
counts: Record<string, number>
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface KanbanBoardCreateOptions {
|
||||
slug: string
|
||||
name?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
color?: string
|
||||
switchCurrent?: boolean
|
||||
}
|
||||
|
||||
export interface KanbanCapabilities {
|
||||
source: 'hermes-cli'
|
||||
supports: Record<string, boolean>
|
||||
missing: string[]
|
||||
}
|
||||
|
||||
export interface KanbanBoardOptions {
|
||||
board?: string
|
||||
}
|
||||
|
||||
// ─── CLI wrappers ───────────────────────────────────────────────
|
||||
|
||||
export async function listBoards(opts?: { includeArchived?: boolean }): Promise<KanbanBoard[]> {
|
||||
const args = ['kanban', 'boards', 'list', '--json']
|
||||
if (opts?.includeArchived) args.push('--all')
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, args, {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
return JSON.parse(stdout)
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: kanban boards list failed')
|
||||
throw new Error(`Failed to list kanban boards: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function findBoard(slug: string, includeArchived = true): Promise<KanbanBoard | null> {
|
||||
const boards = await listBoards({ includeArchived })
|
||||
return boards.find(board => board.slug === slug) || null
|
||||
}
|
||||
|
||||
export async function createBoard(opts: KanbanBoardCreateOptions): Promise<KanbanBoard> {
|
||||
const slug = normalizeBoardSlug(opts.slug)
|
||||
const args = ['kanban', 'boards', 'create', slug]
|
||||
if (opts.name?.trim()) args.push('--name', opts.name.trim())
|
||||
if (opts.description?.trim()) args.push('--description', opts.description.trim())
|
||||
if (opts.icon?.trim()) args.push('--icon', opts.icon.trim())
|
||||
if (opts.color?.trim()) args.push('--color', opts.color.trim())
|
||||
if (opts.switchCurrent) args.push('--switch')
|
||||
|
||||
try {
|
||||
await execFileAsync(HERMES_BIN, args, {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
const board = await findBoard(slug)
|
||||
if (!board) throw new Error('created board was not returned by boards list')
|
||||
return board
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: kanban boards create failed')
|
||||
throw new Error(`Failed to create kanban board: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveBoard(slugInput: string): Promise<void> {
|
||||
const slug = normalizeBoardSlug(slugInput)
|
||||
if (slug === 'default') throw new Error('Cannot archive the default kanban board')
|
||||
|
||||
try {
|
||||
await execFileAsync(HERMES_BIN, ['kanban', 'boards', 'rm', slug], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: kanban boards archive failed')
|
||||
throw new Error(`Failed to archive kanban board: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCapabilities(): Promise<KanbanCapabilities> {
|
||||
const supports = {
|
||||
explicitBoard: true,
|
||||
boardsList: true,
|
||||
boardCreate: true,
|
||||
boardArchive: true,
|
||||
cliCurrentSwitch: true,
|
||||
taskCrudLite: true,
|
||||
commentsWrite: false,
|
||||
taskLog: false,
|
||||
dispatch: false,
|
||||
events: false,
|
||||
diagnostics: false,
|
||||
bulk: false,
|
||||
}
|
||||
const missing = Object.entries(supports)
|
||||
.filter(([, supported]) => !supported)
|
||||
.map(([name]) => name)
|
||||
return { source: 'hermes-cli', supports, missing }
|
||||
}
|
||||
|
||||
export async function listTasks(opts?: {
|
||||
board?: string
|
||||
status?: string
|
||||
assignee?: string
|
||||
tenant?: string
|
||||
}): Promise<KanbanTask[]> {
|
||||
const args = ['kanban', 'list', '--json']
|
||||
const args = [...boardArgs(opts?.board), 'list', '--json']
|
||||
if (opts?.status) args.push('--status', opts.status)
|
||||
if (opts?.assignee) args.push('--assignee', opts.assignee)
|
||||
if (opts?.tenant) args.push('--tenant', opts.tenant)
|
||||
@@ -109,9 +240,9 @@ export async function listTasks(opts?: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTask(taskId: string): Promise<KanbanTaskDetail | null> {
|
||||
export async function getTask(taskId: string, opts?: KanbanBoardOptions): Promise<KanbanTaskDetail | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, ['kanban', 'show', taskId, '--json'], {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, [...boardArgs(opts?.board), 'show', taskId, '--json'], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
@@ -127,13 +258,14 @@ export async function getTask(taskId: string): Promise<KanbanTaskDetail | null>
|
||||
export async function createTask(
|
||||
title: string,
|
||||
opts?: {
|
||||
board?: string
|
||||
body?: string
|
||||
assignee?: string
|
||||
priority?: number
|
||||
tenant?: string
|
||||
},
|
||||
): Promise<KanbanTask> {
|
||||
const args = ['kanban', 'create', title, '--json']
|
||||
const args = [...boardArgs(opts?.board), 'create', title, '--json']
|
||||
if (opts?.body) args.push('--body', opts.body)
|
||||
if (opts?.assignee) args.push('--assignee', opts.assignee)
|
||||
if (opts?.priority !== undefined) args.push('--priority', String(opts.priority))
|
||||
@@ -152,8 +284,8 @@ export async function createTask(
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeTasks(taskIds: string[], summary?: string): Promise<void> {
|
||||
const args = ['kanban', 'complete', ...taskIds]
|
||||
export async function completeTasks(taskIds: string[], summary?: string, opts?: KanbanBoardOptions): Promise<void> {
|
||||
const args = [...boardArgs(opts?.board), 'complete', ...taskIds]
|
||||
if (summary) args.push('--summary', summary)
|
||||
|
||||
try {
|
||||
@@ -168,9 +300,9 @@ export async function completeTasks(taskIds: string[], summary?: string): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export async function blockTask(taskId: string, reason: string): Promise<void> {
|
||||
export async function blockTask(taskId: string, reason: string, opts?: KanbanBoardOptions): Promise<void> {
|
||||
try {
|
||||
await execFileAsync(HERMES_BIN, ['kanban', 'block', taskId, reason], {
|
||||
await execFileAsync(HERMES_BIN, [...boardArgs(opts?.board), 'block', taskId, reason], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
@@ -181,9 +313,9 @@ export async function blockTask(taskId: string, reason: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function unblockTasks(taskIds: string[]): Promise<void> {
|
||||
export async function unblockTasks(taskIds: string[], opts?: KanbanBoardOptions): Promise<void> {
|
||||
try {
|
||||
await execFileAsync(HERMES_BIN, ['kanban', 'unblock', ...taskIds], {
|
||||
await execFileAsync(HERMES_BIN, [...boardArgs(opts?.board), 'unblock', ...taskIds], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
@@ -194,9 +326,9 @@ export async function unblockTasks(taskIds: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function assignTask(taskId: string, profile: string): Promise<void> {
|
||||
export async function assignTask(taskId: string, profile: string, opts?: KanbanBoardOptions): Promise<void> {
|
||||
try {
|
||||
await execFileAsync(HERMES_BIN, ['kanban', 'assign', taskId, profile], {
|
||||
await execFileAsync(HERMES_BIN, [...boardArgs(opts?.board), 'assign', taskId, profile], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
@@ -207,9 +339,9 @@ export async function assignTask(taskId: string, profile: string): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStats(): Promise<KanbanStats> {
|
||||
export async function getStats(opts?: KanbanBoardOptions): Promise<KanbanStats> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, ['kanban', 'stats', '--json'], {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, [...boardArgs(opts?.board), 'stats', '--json'], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
@@ -221,9 +353,9 @@ export async function getStats(): Promise<KanbanStats> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAssignees(): Promise<KanbanAssignee[]> {
|
||||
export async function getAssignees(opts?: KanbanBoardOptions): Promise<KanbanAssignee[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, ['kanban', 'assignees', '--json'], {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, [...boardArgs(opts?.board), 'assignees', '--json'], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
|
||||
Reference in New Issue
Block a user