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
@@ -41,6 +41,7 @@ function validSeverity(value?: string): value is 'warning' | 'error' | 'critical
const MAX_LOG_TAIL_BYTES = 1_000_000
const MAX_DISPATCH_TASKS = 100
const MAX_DISPATCH_FAILURE_LIMIT = 100
const MAX_BULK_TASKS = 100
type PositiveIntegerResult = { value?: number; error?: string }
type StringResult = { value?: string; error?: string }
@@ -85,6 +86,25 @@ function optionalString(value: unknown, name: string): StringResult {
return { value }
}
function optionalNullableString(value: unknown, name: string): { value?: string | null; error?: string } {
if (value === undefined) return {}
if (value === null) return { value: null }
if (typeof value !== 'string') return { error: `${name} must be a string` }
return { value }
}
function hasOwn(body: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(body, key)
}
function optionalTaskStatus(value: unknown, name: string): { value?: kanbanCli.KanbanTaskStatus; error?: string } {
if (value === undefined || value === null) return {}
if (value !== 'triage' && value !== 'todo' && value !== 'ready' && value !== 'running' && value !== 'blocked' && value !== 'done' && value !== 'archived') {
return { error: `${name} must be a valid kanban task status` }
}
return { value }
}
function requiredNonEmptyString(value: unknown, name: string): StringResult {
if (typeof value !== 'string' || !value.trim()) return { error: `${name} is required` }
return { value }
@@ -364,6 +384,80 @@ export async function addComment(ctx: Context) {
}
}
export async function linkTasks(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const parentId = requiredNonEmptyString(bodyResult.body.parent_id, 'parent_id')
const childId = requiredNonEmptyString(bodyResult.body.child_id, 'child_id')
if (rejectBadRequest(ctx, parentId.error || childId.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
ctx.body = await kanbanCli.linkTasks(parentId.value!.trim(), childId.value!.trim(), { board })
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function unlinkTasks(ctx: Context) {
const parentId = requiredNonEmptyString(firstQueryValue(ctx.query.parent_id as string | string[] | undefined), 'parent_id')
const childId = requiredNonEmptyString(firstQueryValue(ctx.query.child_id as string | string[] | undefined), 'child_id')
if (rejectBadRequest(ctx, parentId.error || childId.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
ctx.body = await kanbanCli.unlinkTasks(parentId.value!.trim(), childId.value!.trim(), { board })
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function bulkUpdateTasks(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const ids = requiredNonEmptyStringArray(body.ids, 'ids')
const status = optionalTaskStatus(body.status, 'status')
const assignee = optionalNullableString(body.assignee, 'assignee')
const archive = optionalBoolean(body.archive, 'archive')
const summary = optionalString(body.summary, 'summary')
const reason = optionalString(body.reason, 'reason')
if (rejectBadRequest(ctx, ids.error || status.error || assignee.error || archive.error || summary.error || reason.error)) return
if (!archive.value && status.value === undefined && !hasOwn(body, 'assignee')) {
ctx.status = 400
ctx.body = { error: 'at least one bulk action is required' }
return
}
if (ids.value!.length > MAX_BULK_TASKS) {
ctx.status = 400
ctx.body = { error: `ids must contain <= ${MAX_BULK_TASKS} tasks` }
return
}
if (archive.value && status.value !== undefined) {
ctx.status = 400
ctx.body = { error: 'archive cannot be combined with status' }
return
}
const board = requestBoard(ctx)
if (!board) return
try {
ctx.body = await kanbanCli.bulkUpdateTasks({
board,
ids: ids.value!.map(id => id.trim()),
status: status.value,
assignee: assignee.value,
archive: archive.value,
summary: summary.value,
reason: reason.value,
})
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function taskLog(ctx: Context) {
const board = requestBoard(ctx)
if (!board) return