add hermes kanban board (#534)
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import type { Context } from 'koa'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { resolve, normalize } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import * as kanbanCli from '../../services/hermes/hermes-kanban'
|
||||
import { searchSessionSummariesWithProfile, getSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db'
|
||||
|
||||
function getLatestRunProfile(detail: { runs: Array<{ profile: string | null }> }): string | null {
|
||||
return [...detail.runs].reverse().find(run => run.profile)?.profile || null
|
||||
}
|
||||
|
||||
export async function list(ctx: Context) {
|
||||
const { status, assignee, tenant } = ctx.query as Record<string, string | undefined>
|
||||
try {
|
||||
const tasks = await kanbanCli.listTasks({ status, assignee, tenant })
|
||||
ctx.body = { tasks }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function get(ctx: Context) {
|
||||
try {
|
||||
const detail = await kanbanCli.getTask(ctx.params.id)
|
||||
if (!detail) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Task not found' }
|
||||
return
|
||||
}
|
||||
|
||||
// For completed tasks, find related session from the worker's profile DB
|
||||
if (detail.task.status === 'done' && detail.runs.length > 0) {
|
||||
const profile = getLatestRunProfile(detail)
|
||||
if (profile) {
|
||||
try {
|
||||
const results = await searchSessionSummariesWithProfile(detail.task.id, profile, undefined, 5)
|
||||
if (results.length > 0) {
|
||||
const sessionId = results[0].id
|
||||
const sessionDetail = await getSessionDetailFromDbWithProfile(sessionId, profile)
|
||||
if (sessionDetail) {
|
||||
;(detail as any).session = {
|
||||
id: sessionId,
|
||||
title: sessionDetail.title,
|
||||
source: sessionDetail.source,
|
||||
model: sessionDetail.model,
|
||||
started_at: sessionDetail.started_at,
|
||||
ended_at: sessionDetail.ended_at,
|
||||
messages: sessionDetail.messages,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Session lookup is best-effort, don't fail the whole request
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.body = detail
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(ctx: Context) {
|
||||
const { title, body, assignee, priority, tenant } = ctx.request.body as {
|
||||
title?: string
|
||||
body?: string
|
||||
assignee?: string
|
||||
priority?: number
|
||||
tenant?: string
|
||||
}
|
||||
if (!title) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'title is required' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const task = await kanbanCli.createTask(title, { body, assignee, priority, tenant })
|
||||
ctx.body = { task }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function complete(ctx: Context) {
|
||||
const { task_ids, summary } = ctx.request.body as {
|
||||
task_ids?: string[]
|
||||
summary?: string
|
||||
}
|
||||
if (!task_ids?.length) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'task_ids is required' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
await kanbanCli.completeTasks(task_ids, summary)
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function block(ctx: Context) {
|
||||
const { reason } = ctx.request.body as { reason?: string }
|
||||
if (!reason) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'reason is required' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
await kanbanCli.blockTask(ctx.params.id, reason)
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function unblock(ctx: Context) {
|
||||
const { task_ids } = ctx.request.body as { task_ids?: string[] }
|
||||
if (!task_ids?.length) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'task_ids is required' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
await kanbanCli.unblockTasks(task_ids)
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function assign(ctx: Context) {
|
||||
const { profile } = ctx.request.body as { profile?: string }
|
||||
if (!profile) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'profile is required' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
await kanbanCli.assignTask(ctx.params.id, profile)
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function stats(ctx: Context) {
|
||||
try {
|
||||
const stats = await kanbanCli.getStats()
|
||||
ctx.body = { stats }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function assignees(ctx: Context) {
|
||||
try {
|
||||
const assignees = await kanbanCli.getAssignees()
|
||||
ctx.body = { assignees }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function readArtifact(ctx: Context) {
|
||||
const filePath = ctx.query.path as string | undefined
|
||||
if (!filePath) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'path is required' }
|
||||
return
|
||||
}
|
||||
|
||||
const kanbanDir = resolve(homedir(), '.hermes', 'kanban', 'workspaces')
|
||||
const resolved = resolve(normalize(filePath))
|
||||
|
||||
if (!resolved.startsWith(kanbanDir)) {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: 'Path must be within kanban workspaces' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await readFile(resolved, 'utf-8')
|
||||
ctx.body = { content: data, path: filePath }
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'File not found' }
|
||||
} else {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchSessions(ctx: Context) {
|
||||
const { task_id, profile, q } = ctx.query as {
|
||||
task_id?: string
|
||||
profile?: string
|
||||
q?: string
|
||||
}
|
||||
if (!task_id || !profile) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'task_id and profile are required' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const searchQuery = q || task_id
|
||||
const results = await searchSessionSummariesWithProfile(searchQuery, profile, undefined, 10)
|
||||
ctx.body = { results }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
@@ -859,6 +859,107 @@ export async function listSessionSummaries(source?: string, limit = 2000, profil
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchSessionSummariesWithProfile(
|
||||
query: string,
|
||||
profile: string,
|
||||
source?: string,
|
||||
limit = 20,
|
||||
): Promise<HermesSessionSearchRow[]> {
|
||||
if (!SQLITE_AVAILABLE) {
|
||||
throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`)
|
||||
}
|
||||
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return []
|
||||
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const dbPath = `${getProfileDir(profile)}/state.db`
|
||||
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
|
||||
const normalized = sanitizeFtsQuery(trimmed)
|
||||
const prefixQuery = toPrefixQuery(normalized)
|
||||
const titlePattern = buildLikePattern(normalizeTitleLikeQuery(trimmed).toLowerCase())
|
||||
const useLiteralContentSearch = containsCjk(trimmed) || shouldUseLiteralContentSearch(trimmed)
|
||||
const candidateLimit = searchCandidateLimit(limit)
|
||||
|
||||
try {
|
||||
const sourceClause = source ? 'AND s.source = ?' : ''
|
||||
const sourceParams = source ? [source] : []
|
||||
const titleSql = `
|
||||
WITH base AS (
|
||||
SELECT
|
||||
${SESSION_SELECT},
|
||||
s.parent_session_id AS parent_session_id
|
||||
FROM sessions s
|
||||
WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%'
|
||||
${sourceClause}
|
||||
)
|
||||
SELECT
|
||||
base.*,
|
||||
NULL AS matched_message_id,
|
||||
CASE
|
||||
WHEN base.title IS NOT NULL AND base.title != '' THEN base.title
|
||||
ELSE base.preview
|
||||
END AS snippet,
|
||||
0 AS rank
|
||||
FROM base
|
||||
WHERE LOWER(COALESCE(base.title, '')) LIKE ? ESCAPE '\\'
|
||||
ORDER BY base.last_active DESC
|
||||
LIMIT ?
|
||||
`
|
||||
const titleRows = db.prepare(titleSql).all(...sourceParams, titlePattern, candidateLimit) as Record<string, unknown>[]
|
||||
|
||||
const contentSql = `
|
||||
WITH base AS (
|
||||
SELECT
|
||||
${SESSION_SELECT},
|
||||
s.parent_session_id AS parent_session_id
|
||||
FROM sessions s
|
||||
WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%'
|
||||
${sourceClause}
|
||||
)
|
||||
SELECT
|
||||
base.*,
|
||||
m.id AS matched_message_id,
|
||||
snippet(messages_fts, 0, '>>>', '<<<', '...', 40) AS snippet,
|
||||
bm25(messages_fts) AS rank
|
||||
FROM messages_fts
|
||||
JOIN messages m ON m.id = messages_fts.rowid
|
||||
JOIN base ON base.id = m.session_id
|
||||
WHERE messages_fts MATCH ?
|
||||
ORDER BY rank, base.last_active DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
const contentRows = useLiteralContentSearch
|
||||
? runLiteralContentSearch(db, source, trimmed, candidateLimit)
|
||||
: prefixQuery
|
||||
? (db.prepare(contentSql).all(...sourceParams, prefixQuery, candidateLimit) as Record<string, unknown>[])
|
||||
: []
|
||||
|
||||
const idx = loadAllSessions(db)
|
||||
const merged = new Map<string, HermesSessionSearchRow>()
|
||||
for (const row of titleRows) {
|
||||
const mapped = projectSearchRow(row, idx, source)
|
||||
if (mapped) merged.set(mapped.id, mapped)
|
||||
}
|
||||
for (const row of contentRows) {
|
||||
const mapped = projectSearchRow(row, idx, source)
|
||||
if (mapped && !merged.has(mapped.id)) merged.set(mapped.id, mapped)
|
||||
}
|
||||
|
||||
const items = [...merged.values()]
|
||||
items.sort((a, b) => {
|
||||
if (a.rank !== b.rank) return a.rank - b.rank
|
||||
return b.last_active - a.last_active
|
||||
})
|
||||
return items.slice(0, limit)
|
||||
} catch (_err) {
|
||||
return []
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchSessionSummaries(
|
||||
query: string,
|
||||
source?: string,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../../controllers/hermes/kanban'
|
||||
|
||||
export const kanbanRoutes = new Router()
|
||||
|
||||
kanbanRoutes.get('/api/hermes/kanban/stats', ctrl.stats)
|
||||
kanbanRoutes.get('/api/hermes/kanban/assignees', ctrl.assignees)
|
||||
kanbanRoutes.get('/api/hermes/kanban/artifact', ctrl.readArtifact)
|
||||
kanbanRoutes.get('/api/hermes/kanban/search-sessions', ctrl.searchSessions)
|
||||
kanbanRoutes.get('/api/hermes/kanban', ctrl.list)
|
||||
kanbanRoutes.get('/api/hermes/kanban/:id', ctrl.get)
|
||||
kanbanRoutes.post('/api/hermes/kanban', ctrl.create)
|
||||
kanbanRoutes.post('/api/hermes/kanban/complete', ctrl.complete)
|
||||
kanbanRoutes.post('/api/hermes/kanban/unblock', ctrl.unblock)
|
||||
kanbanRoutes.post('/api/hermes/kanban/:id/block', ctrl.block)
|
||||
kanbanRoutes.post('/api/hermes/kanban/:id/assign', ctrl.assign)
|
||||
@@ -25,6 +25,7 @@ import { fileRoutes } from './hermes/files'
|
||||
import { downloadRoutes } from './hermes/download'
|
||||
import { jobRoutes } from './hermes/jobs'
|
||||
import { cronHistoryRoutes } from './hermes/cron-history'
|
||||
import { kanbanRoutes } from './hermes/kanban'
|
||||
import { proxyRoutes, proxyMiddleware } from './hermes/proxy'
|
||||
import { groupChatRoutes, setGroupChatServer } from './hermes/group-chat'
|
||||
|
||||
@@ -64,6 +65,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
|
||||
app.use(downloadRoutes.routes()) // Must be before proxy
|
||||
app.use(jobRoutes.routes()) // Must be before proxy
|
||||
app.use(cronHistoryRoutes.routes()) // Must be before proxy
|
||||
app.use(kanbanRoutes.routes()) // Must be before proxy
|
||||
app.use(proxyRoutes.routes())
|
||||
|
||||
// Proxy catch-all middleware (must be last)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { logger } from '../logger'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const execOpts = { windowsHide: true }
|
||||
|
||||
function resolveHermesBin(): string {
|
||||
const envBin = process.env.HERMES_BIN?.trim()
|
||||
if (envBin) return envBin
|
||||
return 'hermes'
|
||||
}
|
||||
|
||||
const HERMES_BIN = resolveHermesBin()
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
export type KanbanTaskStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'archived'
|
||||
|
||||
export interface KanbanTask {
|
||||
id: string
|
||||
title: string
|
||||
body: string | null
|
||||
assignee: string | null
|
||||
status: KanbanTaskStatus
|
||||
priority: number
|
||||
created_by: string | null
|
||||
created_at: number
|
||||
started_at: number | null
|
||||
completed_at: number | null
|
||||
workspace_kind: string
|
||||
workspace_path: string | null
|
||||
tenant: string | null
|
||||
result: string | null
|
||||
skills: string[] | null
|
||||
}
|
||||
|
||||
export interface KanbanRun {
|
||||
id: number
|
||||
task_id: string
|
||||
profile: string | null
|
||||
status: string
|
||||
started_at: number
|
||||
ended_at: number | null
|
||||
outcome: string | null
|
||||
summary: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface KanbanComment {
|
||||
id: number
|
||||
task_id: string
|
||||
author: string
|
||||
body: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface KanbanEvent {
|
||||
id: number
|
||||
task_id: string
|
||||
kind: string
|
||||
payload: Record<string, unknown> | null
|
||||
created_at: number
|
||||
run_id: number | null
|
||||
}
|
||||
|
||||
export interface KanbanTaskDetail {
|
||||
task: KanbanTask
|
||||
comments: KanbanComment[]
|
||||
events: KanbanEvent[]
|
||||
runs: KanbanRun[]
|
||||
}
|
||||
|
||||
export interface KanbanStats {
|
||||
by_status: Record<string, number>
|
||||
by_assignee: Record<string, number>
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface KanbanAssignee {
|
||||
name: string
|
||||
on_disk: boolean
|
||||
counts: Record<string, number> | null
|
||||
}
|
||||
|
||||
// ─── CLI wrappers ───────────────────────────────────────────────
|
||||
|
||||
export async function listTasks(opts?: {
|
||||
status?: string
|
||||
assignee?: string
|
||||
tenant?: string
|
||||
}): Promise<KanbanTask[]> {
|
||||
const args = ['kanban', '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)
|
||||
|
||||
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 list failed')
|
||||
throw new Error(`Failed to list kanban tasks: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTask(taskId: string): Promise<KanbanTaskDetail | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, ['kanban', 'show', taskId, '--json'], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
return JSON.parse(stdout)
|
||||
} catch (err: any) {
|
||||
if (err.code === 1 || err.status === 1) return null
|
||||
logger.error(err, 'Hermes CLI: kanban show failed')
|
||||
throw new Error(`Failed to get kanban task: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTask(
|
||||
title: string,
|
||||
opts?: {
|
||||
body?: string
|
||||
assignee?: string
|
||||
priority?: number
|
||||
tenant?: string
|
||||
},
|
||||
): Promise<KanbanTask> {
|
||||
const args = ['kanban', '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))
|
||||
if (opts?.tenant) args.push('--tenant', opts.tenant)
|
||||
|
||||
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 create failed')
|
||||
throw new Error(`Failed to create kanban task: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeTasks(taskIds: string[], summary?: string): Promise<void> {
|
||||
const args = ['kanban', 'complete', ...taskIds]
|
||||
if (summary) args.push('--summary', summary)
|
||||
|
||||
try {
|
||||
await execFileAsync(HERMES_BIN, args, {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: kanban complete failed')
|
||||
throw new Error(`Failed to complete kanban tasks: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function blockTask(taskId: string, reason: string): Promise<void> {
|
||||
try {
|
||||
await execFileAsync(HERMES_BIN, ['kanban', 'block', taskId, reason], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: kanban block failed')
|
||||
throw new Error(`Failed to block kanban task: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function unblockTasks(taskIds: string[]): Promise<void> {
|
||||
try {
|
||||
await execFileAsync(HERMES_BIN, ['kanban', 'unblock', ...taskIds], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: kanban unblock failed')
|
||||
throw new Error(`Failed to unblock kanban tasks: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function assignTask(taskId: string, profile: string): Promise<void> {
|
||||
try {
|
||||
await execFileAsync(HERMES_BIN, ['kanban', 'assign', taskId, profile], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: kanban assign failed')
|
||||
throw new Error(`Failed to assign kanban task: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStats(): Promise<KanbanStats> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, ['kanban', 'stats', '--json'], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
return JSON.parse(stdout)
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: kanban stats failed')
|
||||
throw new Error(`Failed to get kanban stats: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAssignees(): Promise<KanbanAssignee[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, ['kanban', 'assignees', '--json'], {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
return JSON.parse(stdout)
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: kanban assignees failed')
|
||||
throw new Error(`Failed to get kanban assignees: ${err.message}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user