@andypai/agent-kanban 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/package.json +80 -0
- package/src/__tests__/activity.test.ts +139 -0
- package/src/__tests__/api.test.ts +74 -0
- package/src/__tests__/commands/board.test.ts +51 -0
- package/src/__tests__/commands/bulk.test.ts +51 -0
- package/src/__tests__/commands/column.test.ts +78 -0
- package/src/__tests__/commands/task.test.ts +144 -0
- package/src/__tests__/db.test.ts +327 -0
- package/src/__tests__/id.test.ts +19 -0
- package/src/__tests__/index.test.ts +75 -0
- package/src/__tests__/metrics.test.ts +64 -0
- package/src/__tests__/output.test.ts +39 -0
- package/src/activity.ts +73 -0
- package/src/api.ts +209 -0
- package/src/commands/board.ts +29 -0
- package/src/commands/bulk.ts +19 -0
- package/src/commands/column.ts +60 -0
- package/src/commands/task.ts +117 -0
- package/src/config.ts +29 -0
- package/src/db.ts +587 -0
- package/src/errors.ts +32 -0
- package/src/fixtures.ts +128 -0
- package/src/id.ts +8 -0
- package/src/index.ts +413 -0
- package/src/metrics.ts +98 -0
- package/src/output.ts +105 -0
- package/src/providers/capabilities.ts +25 -0
- package/src/providers/errors.ts +16 -0
- package/src/providers/index.ts +24 -0
- package/src/providers/linear-cache.ts +385 -0
- package/src/providers/linear-client.ts +329 -0
- package/src/providers/linear.ts +305 -0
- package/src/providers/local.ts +135 -0
- package/src/providers/types.ts +65 -0
- package/src/server.ts +91 -0
- package/src/types.ts +123 -0
- package/ui/dist/assets/index-DEnUD0fq.css +1 -0
- package/ui/dist/assets/index-DMRjw1nI.js +40 -0
- package/ui/dist/index.html +13 -0
package/src/db.ts
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite'
|
|
2
|
+
import { existsSync, mkdirSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { generateId } from './id.ts'
|
|
6
|
+
import { ErrorCode, KanbanError } from './errors.ts'
|
|
7
|
+
import type { Column, Task, TaskWithColumn, Priority, BoardView } from './types.ts'
|
|
8
|
+
import { logActivity, enterColumn, exitColumn } from './activity.ts'
|
|
9
|
+
|
|
10
|
+
const DEFAULT_COLUMNS = [
|
|
11
|
+
{ name: 'recurring', position: 0 },
|
|
12
|
+
{ name: 'backlog', position: 1 },
|
|
13
|
+
{ name: 'in-progress', position: 2 },
|
|
14
|
+
{ name: 'review', position: 3 },
|
|
15
|
+
{ name: 'done', position: 4 },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
export function getDbPath(): string {
|
|
19
|
+
const envPath = process.env['KANBAN_DB_PATH']
|
|
20
|
+
if (envPath) return envPath
|
|
21
|
+
|
|
22
|
+
const localPath = '.kanban/board.db'
|
|
23
|
+
if (existsSync(localPath)) return localPath
|
|
24
|
+
|
|
25
|
+
const homePath = process.env['HOME'] || homedir()
|
|
26
|
+
const globalPath = join(homePath, '.kanban', 'board.db')
|
|
27
|
+
if (existsSync(globalPath)) return globalPath
|
|
28
|
+
|
|
29
|
+
return localPath
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function openDb(path?: string): Database {
|
|
33
|
+
const dbPath = path ?? getDbPath()
|
|
34
|
+
const dir = dbPath.substring(0, dbPath.lastIndexOf('/'))
|
|
35
|
+
if (dir) {
|
|
36
|
+
mkdirSync(dir, { recursive: true })
|
|
37
|
+
}
|
|
38
|
+
const db = new Database(dbPath)
|
|
39
|
+
db.run('PRAGMA journal_mode = WAL')
|
|
40
|
+
db.run('PRAGMA foreign_keys = ON')
|
|
41
|
+
return db
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function initSchema(db: Database): void {
|
|
45
|
+
db.run('PRAGMA foreign_keys = ON')
|
|
46
|
+
db.run(`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS columns (
|
|
48
|
+
id TEXT PRIMARY KEY,
|
|
49
|
+
name TEXT UNIQUE NOT NULL,
|
|
50
|
+
position INTEGER NOT NULL,
|
|
51
|
+
color TEXT,
|
|
52
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
53
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
54
|
+
)
|
|
55
|
+
`)
|
|
56
|
+
db.run(`
|
|
57
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
58
|
+
id TEXT PRIMARY KEY,
|
|
59
|
+
title TEXT NOT NULL,
|
|
60
|
+
description TEXT NOT NULL DEFAULT '',
|
|
61
|
+
column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE RESTRICT,
|
|
62
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
63
|
+
priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low','medium','high','urgent')),
|
|
64
|
+
assignee TEXT NOT NULL DEFAULT '',
|
|
65
|
+
project TEXT NOT NULL DEFAULT '',
|
|
66
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
67
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
68
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
69
|
+
)
|
|
70
|
+
`)
|
|
71
|
+
db.run(`
|
|
72
|
+
CREATE TABLE IF NOT EXISTS activity_log (
|
|
73
|
+
id TEXT PRIMARY KEY,
|
|
74
|
+
task_id TEXT NOT NULL,
|
|
75
|
+
action TEXT NOT NULL,
|
|
76
|
+
field_changed TEXT,
|
|
77
|
+
old_value TEXT,
|
|
78
|
+
new_value TEXT,
|
|
79
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
80
|
+
)
|
|
81
|
+
`)
|
|
82
|
+
db.run(`
|
|
83
|
+
CREATE TABLE IF NOT EXISTS column_time_tracking (
|
|
84
|
+
id TEXT PRIMARY KEY,
|
|
85
|
+
task_id TEXT NOT NULL,
|
|
86
|
+
column_id TEXT NOT NULL,
|
|
87
|
+
entered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
88
|
+
exited_at TEXT
|
|
89
|
+
)
|
|
90
|
+
`)
|
|
91
|
+
// Run migrations before creating indexes — existing DBs may lack newer columns
|
|
92
|
+
migrateSchema(db)
|
|
93
|
+
// Now safe to create all indexes
|
|
94
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_tasks_column_id ON tasks(column_id)')
|
|
95
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority)')
|
|
96
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee)')
|
|
97
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project)')
|
|
98
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_activity_task_id ON activity_log(task_id)')
|
|
99
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_activity_timestamp ON activity_log(timestamp)')
|
|
100
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_column_time_task_id ON column_time_tracking(task_id)')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function migrateSchema(db: Database): void {
|
|
104
|
+
// Guard: if tasks table doesn't exist yet, nothing to migrate
|
|
105
|
+
const tables = db
|
|
106
|
+
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'")
|
|
107
|
+
.all()
|
|
108
|
+
if (tables.length === 0) return
|
|
109
|
+
|
|
110
|
+
const columns = db.query('PRAGMA table_info(tasks)').all() as { name: string }[]
|
|
111
|
+
const hasProject = columns.some((c) => c.name === 'project')
|
|
112
|
+
if (!hasProject) {
|
|
113
|
+
db.run("ALTER TABLE tasks ADD COLUMN project TEXT NOT NULL DEFAULT ''")
|
|
114
|
+
}
|
|
115
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project)')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function seedDefaultColumns(db: Database): void {
|
|
119
|
+
const existing = db.query('SELECT COUNT(*) as count FROM columns').get() as {
|
|
120
|
+
count: number
|
|
121
|
+
}
|
|
122
|
+
if (existing.count > 0) return
|
|
123
|
+
const stmt = db.prepare('INSERT INTO columns (id, name, position) VALUES ($id, $name, $position)')
|
|
124
|
+
for (const col of DEFAULT_COLUMNS) {
|
|
125
|
+
stmt.run({ $id: generateId('c'), $name: col.name, $position: col.position })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function isInitialized(db: Database): boolean {
|
|
130
|
+
const result = db
|
|
131
|
+
.query("SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name='columns'")
|
|
132
|
+
.get() as { count: number }
|
|
133
|
+
return result.count > 0
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- Column CRUD ---
|
|
137
|
+
|
|
138
|
+
export function resolveColumn(db: Database, idOrName: string): Column {
|
|
139
|
+
const byId = db
|
|
140
|
+
.query('SELECT * FROM columns WHERE id = $id')
|
|
141
|
+
.get({ $id: idOrName }) as Column | null
|
|
142
|
+
if (byId) return byId
|
|
143
|
+
const byName = db
|
|
144
|
+
.query('SELECT * FROM columns WHERE LOWER(name) = LOWER($name)')
|
|
145
|
+
.get({ $name: idOrName }) as Column | null
|
|
146
|
+
if (byName) return byName
|
|
147
|
+
throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No column matching '${idOrName}'`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function listColumns(db: Database): Column[] {
|
|
151
|
+
return db.query('SELECT * FROM columns ORDER BY position').all() as Column[]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function addColumn(
|
|
155
|
+
db: Database,
|
|
156
|
+
name: string,
|
|
157
|
+
opts: { position?: number; color?: string } = {},
|
|
158
|
+
): Column {
|
|
159
|
+
const existing = db
|
|
160
|
+
.query('SELECT id FROM columns WHERE LOWER(name) = LOWER($name)')
|
|
161
|
+
.get({ $name: name })
|
|
162
|
+
if (existing) {
|
|
163
|
+
throw new KanbanError(ErrorCode.COLUMN_NAME_EXISTS, `Column '${name}' already exists`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const id = generateId('c')
|
|
167
|
+
const position =
|
|
168
|
+
opts.position ??
|
|
169
|
+
(
|
|
170
|
+
db.query('SELECT COALESCE(MAX(position), -1) + 1 as next FROM columns').get() as {
|
|
171
|
+
next: number
|
|
172
|
+
}
|
|
173
|
+
).next
|
|
174
|
+
|
|
175
|
+
db.query('UPDATE columns SET position = position + 1 WHERE position >= $pos').run({
|
|
176
|
+
$pos: position,
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
db.query(
|
|
180
|
+
'INSERT INTO columns (id, name, position, color) VALUES ($id, $name, $position, $color)',
|
|
181
|
+
).run({
|
|
182
|
+
$id: id,
|
|
183
|
+
$name: name,
|
|
184
|
+
$position: position,
|
|
185
|
+
$color: opts.color ?? null,
|
|
186
|
+
})
|
|
187
|
+
return resolveColumn(db, id)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function renameColumn(db: Database, idOrName: string, newName: string): Column {
|
|
191
|
+
const col = resolveColumn(db, idOrName)
|
|
192
|
+
const conflict = db
|
|
193
|
+
.query('SELECT id FROM columns WHERE LOWER(name) = LOWER($name) AND id != $id')
|
|
194
|
+
.get({ $name: newName, $id: col.id })
|
|
195
|
+
if (conflict) {
|
|
196
|
+
throw new KanbanError(ErrorCode.COLUMN_NAME_EXISTS, `Column '${newName}' already exists`)
|
|
197
|
+
}
|
|
198
|
+
db.query("UPDATE columns SET name = $name, updated_at = datetime('now') WHERE id = $id").run({
|
|
199
|
+
$name: newName,
|
|
200
|
+
$id: col.id,
|
|
201
|
+
})
|
|
202
|
+
return resolveColumn(db, col.id)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function reorderColumn(db: Database, idOrName: string, newPosition: number): Column {
|
|
206
|
+
const col = resolveColumn(db, idOrName)
|
|
207
|
+
if (newPosition < 0) {
|
|
208
|
+
throw new KanbanError(ErrorCode.INVALID_POSITION, 'Position must be >= 0')
|
|
209
|
+
}
|
|
210
|
+
const oldPos = col.position
|
|
211
|
+
if (oldPos === newPosition) return col
|
|
212
|
+
|
|
213
|
+
if (newPosition < oldPos) {
|
|
214
|
+
db.query(
|
|
215
|
+
'UPDATE columns SET position = position + 1 WHERE position >= $new AND position < $old',
|
|
216
|
+
).run({ $new: newPosition, $old: oldPos })
|
|
217
|
+
} else {
|
|
218
|
+
db.query(
|
|
219
|
+
'UPDATE columns SET position = position - 1 WHERE position > $old AND position <= $new',
|
|
220
|
+
).run({ $old: oldPos, $new: newPosition })
|
|
221
|
+
}
|
|
222
|
+
db.query("UPDATE columns SET position = $pos, updated_at = datetime('now') WHERE id = $id").run({
|
|
223
|
+
$pos: newPosition,
|
|
224
|
+
$id: col.id,
|
|
225
|
+
})
|
|
226
|
+
renumberColumns(db)
|
|
227
|
+
return resolveColumn(db, col.id)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function deleteColumn(db: Database, idOrName: string): Column {
|
|
231
|
+
const col = resolveColumn(db, idOrName)
|
|
232
|
+
const taskCount = db.query('SELECT COUNT(*) as count FROM tasks WHERE column_id = $id').get({
|
|
233
|
+
$id: col.id,
|
|
234
|
+
}) as { count: number }
|
|
235
|
+
if (taskCount.count > 0) {
|
|
236
|
+
throw new KanbanError(
|
|
237
|
+
ErrorCode.COLUMN_NOT_EMPTY,
|
|
238
|
+
`Column '${col.name}' has ${taskCount.count} task(s). Move or delete them first.`,
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
db.query('DELETE FROM columns WHERE id = $id').run({ $id: col.id })
|
|
242
|
+
renumberColumns(db)
|
|
243
|
+
return col
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renumberColumns(db: Database): void {
|
|
247
|
+
const cols = db.query('SELECT id FROM columns ORDER BY position').all() as { id: string }[]
|
|
248
|
+
const stmt = db.prepare('UPDATE columns SET position = $pos WHERE id = $id')
|
|
249
|
+
cols.forEach(({ id }, i) => stmt.run({ $pos: i, $id: id }))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- Task CRUD ---
|
|
253
|
+
|
|
254
|
+
export function addTask(
|
|
255
|
+
db: Database,
|
|
256
|
+
title: string,
|
|
257
|
+
opts: {
|
|
258
|
+
description?: string
|
|
259
|
+
column?: string
|
|
260
|
+
priority?: Priority
|
|
261
|
+
assignee?: string
|
|
262
|
+
project?: string
|
|
263
|
+
metadata?: string
|
|
264
|
+
} = {},
|
|
265
|
+
): TaskWithColumn {
|
|
266
|
+
const column = opts.column ? resolveColumn(db, opts.column) : resolveColumn(db, 'backlog')
|
|
267
|
+
|
|
268
|
+
if (opts.priority && !['low', 'medium', 'high', 'urgent'].includes(opts.priority)) {
|
|
269
|
+
throw new KanbanError(
|
|
270
|
+
ErrorCode.INVALID_PRIORITY,
|
|
271
|
+
`Invalid priority '${opts.priority}'. Must be low, medium, high, or urgent.`,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (opts.metadata) {
|
|
276
|
+
try {
|
|
277
|
+
JSON.parse(opts.metadata)
|
|
278
|
+
} catch {
|
|
279
|
+
throw new KanbanError(ErrorCode.INVALID_METADATA, 'Metadata must be valid JSON')
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const id = generateId('t')
|
|
284
|
+
const maxPos = db
|
|
285
|
+
.query('SELECT COALESCE(MAX(position), -1) + 1 as next FROM tasks WHERE column_id = $col')
|
|
286
|
+
.get({ $col: column.id }) as { next: number }
|
|
287
|
+
|
|
288
|
+
db.query(
|
|
289
|
+
`INSERT INTO tasks (id, title, description, column_id, position, priority, assignee, project, metadata)
|
|
290
|
+
VALUES ($id, $title, $desc, $col, $pos, $pri, $assignee, $project, $meta)`,
|
|
291
|
+
).run({
|
|
292
|
+
$id: id,
|
|
293
|
+
$title: title,
|
|
294
|
+
$desc: opts.description ?? '',
|
|
295
|
+
$col: column.id,
|
|
296
|
+
$pos: maxPos.next,
|
|
297
|
+
$pri: opts.priority ?? 'medium',
|
|
298
|
+
$assignee: opts.assignee ?? '',
|
|
299
|
+
$project: opts.project ?? '',
|
|
300
|
+
$meta: opts.metadata ?? '{}',
|
|
301
|
+
})
|
|
302
|
+
logActivity(db, id, 'created', { new_value: title })
|
|
303
|
+
enterColumn(db, id, column.id)
|
|
304
|
+
return getTask(db, id)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function getTask(db: Database, id: string): TaskWithColumn {
|
|
308
|
+
const task = db
|
|
309
|
+
.query(
|
|
310
|
+
`SELECT t.*, c.name as column_name FROM tasks t
|
|
311
|
+
JOIN columns c ON t.column_id = c.id WHERE t.id = $id`,
|
|
312
|
+
)
|
|
313
|
+
.get({ $id: id }) as TaskWithColumn | null
|
|
314
|
+
if (!task) {
|
|
315
|
+
throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${id}'`)
|
|
316
|
+
}
|
|
317
|
+
return task
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function listTasks(
|
|
321
|
+
db: Database,
|
|
322
|
+
opts: {
|
|
323
|
+
column?: string
|
|
324
|
+
priority?: string
|
|
325
|
+
assignee?: string
|
|
326
|
+
project?: string
|
|
327
|
+
limit?: number
|
|
328
|
+
sort?: string
|
|
329
|
+
} = {},
|
|
330
|
+
): TaskWithColumn[] {
|
|
331
|
+
const conditions: string[] = []
|
|
332
|
+
const params: Record<string, string | number> = {}
|
|
333
|
+
|
|
334
|
+
if (opts.column) {
|
|
335
|
+
const col = resolveColumn(db, opts.column)
|
|
336
|
+
conditions.push('t.column_id = $col')
|
|
337
|
+
params['$col'] = col.id
|
|
338
|
+
}
|
|
339
|
+
if (opts.priority) {
|
|
340
|
+
conditions.push('t.priority = $pri')
|
|
341
|
+
params['$pri'] = opts.priority
|
|
342
|
+
}
|
|
343
|
+
if (opts.assignee) {
|
|
344
|
+
conditions.push('t.assignee = $assignee')
|
|
345
|
+
params['$assignee'] = opts.assignee
|
|
346
|
+
}
|
|
347
|
+
if (opts.project) {
|
|
348
|
+
conditions.push('t.project = $project')
|
|
349
|
+
params['$project'] = opts.project
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
|
|
353
|
+
|
|
354
|
+
const sortMap: Record<string, string> = {
|
|
355
|
+
priority:
|
|
356
|
+
"CASE t.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END",
|
|
357
|
+
created: 't.created_at',
|
|
358
|
+
updated: 't.updated_at',
|
|
359
|
+
position: 't.position',
|
|
360
|
+
title: 't.title',
|
|
361
|
+
}
|
|
362
|
+
const orderBy = sortMap[opts.sort ?? 'position'] ?? 't.position'
|
|
363
|
+
const limitClause = opts.limit ? `LIMIT ${opts.limit}` : ''
|
|
364
|
+
|
|
365
|
+
return db
|
|
366
|
+
.query(
|
|
367
|
+
`SELECT t.*, c.name as column_name FROM tasks t
|
|
368
|
+
JOIN columns c ON t.column_id = c.id
|
|
369
|
+
${where} ORDER BY ${orderBy} ${limitClause}`,
|
|
370
|
+
)
|
|
371
|
+
.all(params as Record<string, string>) as TaskWithColumn[]
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function updateTask(
|
|
375
|
+
db: Database,
|
|
376
|
+
id: string,
|
|
377
|
+
updates: {
|
|
378
|
+
title?: string
|
|
379
|
+
description?: string
|
|
380
|
+
priority?: Priority
|
|
381
|
+
assignee?: string
|
|
382
|
+
project?: string
|
|
383
|
+
metadata?: string
|
|
384
|
+
},
|
|
385
|
+
): TaskWithColumn {
|
|
386
|
+
const existing = getTask(db, id)
|
|
387
|
+
|
|
388
|
+
if (updates.priority && !['low', 'medium', 'high', 'urgent'].includes(updates.priority)) {
|
|
389
|
+
throw new KanbanError(
|
|
390
|
+
ErrorCode.INVALID_PRIORITY,
|
|
391
|
+
`Invalid priority '${updates.priority}'. Must be low, medium, high, or urgent.`,
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
if (updates.metadata) {
|
|
395
|
+
try {
|
|
396
|
+
JSON.parse(updates.metadata)
|
|
397
|
+
} catch {
|
|
398
|
+
throw new KanbanError(ErrorCode.INVALID_METADATA, 'Metadata must be valid JSON')
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const sets: string[] = ["updated_at = datetime('now')"]
|
|
403
|
+
const params: Record<string, string> = { $id: id }
|
|
404
|
+
|
|
405
|
+
if (updates.title !== undefined) {
|
|
406
|
+
sets.push('title = $title')
|
|
407
|
+
params['$title'] = updates.title
|
|
408
|
+
}
|
|
409
|
+
if (updates.description !== undefined) {
|
|
410
|
+
sets.push('description = $desc')
|
|
411
|
+
params['$desc'] = updates.description
|
|
412
|
+
}
|
|
413
|
+
if (updates.priority !== undefined) {
|
|
414
|
+
sets.push('priority = $pri')
|
|
415
|
+
params['$pri'] = updates.priority
|
|
416
|
+
}
|
|
417
|
+
if (updates.assignee !== undefined) {
|
|
418
|
+
sets.push('assignee = $assignee')
|
|
419
|
+
params['$assignee'] = updates.assignee
|
|
420
|
+
}
|
|
421
|
+
if (updates.project !== undefined) {
|
|
422
|
+
sets.push('project = $project')
|
|
423
|
+
params['$project'] = updates.project
|
|
424
|
+
}
|
|
425
|
+
if (updates.metadata !== undefined) {
|
|
426
|
+
sets.push('metadata = $meta')
|
|
427
|
+
params['$meta'] = updates.metadata
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
db.query(`UPDATE tasks SET ${sets.join(', ')} WHERE id = $id`).run(params)
|
|
431
|
+
|
|
432
|
+
if (updates.assignee !== undefined && updates.assignee !== existing.assignee) {
|
|
433
|
+
logActivity(db, id, 'assigned', {
|
|
434
|
+
field: 'assignee',
|
|
435
|
+
old_value: existing.assignee || null,
|
|
436
|
+
new_value: updates.assignee,
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
if (updates.priority !== undefined && updates.priority !== existing.priority) {
|
|
440
|
+
logActivity(db, id, 'prioritized', {
|
|
441
|
+
field: 'priority',
|
|
442
|
+
old_value: existing.priority,
|
|
443
|
+
new_value: updates.priority,
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
if (updates.project !== undefined && updates.project !== existing.project) {
|
|
447
|
+
logActivity(db, id, 'updated', {
|
|
448
|
+
field: 'project',
|
|
449
|
+
old_value: existing.project || null,
|
|
450
|
+
new_value: updates.project,
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
const fieldsToLog: Array<{ key: keyof typeof updates; field: string }> = [
|
|
454
|
+
{ key: 'title', field: 'title' },
|
|
455
|
+
{ key: 'description', field: 'description' },
|
|
456
|
+
{ key: 'metadata', field: 'metadata' },
|
|
457
|
+
]
|
|
458
|
+
for (const { key, field } of fieldsToLog) {
|
|
459
|
+
if (updates[key] !== undefined && updates[key] !== existing[key as keyof TaskWithColumn]) {
|
|
460
|
+
logActivity(db, id, 'updated', {
|
|
461
|
+
field,
|
|
462
|
+
old_value: String(existing[key as keyof TaskWithColumn] ?? ''),
|
|
463
|
+
new_value: String(updates[key]),
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return getTask(db, id)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function deleteTask(db: Database, id: string): TaskWithColumn {
|
|
472
|
+
const task = getTask(db, id)
|
|
473
|
+
exitColumn(db, id, task.column_id)
|
|
474
|
+
logActivity(db, id, 'deleted', { old_value: task.title })
|
|
475
|
+
db.query('DELETE FROM tasks WHERE id = $id').run({ $id: id })
|
|
476
|
+
renumberTasksInColumn(db, task.column_id)
|
|
477
|
+
return task
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function moveTask(db: Database, id: string, columnIdOrName: string): TaskWithColumn {
|
|
481
|
+
const task = getTask(db, id)
|
|
482
|
+
const column = resolveColumn(db, columnIdOrName)
|
|
483
|
+
|
|
484
|
+
const maxPos = db
|
|
485
|
+
.query('SELECT COALESCE(MAX(position), -1) + 1 as next FROM tasks WHERE column_id = $col')
|
|
486
|
+
.get({ $col: column.id }) as { next: number }
|
|
487
|
+
|
|
488
|
+
const oldColumnId = task.column_id
|
|
489
|
+
db.query(
|
|
490
|
+
"UPDATE tasks SET column_id = $col, position = $pos, updated_at = datetime('now') WHERE id = $id",
|
|
491
|
+
).run({ $col: column.id, $pos: maxPos.next, $id: id })
|
|
492
|
+
renumberTasksInColumn(db, oldColumnId)
|
|
493
|
+
|
|
494
|
+
exitColumn(db, id, oldColumnId)
|
|
495
|
+
enterColumn(db, id, column.id)
|
|
496
|
+
logActivity(db, id, 'moved', {
|
|
497
|
+
field: 'column',
|
|
498
|
+
old_value: task.column_name,
|
|
499
|
+
new_value: column.name,
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
return getTask(db, id)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function renumberTasksInColumn(db: Database, columnId: string): void {
|
|
506
|
+
const tasks = db
|
|
507
|
+
.query('SELECT id FROM tasks WHERE column_id = $col ORDER BY position')
|
|
508
|
+
.all({ $col: columnId }) as { id: string }[]
|
|
509
|
+
const stmt = db.prepare('UPDATE tasks SET position = $pos WHERE id = $id')
|
|
510
|
+
tasks.forEach(({ id }, i) => stmt.run({ $pos: i, $id: id }))
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// --- Board ---
|
|
514
|
+
|
|
515
|
+
export function getBoardView(db: Database): BoardView {
|
|
516
|
+
const columns = listColumns(db)
|
|
517
|
+
return {
|
|
518
|
+
columns: columns.map((col) => ({
|
|
519
|
+
...col,
|
|
520
|
+
tasks: db
|
|
521
|
+
.query('SELECT * FROM tasks WHERE column_id = $col ORDER BY position')
|
|
522
|
+
.all({ $col: col.id }) as Task[],
|
|
523
|
+
})),
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// --- Bulk ---
|
|
528
|
+
|
|
529
|
+
export function bulkMoveAll(
|
|
530
|
+
db: Database,
|
|
531
|
+
fromIdOrName: string,
|
|
532
|
+
toIdOrName: string,
|
|
533
|
+
): { moved: number } {
|
|
534
|
+
const fromCol = resolveColumn(db, fromIdOrName)
|
|
535
|
+
const toCol = resolveColumn(db, toIdOrName)
|
|
536
|
+
|
|
537
|
+
const maxPos = db
|
|
538
|
+
.query('SELECT COALESCE(MAX(position), -1) + 1 as next FROM tasks WHERE column_id = $col')
|
|
539
|
+
.get({ $col: toCol.id }) as { next: number }
|
|
540
|
+
|
|
541
|
+
const tasks = db
|
|
542
|
+
.query('SELECT id FROM tasks WHERE column_id = $col ORDER BY position')
|
|
543
|
+
.all({ $col: fromCol.id }) as { id: string }[]
|
|
544
|
+
|
|
545
|
+
const stmt = db.prepare(
|
|
546
|
+
"UPDATE tasks SET column_id = $toCol, position = $pos, updated_at = datetime('now') WHERE id = $id",
|
|
547
|
+
)
|
|
548
|
+
tasks.forEach(({ id }, i) => {
|
|
549
|
+
stmt.run({ $toCol: toCol.id, $pos: maxPos.next + i, $id: id })
|
|
550
|
+
exitColumn(db, id, fromCol.id)
|
|
551
|
+
enterColumn(db, id, toCol.id)
|
|
552
|
+
logActivity(db, id, 'moved', {
|
|
553
|
+
field: 'column',
|
|
554
|
+
old_value: fromCol.name,
|
|
555
|
+
new_value: toCol.name,
|
|
556
|
+
})
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
return { moved: tasks.length }
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function bulkClearDone(db: Database): { deleted: number } {
|
|
563
|
+
const doneCol = db.query("SELECT id, name FROM columns WHERE LOWER(name) = 'done'").get() as {
|
|
564
|
+
id: string
|
|
565
|
+
name: string
|
|
566
|
+
} | null
|
|
567
|
+
if (!doneCol) return { deleted: 0 }
|
|
568
|
+
|
|
569
|
+
const tasks = db
|
|
570
|
+
.query('SELECT id, title FROM tasks WHERE column_id = $col')
|
|
571
|
+
.all({ $col: doneCol.id }) as { id: string; title: string }[]
|
|
572
|
+
for (const task of tasks) {
|
|
573
|
+
exitColumn(db, task.id, doneCol.id)
|
|
574
|
+
logActivity(db, task.id, 'deleted', { old_value: task.title })
|
|
575
|
+
}
|
|
576
|
+
db.query('DELETE FROM tasks WHERE column_id = $col').run({ $col: doneCol.id })
|
|
577
|
+
return { deleted: tasks.length }
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function resetBoard(db: Database): void {
|
|
581
|
+
db.run('DROP TABLE IF EXISTS column_time_tracking')
|
|
582
|
+
db.run('DROP TABLE IF EXISTS activity_log')
|
|
583
|
+
db.run('DROP TABLE IF EXISTS tasks')
|
|
584
|
+
db.run('DROP TABLE IF EXISTS columns')
|
|
585
|
+
initSchema(db)
|
|
586
|
+
seedDefaultColumns(db)
|
|
587
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const ErrorCode = {
|
|
2
|
+
BOARD_NOT_INITIALIZED: 'BOARD_NOT_INITIALIZED',
|
|
3
|
+
BOARD_ALREADY_INITIALIZED: 'BOARD_ALREADY_INITIALIZED',
|
|
4
|
+
TASK_NOT_FOUND: 'TASK_NOT_FOUND',
|
|
5
|
+
COLUMN_NOT_FOUND: 'COLUMN_NOT_FOUND',
|
|
6
|
+
COLUMN_NOT_EMPTY: 'COLUMN_NOT_EMPTY',
|
|
7
|
+
COLUMN_NAME_EXISTS: 'COLUMN_NAME_EXISTS',
|
|
8
|
+
INVALID_PRIORITY: 'INVALID_PRIORITY',
|
|
9
|
+
INVALID_METADATA: 'INVALID_METADATA',
|
|
10
|
+
INVALID_POSITION: 'INVALID_POSITION',
|
|
11
|
+
MISSING_ARGUMENT: 'MISSING_ARGUMENT',
|
|
12
|
+
UNKNOWN_COMMAND: 'UNKNOWN_COMMAND',
|
|
13
|
+
UNSUPPORTED_OPERATION: 'UNSUPPORTED_OPERATION',
|
|
14
|
+
PROVIDER_AUTH_FAILED: 'PROVIDER_AUTH_FAILED',
|
|
15
|
+
PROVIDER_RATE_LIMITED: 'PROVIDER_RATE_LIMITED',
|
|
16
|
+
PROVIDER_UPSTREAM_ERROR: 'PROVIDER_UPSTREAM_ERROR',
|
|
17
|
+
PROVIDER_SYNC_REQUIRED: 'PROVIDER_SYNC_REQUIRED',
|
|
18
|
+
PROVIDER_NOT_CONFIGURED: 'PROVIDER_NOT_CONFIGURED',
|
|
19
|
+
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
|
20
|
+
} as const
|
|
21
|
+
|
|
22
|
+
export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]
|
|
23
|
+
|
|
24
|
+
export class KanbanError extends Error {
|
|
25
|
+
constructor(
|
|
26
|
+
public code: ErrorCodeValue,
|
|
27
|
+
message: string,
|
|
28
|
+
) {
|
|
29
|
+
super(message)
|
|
30
|
+
this.name = 'KanbanError'
|
|
31
|
+
}
|
|
32
|
+
}
|