@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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach } from 'bun:test'
|
|
2
|
+
import { Database } from 'bun:sqlite'
|
|
3
|
+
import { initSchema, seedDefaultColumns, addTask, listTasks } from '../../db.ts'
|
|
4
|
+
import { bulkMoveAllCmd, bulkClearDoneCmd } from '../../commands/bulk.ts'
|
|
5
|
+
import { KanbanError } from '../../errors.ts'
|
|
6
|
+
|
|
7
|
+
let db: Database
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
db = new Database(':memory:')
|
|
11
|
+
db.run('PRAGMA foreign_keys = ON')
|
|
12
|
+
initSchema(db)
|
|
13
|
+
seedDefaultColumns(db)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('bulkMoveAllCmd', () => {
|
|
17
|
+
test('moves all tasks between columns', () => {
|
|
18
|
+
addTask(db, 'A', { column: 'recurring' })
|
|
19
|
+
addTask(db, 'B', { column: 'recurring' })
|
|
20
|
+
const result = bulkMoveAllCmd(db, { from: 'recurring', to: 'in-progress' })
|
|
21
|
+
expect(result.ok).toBe(true)
|
|
22
|
+
if (result.ok) {
|
|
23
|
+
expect((result.data as { moved: number }).moved).toBe(2)
|
|
24
|
+
}
|
|
25
|
+
expect(listTasks(db, { column: 'recurring' })).toHaveLength(0)
|
|
26
|
+
expect(listTasks(db, { column: 'in-progress' })).toHaveLength(2)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('throws without args', () => {
|
|
30
|
+
expect(() => bulkMoveAllCmd(db, {})).toThrow(KanbanError)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('bulkClearDoneCmd', () => {
|
|
35
|
+
test('clears done tasks', () => {
|
|
36
|
+
addTask(db, 'Done!', { column: 'done' })
|
|
37
|
+
addTask(db, 'Still working', { column: 'recurring' })
|
|
38
|
+
const result = bulkClearDoneCmd(db)
|
|
39
|
+
if (result.ok) {
|
|
40
|
+
expect((result.data as { deleted: number }).deleted).toBe(1)
|
|
41
|
+
}
|
|
42
|
+
expect(listTasks(db)).toHaveLength(1)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('returns 0 when done column is empty', () => {
|
|
46
|
+
const result = bulkClearDoneCmd(db)
|
|
47
|
+
if (result.ok) {
|
|
48
|
+
expect((result.data as { deleted: number }).deleted).toBe(0)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach } from 'bun:test'
|
|
2
|
+
import { Database } from 'bun:sqlite'
|
|
3
|
+
import { initSchema, seedDefaultColumns, addTask } from '../../db.ts'
|
|
4
|
+
import {
|
|
5
|
+
columnAdd,
|
|
6
|
+
columnList,
|
|
7
|
+
columnRename,
|
|
8
|
+
columnReorder,
|
|
9
|
+
columnDelete,
|
|
10
|
+
} from '../../commands/column.ts'
|
|
11
|
+
import { KanbanError } from '../../errors.ts'
|
|
12
|
+
import type { Column } from '../../types.ts'
|
|
13
|
+
|
|
14
|
+
let db: Database
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
db = new Database(':memory:')
|
|
18
|
+
db.run('PRAGMA foreign_keys = ON')
|
|
19
|
+
initSchema(db)
|
|
20
|
+
seedDefaultColumns(db)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('columnAdd', () => {
|
|
24
|
+
test('adds a column', () => {
|
|
25
|
+
const result = columnAdd(db, { name: 'testing' })
|
|
26
|
+
expect(result.ok).toBe(true)
|
|
27
|
+
if (result.ok) {
|
|
28
|
+
expect((result.data as Column).name).toBe('testing')
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('throws without name', () => {
|
|
33
|
+
expect(() => columnAdd(db, {})).toThrow(KanbanError)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('columnList', () => {
|
|
38
|
+
test('returns all columns', () => {
|
|
39
|
+
const result = columnList(db)
|
|
40
|
+
if (result.ok) {
|
|
41
|
+
expect(result.data as Column[]).toHaveLength(5)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('columnRename', () => {
|
|
47
|
+
test('renames column', () => {
|
|
48
|
+
const result = columnRename(db, { idOrName: 'recurring', newName: 'weekly' })
|
|
49
|
+
if (result.ok) {
|
|
50
|
+
expect((result.data as Column).name).toBe('weekly')
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('throws without args', () => {
|
|
55
|
+
expect(() => columnRename(db, {})).toThrow(KanbanError)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('columnReorder', () => {
|
|
60
|
+
test('reorders column', () => {
|
|
61
|
+
const result = columnReorder(db, { idOrName: 'done', position: '0' })
|
|
62
|
+
if (result.ok) {
|
|
63
|
+
expect((result.data as Column).position).toBe(0)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('columnDelete', () => {
|
|
69
|
+
test('deletes empty column', () => {
|
|
70
|
+
const result = columnDelete(db, { idOrName: 'review' })
|
|
71
|
+
expect(result.ok).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('fails if column has tasks', () => {
|
|
75
|
+
addTask(db, 'Blocker', { column: 'review' })
|
|
76
|
+
expect(() => columnDelete(db, { idOrName: 'review' })).toThrow(KanbanError)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach } from 'bun:test'
|
|
2
|
+
import { Database } from 'bun:sqlite'
|
|
3
|
+
import { initSchema, seedDefaultColumns } from '../../db.ts'
|
|
4
|
+
import {
|
|
5
|
+
taskAdd,
|
|
6
|
+
taskList,
|
|
7
|
+
taskView,
|
|
8
|
+
taskUpdate,
|
|
9
|
+
taskDelete,
|
|
10
|
+
taskMove,
|
|
11
|
+
taskAssign,
|
|
12
|
+
taskPrioritize,
|
|
13
|
+
} from '../../commands/task.ts'
|
|
14
|
+
import { KanbanError } from '../../errors.ts'
|
|
15
|
+
import type { TaskWithColumn } from '../../types.ts'
|
|
16
|
+
|
|
17
|
+
let db: Database
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
db = new Database(':memory:')
|
|
21
|
+
db.run('PRAGMA foreign_keys = ON')
|
|
22
|
+
initSchema(db)
|
|
23
|
+
seedDefaultColumns(db)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('taskAdd', () => {
|
|
27
|
+
test('adds a task with all options', () => {
|
|
28
|
+
const result = taskAdd(db, {
|
|
29
|
+
title: 'Build feature',
|
|
30
|
+
description: 'Do the thing',
|
|
31
|
+
column: 'recurring',
|
|
32
|
+
priority: 'high',
|
|
33
|
+
assignee: 'alice',
|
|
34
|
+
metadata: '{"sprint": 5}',
|
|
35
|
+
})
|
|
36
|
+
expect(result.ok).toBe(true)
|
|
37
|
+
if (result.ok) {
|
|
38
|
+
const task = result.data as TaskWithColumn
|
|
39
|
+
expect(task.title).toBe('Build feature')
|
|
40
|
+
expect(task.description).toBe('Do the thing')
|
|
41
|
+
expect(task.priority).toBe('high')
|
|
42
|
+
expect(task.assignee).toBe('alice')
|
|
43
|
+
expect(task.metadata).toBe('{"sprint": 5}')
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('throws without title', () => {
|
|
48
|
+
expect(() => taskAdd(db, {})).toThrow(KanbanError)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('taskList', () => {
|
|
53
|
+
test('lists all tasks', () => {
|
|
54
|
+
taskAdd(db, { title: 'A' })
|
|
55
|
+
taskAdd(db, { title: 'B' })
|
|
56
|
+
const result = taskList(db, {})
|
|
57
|
+
expect(result.ok).toBe(true)
|
|
58
|
+
if (result.ok) {
|
|
59
|
+
expect(result.data as TaskWithColumn[]).toHaveLength(2)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('filters by column', () => {
|
|
64
|
+
taskAdd(db, { title: 'A', column: 'recurring' })
|
|
65
|
+
taskAdd(db, { title: 'B', column: 'done' })
|
|
66
|
+
const result = taskList(db, { column: 'recurring' })
|
|
67
|
+
if (result.ok) {
|
|
68
|
+
expect(result.data as TaskWithColumn[]).toHaveLength(1)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('taskView', () => {
|
|
74
|
+
test('returns task details', () => {
|
|
75
|
+
const addResult = taskAdd(db, { title: 'View me' })
|
|
76
|
+
if (!addResult.ok) throw new Error('unexpected')
|
|
77
|
+
const task = addResult.data as TaskWithColumn
|
|
78
|
+
const result = taskView(db, { id: task.id })
|
|
79
|
+
expect(result.ok).toBe(true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('throws for missing id', () => {
|
|
83
|
+
expect(() => taskView(db, {})).toThrow(KanbanError)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('taskUpdate', () => {
|
|
88
|
+
test('updates task fields', () => {
|
|
89
|
+
const addResult = taskAdd(db, { title: 'Original' })
|
|
90
|
+
if (!addResult.ok) throw new Error('unexpected')
|
|
91
|
+
const task = addResult.data as TaskWithColumn
|
|
92
|
+
const result = taskUpdate(db, { id: task.id, title: 'Modified' })
|
|
93
|
+
if (result.ok) {
|
|
94
|
+
expect((result.data as TaskWithColumn).title).toBe('Modified')
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('taskDelete', () => {
|
|
100
|
+
test('deletes task', () => {
|
|
101
|
+
const addResult = taskAdd(db, { title: 'Delete me' })
|
|
102
|
+
if (!addResult.ok) throw new Error('unexpected')
|
|
103
|
+
const task = addResult.data as TaskWithColumn
|
|
104
|
+
const result = taskDelete(db, { id: task.id })
|
|
105
|
+
expect(result.ok).toBe(true)
|
|
106
|
+
expect(() => taskView(db, { id: task.id })).toThrow(KanbanError)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('taskMove', () => {
|
|
111
|
+
test('moves task to new column', () => {
|
|
112
|
+
const addResult = taskAdd(db, { title: 'Move me', column: 'recurring' })
|
|
113
|
+
if (!addResult.ok) throw new Error('unexpected')
|
|
114
|
+
const task = addResult.data as TaskWithColumn
|
|
115
|
+
const result = taskMove(db, { id: task.id, column: 'in-progress' })
|
|
116
|
+
if (result.ok) {
|
|
117
|
+
expect((result.data as TaskWithColumn).column_name).toBe('in-progress')
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('taskAssign', () => {
|
|
123
|
+
test('assigns task', () => {
|
|
124
|
+
const addResult = taskAdd(db, { title: 'Assign me' })
|
|
125
|
+
if (!addResult.ok) throw new Error('unexpected')
|
|
126
|
+
const task = addResult.data as TaskWithColumn
|
|
127
|
+
const result = taskAssign(db, { id: task.id, assignee: 'bob' })
|
|
128
|
+
if (result.ok) {
|
|
129
|
+
expect((result.data as TaskWithColumn).assignee).toBe('bob')
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('taskPrioritize', () => {
|
|
135
|
+
test('sets priority', () => {
|
|
136
|
+
const addResult = taskAdd(db, { title: 'Prioritize me' })
|
|
137
|
+
if (!addResult.ok) throw new Error('unexpected')
|
|
138
|
+
const task = addResult.data as TaskWithColumn
|
|
139
|
+
const result = taskPrioritize(db, { id: task.id, priority: 'urgent' })
|
|
140
|
+
if (result.ok) {
|
|
141
|
+
expect((result.data as TaskWithColumn).priority).toBe('urgent')
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Database } from 'bun:sqlite'
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import {
|
|
7
|
+
getDbPath,
|
|
8
|
+
initSchema,
|
|
9
|
+
seedDefaultColumns,
|
|
10
|
+
isInitialized,
|
|
11
|
+
listColumns,
|
|
12
|
+
resolveColumn,
|
|
13
|
+
addColumn,
|
|
14
|
+
renameColumn,
|
|
15
|
+
reorderColumn,
|
|
16
|
+
deleteColumn,
|
|
17
|
+
addTask,
|
|
18
|
+
getTask,
|
|
19
|
+
listTasks,
|
|
20
|
+
updateTask,
|
|
21
|
+
deleteTask,
|
|
22
|
+
moveTask,
|
|
23
|
+
getBoardView,
|
|
24
|
+
bulkMoveAll,
|
|
25
|
+
bulkClearDone,
|
|
26
|
+
resetBoard,
|
|
27
|
+
migrateSchema,
|
|
28
|
+
} from '../db.ts'
|
|
29
|
+
import { KanbanError } from '../errors.ts'
|
|
30
|
+
|
|
31
|
+
let db: Database
|
|
32
|
+
let originalCwd: string
|
|
33
|
+
let originalHome: string | undefined
|
|
34
|
+
let tempDirsToRemove: string[]
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
originalCwd = process.cwd()
|
|
38
|
+
originalHome = process.env['HOME']
|
|
39
|
+
tempDirsToRemove = []
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
db = new Database(':memory:')
|
|
44
|
+
db.run('PRAGMA foreign_keys = ON')
|
|
45
|
+
initSchema(db)
|
|
46
|
+
seedDefaultColumns(db)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
process.chdir(originalCwd)
|
|
51
|
+
|
|
52
|
+
if (originalHome === undefined) {
|
|
53
|
+
delete process.env['HOME']
|
|
54
|
+
} else {
|
|
55
|
+
process.env['HOME'] = originalHome
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const dir of tempDirsToRemove) {
|
|
59
|
+
rmSync(dir, { recursive: true, force: true })
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('schema', () => {
|
|
64
|
+
test('isInitialized returns true after init', () => {
|
|
65
|
+
expect(isInitialized(db)).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('isInitialized returns false on fresh db', () => {
|
|
69
|
+
const fresh = new Database(':memory:')
|
|
70
|
+
expect(isInitialized(fresh)).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('seeds 5 default columns', () => {
|
|
74
|
+
expect(listColumns(db)).toHaveLength(5)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('seedDefaultColumns is idempotent', () => {
|
|
78
|
+
seedDefaultColumns(db)
|
|
79
|
+
expect(listColumns(db)).toHaveLength(5)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('migrateSchema adds project column and index to legacy tasks table', () => {
|
|
83
|
+
const legacy = new Database(':memory:')
|
|
84
|
+
legacy.run(
|
|
85
|
+
`CREATE TABLE tasks (
|
|
86
|
+
id TEXT PRIMARY KEY,
|
|
87
|
+
title TEXT NOT NULL,
|
|
88
|
+
description TEXT NOT NULL DEFAULT '',
|
|
89
|
+
column_id TEXT NOT NULL,
|
|
90
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
91
|
+
priority TEXT NOT NULL DEFAULT 'medium',
|
|
92
|
+
assignee TEXT NOT NULL DEFAULT '',
|
|
93
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
94
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
95
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
96
|
+
)`,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
migrateSchema(legacy)
|
|
100
|
+
|
|
101
|
+
const columns = legacy.query('PRAGMA table_info(tasks)').all() as { name: string }[]
|
|
102
|
+
const indexes = legacy.query('PRAGMA index_list(tasks)').all() as { name: string }[]
|
|
103
|
+
|
|
104
|
+
expect(columns.some((c) => c.name === 'project')).toBe(true)
|
|
105
|
+
expect(indexes.some((i) => i.name === 'idx_tasks_project')).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('getDbPath prefers local board when present', () => {
|
|
109
|
+
const tempRoot = mkdtempSync(join(tmpdir(), 'agent-kanban-local-'))
|
|
110
|
+
tempDirsToRemove.push(tempRoot)
|
|
111
|
+
const projectDir = join(tempRoot, 'project')
|
|
112
|
+
mkdirSync(join(projectDir, '.kanban'), { recursive: true })
|
|
113
|
+
writeFileSync(join(projectDir, '.kanban', 'board.db'), '')
|
|
114
|
+
|
|
115
|
+
const fakeHome = join(tempRoot, 'home')
|
|
116
|
+
mkdirSync(join(fakeHome, '.kanban'), { recursive: true })
|
|
117
|
+
writeFileSync(join(fakeHome, '.kanban', 'board.db'), '')
|
|
118
|
+
|
|
119
|
+
process.chdir(projectDir)
|
|
120
|
+
process.env['HOME'] = fakeHome
|
|
121
|
+
delete process.env['KANBAN_DB_PATH']
|
|
122
|
+
|
|
123
|
+
expect(getDbPath()).toBe('.kanban/board.db')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('getDbPath falls back to global board when local board is absent', () => {
|
|
127
|
+
const tempRoot = mkdtempSync(join(tmpdir(), 'agent-kanban-global-'))
|
|
128
|
+
tempDirsToRemove.push(tempRoot)
|
|
129
|
+
const projectDir = join(tempRoot, 'project')
|
|
130
|
+
mkdirSync(projectDir, { recursive: true })
|
|
131
|
+
|
|
132
|
+
const fakeHome = join(tempRoot, 'home')
|
|
133
|
+
mkdirSync(join(fakeHome, '.kanban'), { recursive: true })
|
|
134
|
+
const globalBoard = join(fakeHome, '.kanban', 'board.db')
|
|
135
|
+
writeFileSync(globalBoard, '')
|
|
136
|
+
|
|
137
|
+
process.chdir(projectDir)
|
|
138
|
+
process.env['HOME'] = fakeHome
|
|
139
|
+
delete process.env['KANBAN_DB_PATH']
|
|
140
|
+
|
|
141
|
+
expect(getDbPath()).toBe(globalBoard)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('columns', () => {
|
|
146
|
+
test('resolveColumn by name (case-insensitive)', () => {
|
|
147
|
+
const col = resolveColumn(db, 'BACKLOG')
|
|
148
|
+
expect(col.name).toBe('backlog')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('resolveColumn by id', () => {
|
|
152
|
+
const cols = listColumns(db)
|
|
153
|
+
const col = resolveColumn(db, cols[0]!.id)
|
|
154
|
+
expect(col.name).toBe('recurring')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('resolveColumn throws for unknown', () => {
|
|
158
|
+
expect(() => resolveColumn(db, 'nonexistent')).toThrow(KanbanError)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('addColumn appends at end', () => {
|
|
162
|
+
const col = addColumn(db, 'testing')
|
|
163
|
+
expect(col.name).toBe('testing')
|
|
164
|
+
expect(col.position).toBe(5)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('addColumn at specific position shifts others', () => {
|
|
168
|
+
addColumn(db, 'testing', { position: 1 })
|
|
169
|
+
const cols = listColumns(db)
|
|
170
|
+
expect(cols.find((c) => c.name === 'testing')!.position).toBe(1)
|
|
171
|
+
expect(cols.find((c) => c.name === 'backlog')!.position).toBe(2)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('addColumn rejects duplicate name', () => {
|
|
175
|
+
expect(() => addColumn(db, 'recurring')).toThrow(KanbanError)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('renameColumn works', () => {
|
|
179
|
+
const col = renameColumn(db, 'recurring', 'weekly')
|
|
180
|
+
expect(col.name).toBe('weekly')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('reorderColumn moves column', () => {
|
|
184
|
+
const col = reorderColumn(db, 'done', 0)
|
|
185
|
+
expect(col.position).toBe(0)
|
|
186
|
+
const cols = listColumns(db)
|
|
187
|
+
expect(cols[0]!.name).toBe('done')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('deleteColumn removes empty column', () => {
|
|
191
|
+
const col = deleteColumn(db, 'review')
|
|
192
|
+
expect(col.name).toBe('review')
|
|
193
|
+
expect(listColumns(db)).toHaveLength(4)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('deleteColumn fails if tasks exist', () => {
|
|
197
|
+
addTask(db, 'Test', { column: 'recurring' })
|
|
198
|
+
expect(() => deleteColumn(db, 'recurring')).toThrow(KanbanError)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('tasks', () => {
|
|
203
|
+
test('addTask creates task in specified column', () => {
|
|
204
|
+
const task = addTask(db, 'My task', { column: 'recurring', priority: 'high' })
|
|
205
|
+
expect(task.title).toBe('My task')
|
|
206
|
+
expect(task.column_name).toBe('recurring')
|
|
207
|
+
expect(task.priority).toBe('high')
|
|
208
|
+
expect(task.id).toMatch(/^t_/)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('addTask defaults to backlog', () => {
|
|
212
|
+
const task = addTask(db, 'My task')
|
|
213
|
+
expect(task.column_name).toBe('backlog')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('addTask validates priority', () => {
|
|
217
|
+
expect(() => addTask(db, 'Bad', { priority: 'critical' as 'high' })).toThrow(KanbanError)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('addTask validates metadata', () => {
|
|
221
|
+
expect(() => addTask(db, 'Bad', { metadata: 'not json' })).toThrow(KanbanError)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('getTask returns task with column_name', () => {
|
|
225
|
+
const created = addTask(db, 'Find me')
|
|
226
|
+
const found = getTask(db, created.id)
|
|
227
|
+
expect(found.title).toBe('Find me')
|
|
228
|
+
expect(found.column_name).toBeDefined()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('getTask throws for unknown id', () => {
|
|
232
|
+
expect(() => getTask(db, 't_nonexist')).toThrow(KanbanError)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('listTasks filters by column', () => {
|
|
236
|
+
addTask(db, 'Task A', { column: 'recurring' })
|
|
237
|
+
addTask(db, 'Task B', { column: 'backlog' })
|
|
238
|
+
const tasks = listTasks(db, { column: 'recurring' })
|
|
239
|
+
expect(tasks).toHaveLength(1)
|
|
240
|
+
expect(tasks[0]!.title).toBe('Task A')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('listTasks filters by priority', () => {
|
|
244
|
+
addTask(db, 'Urgent!', { priority: 'urgent' })
|
|
245
|
+
addTask(db, 'Chill', { priority: 'low' })
|
|
246
|
+
const tasks = listTasks(db, { priority: 'urgent' })
|
|
247
|
+
expect(tasks).toHaveLength(1)
|
|
248
|
+
expect(tasks[0]!.title).toBe('Urgent!')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('listTasks filters by assignee', () => {
|
|
252
|
+
addTask(db, 'Mine', { assignee: 'alice' })
|
|
253
|
+
addTask(db, 'Theirs', { assignee: 'bob' })
|
|
254
|
+
const tasks = listTasks(db, { assignee: 'alice' })
|
|
255
|
+
expect(tasks).toHaveLength(1)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('listTasks respects limit', () => {
|
|
259
|
+
addTask(db, 'A')
|
|
260
|
+
addTask(db, 'B')
|
|
261
|
+
addTask(db, 'C')
|
|
262
|
+
expect(listTasks(db, { limit: 2 })).toHaveLength(2)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('updateTask modifies fields', () => {
|
|
266
|
+
const task = addTask(db, 'Original')
|
|
267
|
+
const updated = updateTask(db, task.id, {
|
|
268
|
+
title: 'Updated',
|
|
269
|
+
priority: 'urgent',
|
|
270
|
+
assignee: 'bob',
|
|
271
|
+
})
|
|
272
|
+
expect(updated.title).toBe('Updated')
|
|
273
|
+
expect(updated.priority).toBe('urgent')
|
|
274
|
+
expect(updated.assignee).toBe('bob')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('deleteTask removes task', () => {
|
|
278
|
+
const task = addTask(db, 'Doomed')
|
|
279
|
+
deleteTask(db, task.id)
|
|
280
|
+
expect(() => getTask(db, task.id)).toThrow(KanbanError)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('moveTask changes column', () => {
|
|
284
|
+
const task = addTask(db, 'Mobile', { column: 'recurring' })
|
|
285
|
+
const moved = moveTask(db, task.id, 'in-progress')
|
|
286
|
+
expect(moved.column_name).toBe('in-progress')
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
describe('board view', () => {
|
|
291
|
+
test('returns all columns with tasks', () => {
|
|
292
|
+
addTask(db, 'Task A', { column: 'recurring' })
|
|
293
|
+
const view = getBoardView(db)
|
|
294
|
+
expect(view.columns).toHaveLength(5)
|
|
295
|
+
const recurringCol = view.columns.find((c) => c.name === 'recurring')!
|
|
296
|
+
expect(recurringCol.tasks).toHaveLength(1)
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
describe('bulk operations', () => {
|
|
301
|
+
test('bulkMoveAll moves all tasks between columns', () => {
|
|
302
|
+
addTask(db, 'A', { column: 'recurring' })
|
|
303
|
+
addTask(db, 'B', { column: 'recurring' })
|
|
304
|
+
const result = bulkMoveAll(db, 'recurring', 'in-progress')
|
|
305
|
+
expect(result.moved).toBe(2)
|
|
306
|
+
expect(listTasks(db, { column: 'in-progress' })).toHaveLength(2)
|
|
307
|
+
expect(listTasks(db, { column: 'recurring' })).toHaveLength(0)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('bulkClearDone removes tasks in done column', () => {
|
|
311
|
+
addTask(db, 'Finished', { column: 'done' })
|
|
312
|
+
addTask(db, 'Also done', { column: 'done' })
|
|
313
|
+
addTask(db, 'Still going', { column: 'recurring' })
|
|
314
|
+
const result = bulkClearDone(db)
|
|
315
|
+
expect(result.deleted).toBe(2)
|
|
316
|
+
expect(listTasks(db)).toHaveLength(1)
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
describe('resetBoard', () => {
|
|
321
|
+
test('clears all data and re-seeds', () => {
|
|
322
|
+
addTask(db, 'Gone soon')
|
|
323
|
+
resetBoard(db)
|
|
324
|
+
expect(listColumns(db)).toHaveLength(5)
|
|
325
|
+
expect(listTasks(db)).toHaveLength(0)
|
|
326
|
+
})
|
|
327
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { generateId } from '../id.ts'
|
|
3
|
+
|
|
4
|
+
describe('generateId', () => {
|
|
5
|
+
test('generates task IDs with t_ prefix', () => {
|
|
6
|
+
const id = generateId('t')
|
|
7
|
+
expect(id).toMatch(/^t_[a-z0-9]{8}$/)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('generates column IDs with c_ prefix', () => {
|
|
11
|
+
const id = generateId('c')
|
|
12
|
+
expect(id).toMatch(/^c_[a-z0-9]{8}$/)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('generates unique IDs', () => {
|
|
16
|
+
const ids = new Set(Array.from({ length: 100 }, () => generateId('t')))
|
|
17
|
+
expect(ids.size).toBe(100)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Database } from 'bun:sqlite'
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { run } from '../index.ts'
|
|
7
|
+
|
|
8
|
+
describe('run', () => {
|
|
9
|
+
test('applies schema migration before task commands', async () => {
|
|
10
|
+
process.env['KANBAN_PROVIDER'] = 'local'
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), 'kanban-run-'))
|
|
12
|
+
const dbPath = join(dir, 'board.db')
|
|
13
|
+
|
|
14
|
+
const legacy = new Database(dbPath)
|
|
15
|
+
legacy.run(
|
|
16
|
+
`CREATE TABLE columns (
|
|
17
|
+
id TEXT PRIMARY KEY,
|
|
18
|
+
name TEXT UNIQUE NOT NULL,
|
|
19
|
+
position INTEGER NOT NULL,
|
|
20
|
+
color TEXT,
|
|
21
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
22
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
23
|
+
)`,
|
|
24
|
+
)
|
|
25
|
+
legacy.run(
|
|
26
|
+
`CREATE TABLE tasks (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
title TEXT NOT NULL,
|
|
29
|
+
description TEXT NOT NULL DEFAULT '',
|
|
30
|
+
column_id TEXT NOT NULL,
|
|
31
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
priority TEXT NOT NULL DEFAULT 'medium',
|
|
33
|
+
assignee TEXT NOT NULL DEFAULT '',
|
|
34
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
35
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
36
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
37
|
+
)`,
|
|
38
|
+
)
|
|
39
|
+
legacy.run(
|
|
40
|
+
`CREATE TABLE activity_log (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
task_id TEXT NOT NULL,
|
|
43
|
+
action TEXT NOT NULL,
|
|
44
|
+
field_changed TEXT,
|
|
45
|
+
old_value TEXT,
|
|
46
|
+
new_value TEXT,
|
|
47
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
48
|
+
)`,
|
|
49
|
+
)
|
|
50
|
+
legacy.run(
|
|
51
|
+
`CREATE TABLE column_time_tracking (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
task_id TEXT NOT NULL,
|
|
54
|
+
column_id TEXT NOT NULL,
|
|
55
|
+
entered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
56
|
+
exited_at TEXT
|
|
57
|
+
)`,
|
|
58
|
+
)
|
|
59
|
+
legacy.run("INSERT INTO columns (id, name, position) VALUES ('c_backlog', 'backlog', 0)")
|
|
60
|
+
legacy.close()
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const result = await run(['--db', dbPath, 'task', 'add', 'Migrated task'])
|
|
64
|
+
expect(result.exitCode).toBe(0)
|
|
65
|
+
expect(result.output.ok).toBe(true)
|
|
66
|
+
|
|
67
|
+
const verify = new Database(dbPath)
|
|
68
|
+
const columns = verify.query('PRAGMA table_info(tasks)').all() as { name: string }[]
|
|
69
|
+
expect(columns.some((column) => column.name === 'project')).toBe(true)
|
|
70
|
+
verify.close()
|
|
71
|
+
} finally {
|
|
72
|
+
rmSync(dir, { recursive: true, force: true })
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
})
|