@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,64 @@
|
|
|
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 { getBoardMetrics } from '../metrics.ts'
|
|
5
|
+
|
|
6
|
+
let db: Database
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
db = new Database(':memory:')
|
|
10
|
+
db.run('PRAGMA foreign_keys = ON')
|
|
11
|
+
initSchema(db)
|
|
12
|
+
seedDefaultColumns(db)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('getBoardMetrics', () => {
|
|
16
|
+
test('returns tasks per column', () => {
|
|
17
|
+
addTask(db, 'A', { column: 'recurring' })
|
|
18
|
+
addTask(db, 'B', { column: 'recurring' })
|
|
19
|
+
addTask(db, 'C', { column: 'backlog' })
|
|
20
|
+
const metrics = getBoardMetrics(db)
|
|
21
|
+
expect(metrics.tasksByColumn.find((c) => c.column_name === 'recurring')!.count).toBe(2)
|
|
22
|
+
expect(metrics.tasksByColumn.find((c) => c.column_name === 'backlog')!.count).toBe(1)
|
|
23
|
+
expect(metrics.tasksByColumn.find((c) => c.column_name === 'done')!.count).toBe(0)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('returns tasks by priority', () => {
|
|
27
|
+
addTask(db, 'Urgent', { priority: 'urgent' })
|
|
28
|
+
addTask(db, 'High', { priority: 'high' })
|
|
29
|
+
addTask(db, 'Low', { priority: 'low' })
|
|
30
|
+
const metrics = getBoardMetrics(db)
|
|
31
|
+
expect(metrics.tasksByPriority.find((p) => p.priority === 'urgent')!.count).toBe(1)
|
|
32
|
+
expect(metrics.tasksByPriority.find((p) => p.priority === 'high')!.count).toBe(1)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('returns total and completed counts', () => {
|
|
36
|
+
addTask(db, 'A', { column: 'recurring' })
|
|
37
|
+
addTask(db, 'B', { column: 'done' })
|
|
38
|
+
addTask(db, 'C', { column: 'done' })
|
|
39
|
+
const metrics = getBoardMetrics(db)
|
|
40
|
+
expect(metrics.totalTasks).toBe(3)
|
|
41
|
+
expect(metrics.completedTasks).toBe(2)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('returns null avg completion when no data', () => {
|
|
45
|
+
const metrics = getBoardMetrics(db)
|
|
46
|
+
expect(metrics.avgCompletionHours).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('returns recent activity', () => {
|
|
50
|
+
addTask(db, 'A')
|
|
51
|
+
addTask(db, 'B')
|
|
52
|
+
const metrics = getBoardMetrics(db)
|
|
53
|
+
expect(metrics.recentActivity.length).toBeGreaterThanOrEqual(2)
|
|
54
|
+
expect(metrics.recentActivity[0]!.action).toBe('created')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('works with empty board', () => {
|
|
58
|
+
const metrics = getBoardMetrics(db)
|
|
59
|
+
expect(metrics.totalTasks).toBe(0)
|
|
60
|
+
expect(metrics.completedTasks).toBe(0)
|
|
61
|
+
expect(metrics.tasksByColumn).toHaveLength(5)
|
|
62
|
+
expect(metrics.tasksByPriority).toHaveLength(0)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { success, error, formatOutput } from '../output.ts'
|
|
3
|
+
|
|
4
|
+
describe('success', () => {
|
|
5
|
+
test('wraps data in ok envelope', () => {
|
|
6
|
+
const result = success({ id: '123' })
|
|
7
|
+
expect(result).toEqual({ ok: true, data: { id: '123' } })
|
|
8
|
+
})
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe('error', () => {
|
|
12
|
+
test('wraps error in envelope', () => {
|
|
13
|
+
const result = error('NOT_FOUND', 'Task not found')
|
|
14
|
+
expect(result).toEqual({
|
|
15
|
+
ok: false,
|
|
16
|
+
error: { code: 'NOT_FOUND', message: 'Task not found' },
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('formatOutput', () => {
|
|
22
|
+
test('returns compact JSON when pretty=false', () => {
|
|
23
|
+
const result = success({ x: 1 })
|
|
24
|
+
const output = formatOutput(result, false)
|
|
25
|
+
expect(output).toBe('{"ok":true,"data":{"x":1}}')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('returns error text when pretty=true', () => {
|
|
29
|
+
const result = error('CODE', 'Something failed')
|
|
30
|
+
const output = formatOutput(result, true)
|
|
31
|
+
expect(output).toBe('Error [CODE]: Something failed')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('formats message data in pretty mode', () => {
|
|
35
|
+
const result = success({ message: 'Board initialized.' })
|
|
36
|
+
const output = formatOutput(result, true)
|
|
37
|
+
expect(output).toBe('Board initialized.')
|
|
38
|
+
})
|
|
39
|
+
})
|
package/src/activity.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite'
|
|
2
|
+
import { generateId } from './id.ts'
|
|
3
|
+
import type { ActivityEntry, ActivityAction, ColumnTimeEntry } from './types.ts'
|
|
4
|
+
|
|
5
|
+
export function logActivity(
|
|
6
|
+
db: Database,
|
|
7
|
+
taskId: string,
|
|
8
|
+
action: ActivityAction,
|
|
9
|
+
opts: { field?: string; old_value?: string | null; new_value?: string | null } = {},
|
|
10
|
+
): void {
|
|
11
|
+
db.query(
|
|
12
|
+
`INSERT INTO activity_log (id, task_id, action, field_changed, old_value, new_value)
|
|
13
|
+
VALUES ($id, $task_id, $action, $field, $old, $new)`,
|
|
14
|
+
).run({
|
|
15
|
+
$id: generateId('a'),
|
|
16
|
+
$task_id: taskId,
|
|
17
|
+
$action: action,
|
|
18
|
+
$field: opts.field ?? null,
|
|
19
|
+
$old: opts.old_value ?? null,
|
|
20
|
+
$new: opts.new_value ?? null,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function listActivity(
|
|
25
|
+
db: Database,
|
|
26
|
+
opts: { limit?: number; taskId?: string } = {},
|
|
27
|
+
): ActivityEntry[] {
|
|
28
|
+
const conditions: string[] = []
|
|
29
|
+
const params: Record<string, string | number> = {}
|
|
30
|
+
|
|
31
|
+
if (opts.taskId) {
|
|
32
|
+
conditions.push('task_id = $task_id')
|
|
33
|
+
params['$task_id'] = opts.taskId
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
|
|
37
|
+
const limit = opts.limit ? `LIMIT ${opts.limit}` : 'LIMIT 50'
|
|
38
|
+
|
|
39
|
+
return db
|
|
40
|
+
.query(`SELECT * FROM activity_log ${where} ORDER BY timestamp DESC, rowid DESC ${limit}`)
|
|
41
|
+
.all(params as Record<string, string>) as ActivityEntry[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getTaskActivity(db: Database, taskId: string): ActivityEntry[] {
|
|
45
|
+
return listActivity(db, { taskId })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function enterColumn(db: Database, taskId: string, columnId: string): void {
|
|
49
|
+
db.query(
|
|
50
|
+
`INSERT INTO column_time_tracking (id, task_id, column_id)
|
|
51
|
+
VALUES ($id, $task_id, $col)`,
|
|
52
|
+
).run({
|
|
53
|
+
$id: generateId('ct'),
|
|
54
|
+
$task_id: taskId,
|
|
55
|
+
$col: columnId,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function exitColumn(db: Database, taskId: string, columnId: string): void {
|
|
60
|
+
db.query(
|
|
61
|
+
`UPDATE column_time_tracking SET exited_at = datetime('now')
|
|
62
|
+
WHERE task_id = $task_id AND column_id = $col AND exited_at IS NULL`,
|
|
63
|
+
).run({
|
|
64
|
+
$task_id: taskId,
|
|
65
|
+
$col: columnId,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getColumnTimeEntries(db: Database, taskId: string): ColumnTimeEntry[] {
|
|
70
|
+
return db
|
|
71
|
+
.query(`SELECT * FROM column_time_tracking WHERE task_id = $task_id ORDER BY entered_at`)
|
|
72
|
+
.all({ $task_id: taskId }) as ColumnTimeEntry[]
|
|
73
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { KanbanError, ErrorCode } from './errors.ts'
|
|
2
|
+
import type { BoardConfig, CliOutput } from './types.ts'
|
|
3
|
+
import type { CreateTaskInput, UpdateTaskInput, KanbanProvider } from './providers/types.ts'
|
|
4
|
+
|
|
5
|
+
interface MoveTaskBody {
|
|
6
|
+
column?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function json(data: unknown, status = 200): Response {
|
|
10
|
+
return Response.json(data, { status })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseOptionalInt(value: string | null): number | undefined {
|
|
14
|
+
return value ? parseInt(value, 10) : undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function missingArgument(field: string): Response {
|
|
18
|
+
return json(
|
|
19
|
+
{ ok: false, error: { code: 'MISSING_ARGUMENT', message: `${field} is required` } },
|
|
20
|
+
400,
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function statusForCode(code: string): number {
|
|
25
|
+
if (code === ErrorCode.TASK_NOT_FOUND || code === ErrorCode.COLUMN_NOT_FOUND) return 404
|
|
26
|
+
if (code === ErrorCode.PROVIDER_AUTH_FAILED) return 401
|
|
27
|
+
if (code === ErrorCode.PROVIDER_RATE_LIMITED) return 429
|
|
28
|
+
if (code === ErrorCode.UNSUPPORTED_OPERATION) return 400
|
|
29
|
+
if (code === ErrorCode.PROVIDER_NOT_CONFIGURED) return 500
|
|
30
|
+
return 400
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toResponse(result: CliOutput): Response {
|
|
34
|
+
if (result.ok) return json(result)
|
|
35
|
+
return json(result, statusForCode(result.error.code))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function wrapHandler(fn: () => Promise<CliOutput> | CliOutput): Promise<Response> {
|
|
39
|
+
try {
|
|
40
|
+
return toResponse(await fn())
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (err instanceof KanbanError) {
|
|
43
|
+
return json(
|
|
44
|
+
{ ok: false, error: { code: err.code, message: err.message } },
|
|
45
|
+
statusForCode(err.code),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
49
|
+
return json({ ok: false, error: { code: 'INTERNAL_ERROR', message: msg } }, 500)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ApiResult {
|
|
54
|
+
response: Response
|
|
55
|
+
mutated: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function handleRequest(provider: KanbanProvider, req: Request): Promise<ApiResult> {
|
|
59
|
+
const url = new URL(req.url)
|
|
60
|
+
const path = url.pathname
|
|
61
|
+
const method = req.method
|
|
62
|
+
|
|
63
|
+
if (path === '/api/bootstrap' && method === 'GET') {
|
|
64
|
+
return {
|
|
65
|
+
response: await wrapHandler(async () => ({ ok: true, data: await provider.getBootstrap() })),
|
|
66
|
+
mutated: false,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (path === '/api/provider' && method === 'GET') {
|
|
71
|
+
return {
|
|
72
|
+
response: await wrapHandler(async () => ({ ok: true, data: await provider.getContext() })),
|
|
73
|
+
mutated: false,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (path === '/api/board' && method === 'GET') {
|
|
78
|
+
return {
|
|
79
|
+
response: await wrapHandler(async () => ({ ok: true, data: await provider.getBoard() })),
|
|
80
|
+
mutated: false,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (path === '/api/columns' && method === 'GET') {
|
|
85
|
+
return {
|
|
86
|
+
response: await wrapHandler(async () => ({ ok: true, data: await provider.listColumns() })),
|
|
87
|
+
mutated: false,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (path === '/api/tasks' && method === 'GET') {
|
|
92
|
+
return {
|
|
93
|
+
response: await wrapHandler(async () => {
|
|
94
|
+
const column = url.searchParams.get('column') ?? undefined
|
|
95
|
+
const priority = url.searchParams.get('priority') ?? undefined
|
|
96
|
+
const assignee = url.searchParams.get('assignee') ?? undefined
|
|
97
|
+
const project = url.searchParams.get('project') ?? undefined
|
|
98
|
+
const sort = url.searchParams.get('sort') ?? undefined
|
|
99
|
+
const limit = parseOptionalInt(url.searchParams.get('limit'))
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
data: await provider.listTasks({ column, priority, assignee, project, sort, limit }),
|
|
103
|
+
}
|
|
104
|
+
}),
|
|
105
|
+
mutated: false,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (path === '/api/tasks' && method === 'POST') {
|
|
110
|
+
const body = (await req.json()) as Partial<CreateTaskInput>
|
|
111
|
+
if (!body.title) return { response: missingArgument('title'), mutated: false }
|
|
112
|
+
const response = await wrapHandler(async () => ({
|
|
113
|
+
ok: true,
|
|
114
|
+
data: await provider.createTask({
|
|
115
|
+
title: body.title!,
|
|
116
|
+
description: body.description,
|
|
117
|
+
column: body.column,
|
|
118
|
+
priority: body.priority,
|
|
119
|
+
assignee: body.assignee,
|
|
120
|
+
project: body.project,
|
|
121
|
+
metadata: body.metadata,
|
|
122
|
+
}),
|
|
123
|
+
}))
|
|
124
|
+
return { response, mutated: response.ok }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const taskMatch = path.match(/^\/api\/tasks\/([^/]+)$/)
|
|
128
|
+
if (taskMatch) {
|
|
129
|
+
const id = decodeURIComponent(taskMatch[1]!)
|
|
130
|
+
|
|
131
|
+
if (method === 'GET') {
|
|
132
|
+
return {
|
|
133
|
+
response: await wrapHandler(async () => ({ ok: true, data: await provider.getTask(id) })),
|
|
134
|
+
mutated: false,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (method === 'PATCH') {
|
|
139
|
+
const body = (await req.json()) as UpdateTaskInput
|
|
140
|
+
const response = await wrapHandler(async () => ({
|
|
141
|
+
ok: true,
|
|
142
|
+
data: await provider.updateTask(id, body),
|
|
143
|
+
}))
|
|
144
|
+
return { response, mutated: response.ok }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (method === 'DELETE') {
|
|
148
|
+
const response = await wrapHandler(async () => ({
|
|
149
|
+
ok: true,
|
|
150
|
+
data: await provider.deleteTask(id),
|
|
151
|
+
}))
|
|
152
|
+
return { response, mutated: response.ok }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const moveMatch = path.match(/^\/api\/tasks\/([^/]+)\/move$/)
|
|
157
|
+
if (moveMatch && method === 'PATCH') {
|
|
158
|
+
const id = decodeURIComponent(moveMatch[1]!)
|
|
159
|
+
const body = (await req.json()) as MoveTaskBody
|
|
160
|
+
if (!body.column) return { response: missingArgument('column'), mutated: false }
|
|
161
|
+
const response = await wrapHandler(async () => ({
|
|
162
|
+
ok: true,
|
|
163
|
+
data: await provider.moveTask(id, body.column!),
|
|
164
|
+
}))
|
|
165
|
+
return { response, mutated: response.ok }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (path === '/api/activity' && method === 'GET') {
|
|
169
|
+
return {
|
|
170
|
+
response: await wrapHandler(async () => {
|
|
171
|
+
const taskId = url.searchParams.get('taskId') ?? undefined
|
|
172
|
+
const limit = parseOptionalInt(url.searchParams.get('limit'))
|
|
173
|
+
return { ok: true, data: await provider.getActivity(limit, taskId) }
|
|
174
|
+
}),
|
|
175
|
+
mutated: false,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (path === '/api/metrics' && method === 'GET') {
|
|
180
|
+
return {
|
|
181
|
+
response: await wrapHandler(async () => ({ ok: true, data: await provider.getMetrics() })),
|
|
182
|
+
mutated: false,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (path === '/api/config' && method === 'GET') {
|
|
187
|
+
return {
|
|
188
|
+
response: await wrapHandler(async () => ({ ok: true, data: await provider.getConfig() })),
|
|
189
|
+
mutated: false,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (path === '/api/config' && method === 'PATCH') {
|
|
194
|
+
const body = (await req.json()) as Partial<BoardConfig>
|
|
195
|
+
const response = await wrapHandler(async () => ({
|
|
196
|
+
ok: true,
|
|
197
|
+
data: await provider.patchConfig(body),
|
|
198
|
+
}))
|
|
199
|
+
return { response, mutated: response.ok }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
response: json(
|
|
204
|
+
{ ok: false, error: { code: 'NOT_FOUND', message: `No route: ${method} ${path}` } },
|
|
205
|
+
404,
|
|
206
|
+
),
|
|
207
|
+
mutated: false,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import { initSchema, seedDefaultColumns, isInitialized, getBoardView, resetBoard } from '../db.ts'
|
|
3
|
+
import { ErrorCode, KanbanError } from '../errors.ts'
|
|
4
|
+
import { success } from '../output.ts'
|
|
5
|
+
import type { CliOutput } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
export function boardInit(db: Database): CliOutput {
|
|
8
|
+
if (isInitialized(db)) {
|
|
9
|
+
throw new KanbanError(ErrorCode.BOARD_ALREADY_INITIALIZED, 'Board is already initialized')
|
|
10
|
+
}
|
|
11
|
+
initSchema(db)
|
|
12
|
+
seedDefaultColumns(db)
|
|
13
|
+
return success({ message: 'Board initialized with default columns.' })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function boardView(db: Database): CliOutput {
|
|
17
|
+
if (!isInitialized(db)) {
|
|
18
|
+
throw new KanbanError(
|
|
19
|
+
ErrorCode.BOARD_NOT_INITIALIZED,
|
|
20
|
+
'Board not initialized. Run: kanban board init',
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
return success(getBoardView(db))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function boardReset(db: Database): CliOutput {
|
|
27
|
+
resetBoard(db)
|
|
28
|
+
return success({ message: 'Board reset. All data cleared and defaults restored.' })
|
|
29
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import { bulkMoveAll, bulkClearDone } from '../db.ts'
|
|
3
|
+
import { ErrorCode, KanbanError } from '../errors.ts'
|
|
4
|
+
import { success } from '../output.ts'
|
|
5
|
+
import type { CliOutput } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
export function bulkMoveAllCmd(db: Database, args: { from?: string; to?: string }): CliOutput {
|
|
8
|
+
if (!args.from || !args.to) {
|
|
9
|
+
throw new KanbanError(
|
|
10
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
11
|
+
'Usage: kanban bulk move-all <from-col> <to-col>',
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
return success(bulkMoveAll(db, args.from, args.to))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function bulkClearDoneCmd(db: Database): CliOutput {
|
|
18
|
+
return success(bulkClearDone(db))
|
|
19
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import { addColumn, listColumns, renameColumn, reorderColumn, deleteColumn } from '../db.ts'
|
|
3
|
+
import { ErrorCode, KanbanError } from '../errors.ts'
|
|
4
|
+
import { success } from '../output.ts'
|
|
5
|
+
import type { CliOutput } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
export function columnAdd(
|
|
8
|
+
db: Database,
|
|
9
|
+
args: { name?: string; position?: string; color?: string },
|
|
10
|
+
): CliOutput {
|
|
11
|
+
if (!args.name) {
|
|
12
|
+
throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Column name is required')
|
|
13
|
+
}
|
|
14
|
+
const pos = args.position !== undefined ? parseInt(args.position, 10) : undefined
|
|
15
|
+
if (pos !== undefined && isNaN(pos)) {
|
|
16
|
+
throw new KanbanError(ErrorCode.INVALID_POSITION, 'Position must be a number')
|
|
17
|
+
}
|
|
18
|
+
return success(addColumn(db, args.name, { position: pos, color: args.color }))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function columnList(db: Database): CliOutput {
|
|
22
|
+
return success(listColumns(db))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function columnRename(
|
|
26
|
+
db: Database,
|
|
27
|
+
args: { idOrName?: string; newName?: string },
|
|
28
|
+
): CliOutput {
|
|
29
|
+
if (!args.idOrName || !args.newName) {
|
|
30
|
+
throw new KanbanError(
|
|
31
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
32
|
+
'Usage: kanban column rename <id|name> <new-name>',
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
return success(renameColumn(db, args.idOrName, args.newName))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function columnReorder(
|
|
39
|
+
db: Database,
|
|
40
|
+
args: { idOrName?: string; position?: string },
|
|
41
|
+
): CliOutput {
|
|
42
|
+
if (!args.idOrName || args.position === undefined) {
|
|
43
|
+
throw new KanbanError(
|
|
44
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
45
|
+
'Usage: kanban column reorder <id|name> <position>',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
const pos = parseInt(args.position, 10)
|
|
49
|
+
if (isNaN(pos)) {
|
|
50
|
+
throw new KanbanError(ErrorCode.INVALID_POSITION, 'Position must be a number')
|
|
51
|
+
}
|
|
52
|
+
return success(reorderColumn(db, args.idOrName, pos))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function columnDelete(db: Database, args: { idOrName?: string }): CliOutput {
|
|
56
|
+
if (!args.idOrName) {
|
|
57
|
+
throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Usage: kanban column delete <id|name>')
|
|
58
|
+
}
|
|
59
|
+
return success(deleteColumn(db, args.idOrName))
|
|
60
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import { addTask, getTask, listTasks, updateTask, deleteTask, moveTask } from '../db.ts'
|
|
3
|
+
import { ErrorCode, KanbanError } from '../errors.ts'
|
|
4
|
+
import { success } from '../output.ts'
|
|
5
|
+
import type { CliOutput, Priority } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
export function taskAdd(
|
|
8
|
+
db: Database,
|
|
9
|
+
args: {
|
|
10
|
+
title?: string
|
|
11
|
+
description?: string
|
|
12
|
+
column?: string
|
|
13
|
+
priority?: string
|
|
14
|
+
assignee?: string
|
|
15
|
+
project?: string
|
|
16
|
+
metadata?: string
|
|
17
|
+
},
|
|
18
|
+
): CliOutput {
|
|
19
|
+
if (!args.title) {
|
|
20
|
+
throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task title is required')
|
|
21
|
+
}
|
|
22
|
+
return success(
|
|
23
|
+
addTask(db, args.title, {
|
|
24
|
+
description: args.description,
|
|
25
|
+
column: args.column,
|
|
26
|
+
priority: args.priority as Priority | undefined,
|
|
27
|
+
assignee: args.assignee,
|
|
28
|
+
project: args.project,
|
|
29
|
+
metadata: args.metadata,
|
|
30
|
+
}),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function taskList(
|
|
35
|
+
db: Database,
|
|
36
|
+
opts: {
|
|
37
|
+
column?: string
|
|
38
|
+
priority?: string
|
|
39
|
+
assignee?: string
|
|
40
|
+
project?: string
|
|
41
|
+
limit?: string
|
|
42
|
+
sort?: string
|
|
43
|
+
},
|
|
44
|
+
): CliOutput {
|
|
45
|
+
return success(
|
|
46
|
+
listTasks(db, {
|
|
47
|
+
column: opts.column,
|
|
48
|
+
priority: opts.priority,
|
|
49
|
+
assignee: opts.assignee,
|
|
50
|
+
project: opts.project,
|
|
51
|
+
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
|
|
52
|
+
sort: opts.sort,
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function taskView(db: Database, args: { id?: string }): CliOutput {
|
|
58
|
+
if (!args.id) {
|
|
59
|
+
throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
|
|
60
|
+
}
|
|
61
|
+
return success(getTask(db, args.id))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function taskUpdate(
|
|
65
|
+
db: Database,
|
|
66
|
+
args: {
|
|
67
|
+
id?: string
|
|
68
|
+
title?: string
|
|
69
|
+
description?: string
|
|
70
|
+
priority?: string
|
|
71
|
+
assignee?: string
|
|
72
|
+
project?: string
|
|
73
|
+
metadata?: string
|
|
74
|
+
},
|
|
75
|
+
): CliOutput {
|
|
76
|
+
if (!args.id) {
|
|
77
|
+
throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
|
|
78
|
+
}
|
|
79
|
+
return success(
|
|
80
|
+
updateTask(db, args.id, {
|
|
81
|
+
title: args.title,
|
|
82
|
+
description: args.description,
|
|
83
|
+
priority: args.priority as Priority | undefined,
|
|
84
|
+
assignee: args.assignee,
|
|
85
|
+
project: args.project,
|
|
86
|
+
metadata: args.metadata,
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function taskDelete(db: Database, args: { id?: string }): CliOutput {
|
|
92
|
+
if (!args.id) {
|
|
93
|
+
throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
|
|
94
|
+
}
|
|
95
|
+
return success(deleteTask(db, args.id))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function taskMove(db: Database, args: { id?: string; column?: string }): CliOutput {
|
|
99
|
+
if (!args.id || !args.column) {
|
|
100
|
+
throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Usage: kanban task move <id> <column>')
|
|
101
|
+
}
|
|
102
|
+
return success(moveTask(db, args.id, args.column))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function taskAssign(db: Database, args: { id?: string; assignee?: string }): CliOutput {
|
|
106
|
+
if (!args.id || !args.assignee) {
|
|
107
|
+
throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Usage: kanban task assign <id> <assignee>')
|
|
108
|
+
}
|
|
109
|
+
return success(updateTask(db, args.id, { assignee: args.assignee }))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function taskPrioritize(db: Database, args: { id?: string; priority?: string }): CliOutput {
|
|
113
|
+
if (!args.id || !args.priority) {
|
|
114
|
+
throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Usage: kanban task prioritize <id> <level>')
|
|
115
|
+
}
|
|
116
|
+
return success(updateTask(db, args.id, { priority: args.priority as Priority }))
|
|
117
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import type { BoardConfig } from './types.ts'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CONFIG: BoardConfig = { members: [], projects: [] }
|
|
6
|
+
|
|
7
|
+
export function getConfigPath(dbPath: string): string {
|
|
8
|
+
return join(dirname(dbPath), 'config.json')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function loadConfig(dbPath: string): BoardConfig {
|
|
12
|
+
const configPath = getConfigPath(dbPath)
|
|
13
|
+
try {
|
|
14
|
+
const raw = readFileSync(configPath, 'utf-8')
|
|
15
|
+
const parsed = JSON.parse(raw) as Partial<BoardConfig>
|
|
16
|
+
return {
|
|
17
|
+
members: Array.isArray(parsed.members) ? parsed.members : [],
|
|
18
|
+
projects: Array.isArray(parsed.projects) ? parsed.projects : [],
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
return { ...DEFAULT_CONFIG, members: [], projects: [] }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function saveConfig(configPath: string, config: BoardConfig): void {
|
|
26
|
+
const tmp = configPath + '.tmp'
|
|
27
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n')
|
|
28
|
+
renameSync(tmp, configPath)
|
|
29
|
+
}
|