@andypai/agent-kanban 0.3.3 → 0.3.5
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/README.md +26 -17
- package/package.json +3 -2
- package/src/__tests__/api.test.ts +3 -3
- package/src/__tests__/index.test.ts +22 -2
- package/src/__tests__/jira-provider-read.test.ts +22 -0
- package/src/__tests__/jira-wiring.test.ts +47 -17
- package/src/__tests__/linear-provider-sync.test.ts +77 -0
- package/src/__tests__/postgres-jira-provider.test.ts +315 -0
- package/src/__tests__/postgres-linear-provider.test.ts +309 -0
- package/src/__tests__/postgres-local-provider.test.ts +129 -0
- package/src/__tests__/storage-config.test.ts +39 -0
- package/src/__tests__/sync-config.test.ts +32 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +65 -28
- package/src/mcp/errors.ts +1 -0
- package/src/provider-runtime.ts +110 -0
- package/src/providers/index.ts +16 -37
- package/src/providers/jira.ts +5 -2
- package/src/providers/linear.ts +3 -2
- package/src/providers/postgres-jira.ts +1188 -0
- package/src/providers/postgres-linear.ts +1088 -0
- package/src/providers/postgres-local.ts +611 -0
- package/src/server.ts +2 -3
- package/src/storage-config.ts +41 -0
- package/src/sync-config.ts +21 -0
- package/src/tracker-config.ts +104 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import type { Sql } from 'postgres'
|
|
2
|
+
|
|
3
|
+
import { ErrorCode, KanbanError } from '../errors'
|
|
4
|
+
import { generateId } from '../id'
|
|
5
|
+
import type {
|
|
6
|
+
ActivityEntry,
|
|
7
|
+
BoardBootstrap,
|
|
8
|
+
BoardConfig,
|
|
9
|
+
BoardMetrics,
|
|
10
|
+
BoardView,
|
|
11
|
+
Column,
|
|
12
|
+
Priority,
|
|
13
|
+
Task,
|
|
14
|
+
TaskComment,
|
|
15
|
+
TaskWithColumn,
|
|
16
|
+
} from '../types'
|
|
17
|
+
import { LOCAL_CAPABILITIES } from './capabilities'
|
|
18
|
+
import type {
|
|
19
|
+
CreateTaskInput,
|
|
20
|
+
KanbanProvider,
|
|
21
|
+
ProviderContext,
|
|
22
|
+
ProviderSyncStatus,
|
|
23
|
+
TaskListFilters,
|
|
24
|
+
UpdateTaskInput,
|
|
25
|
+
} from './types'
|
|
26
|
+
import type { LocalTrackerConfig } from '../tracker-config'
|
|
27
|
+
|
|
28
|
+
const DEFAULT_COLUMNS = [
|
|
29
|
+
{ name: 'recurring', position: 0 },
|
|
30
|
+
{ name: 'backlog', position: 1 },
|
|
31
|
+
{ name: 'in-progress', position: 2 },
|
|
32
|
+
{ name: 'review', position: 3 },
|
|
33
|
+
{ name: 'done', position: 4 },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
interface TaskRow {
|
|
37
|
+
id: string
|
|
38
|
+
title: string
|
|
39
|
+
description: string
|
|
40
|
+
column_id: string
|
|
41
|
+
column_name?: string
|
|
42
|
+
position: number
|
|
43
|
+
priority: Priority
|
|
44
|
+
assignee: string
|
|
45
|
+
project: string
|
|
46
|
+
metadata: string
|
|
47
|
+
revision: number
|
|
48
|
+
created_at: string
|
|
49
|
+
updated_at: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ActivityRow {
|
|
53
|
+
id: string
|
|
54
|
+
task_id: string
|
|
55
|
+
action: ActivityEntry['action']
|
|
56
|
+
field_changed: string | null
|
|
57
|
+
old_value: string | null
|
|
58
|
+
new_value: string | null
|
|
59
|
+
timestamp: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function nowIso(): string {
|
|
63
|
+
return new Date().toISOString()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function defaultColumns(config: Pick<LocalTrackerConfig, 'defaultColumns'>): Array<{
|
|
67
|
+
name: string
|
|
68
|
+
position: number
|
|
69
|
+
}> {
|
|
70
|
+
const names =
|
|
71
|
+
config.defaultColumns && config.defaultColumns.length > 0
|
|
72
|
+
? config.defaultColumns
|
|
73
|
+
: DEFAULT_COLUMNS.map((column) => column.name)
|
|
74
|
+
return names.map((name, position) => ({ name, position }))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assertPriority(priority: string): asserts priority is Priority {
|
|
78
|
+
if (!['low', 'medium', 'high', 'urgent'].includes(priority)) {
|
|
79
|
+
throw new KanbanError(ErrorCode.INVALID_PRIORITY, `Invalid priority: ${priority}`)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseMetadata(metadata: string | undefined): string {
|
|
84
|
+
if (metadata === undefined) return '{}'
|
|
85
|
+
try {
|
|
86
|
+
JSON.parse(metadata)
|
|
87
|
+
return metadata
|
|
88
|
+
} catch {
|
|
89
|
+
throw new KanbanError(ErrorCode.INVALID_METADATA, 'Metadata must be valid JSON')
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class PostgresLocalProvider implements KanbanProvider {
|
|
94
|
+
readonly type = 'local' as const
|
|
95
|
+
private readonly ready: Promise<void>
|
|
96
|
+
|
|
97
|
+
constructor(
|
|
98
|
+
private readonly sql: Sql,
|
|
99
|
+
private readonly config: Pick<LocalTrackerConfig, 'defaultColumns' | 'defaultTaskColumn'> = {},
|
|
100
|
+
) {
|
|
101
|
+
this.ready = this.ensureSchema().then(() => this.seedDefaultColumns())
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async initialize(): Promise<void> {
|
|
105
|
+
await this.ready
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async ensureSchema(): Promise<void> {
|
|
109
|
+
await this.sql`
|
|
110
|
+
CREATE TABLE IF NOT EXISTS columns (
|
|
111
|
+
id TEXT PRIMARY KEY,
|
|
112
|
+
name TEXT UNIQUE NOT NULL,
|
|
113
|
+
position INTEGER NOT NULL,
|
|
114
|
+
color TEXT,
|
|
115
|
+
created_at TEXT NOT NULL,
|
|
116
|
+
updated_at TEXT NOT NULL
|
|
117
|
+
)
|
|
118
|
+
`
|
|
119
|
+
await this.sql`
|
|
120
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
121
|
+
id TEXT PRIMARY KEY,
|
|
122
|
+
title TEXT NOT NULL,
|
|
123
|
+
description TEXT NOT NULL DEFAULT '',
|
|
124
|
+
column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE RESTRICT,
|
|
125
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
126
|
+
priority TEXT NOT NULL DEFAULT 'medium',
|
|
127
|
+
assignee TEXT NOT NULL DEFAULT '',
|
|
128
|
+
project TEXT NOT NULL DEFAULT '',
|
|
129
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
130
|
+
revision INTEGER NOT NULL DEFAULT 0,
|
|
131
|
+
created_at TEXT NOT NULL,
|
|
132
|
+
updated_at TEXT NOT NULL
|
|
133
|
+
)
|
|
134
|
+
`
|
|
135
|
+
await this.sql`
|
|
136
|
+
CREATE TABLE IF NOT EXISTS activity_log (
|
|
137
|
+
id TEXT PRIMARY KEY,
|
|
138
|
+
task_id TEXT NOT NULL,
|
|
139
|
+
action TEXT NOT NULL,
|
|
140
|
+
field_changed TEXT,
|
|
141
|
+
old_value TEXT,
|
|
142
|
+
new_value TEXT,
|
|
143
|
+
timestamp TEXT NOT NULL
|
|
144
|
+
)
|
|
145
|
+
`
|
|
146
|
+
await this.sql`
|
|
147
|
+
CREATE TABLE IF NOT EXISTS comments (
|
|
148
|
+
id TEXT PRIMARY KEY,
|
|
149
|
+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
150
|
+
body TEXT NOT NULL,
|
|
151
|
+
author TEXT,
|
|
152
|
+
created_at TEXT NOT NULL,
|
|
153
|
+
updated_at TEXT NOT NULL
|
|
154
|
+
)
|
|
155
|
+
`
|
|
156
|
+
await this.sql`
|
|
157
|
+
CREATE TABLE IF NOT EXISTS column_time_tracking (
|
|
158
|
+
id TEXT PRIMARY KEY,
|
|
159
|
+
task_id TEXT NOT NULL,
|
|
160
|
+
column_id TEXT NOT NULL,
|
|
161
|
+
entered_at TEXT NOT NULL,
|
|
162
|
+
exited_at TEXT
|
|
163
|
+
)
|
|
164
|
+
`
|
|
165
|
+
await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_column_id ON tasks(column_id)`
|
|
166
|
+
await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority)`
|
|
167
|
+
await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee)`
|
|
168
|
+
await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project)`
|
|
169
|
+
await this.sql`CREATE INDEX IF NOT EXISTS idx_activity_task_id ON activity_log(task_id)`
|
|
170
|
+
await this.sql`CREATE INDEX IF NOT EXISTS idx_activity_timestamp ON activity_log(timestamp)`
|
|
171
|
+
await this.sql`CREATE INDEX IF NOT EXISTS idx_comments_task_id ON comments(task_id)`
|
|
172
|
+
await this
|
|
173
|
+
.sql`CREATE INDEX IF NOT EXISTS idx_column_time_task_id ON column_time_tracking(task_id)`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async seedDefaultColumns(): Promise<void> {
|
|
177
|
+
const [row] = await this.sql<
|
|
178
|
+
{ count: string | number }[]
|
|
179
|
+
>`SELECT COUNT(*) AS count FROM columns`
|
|
180
|
+
if (Number(row?.count ?? 0) > 0) return
|
|
181
|
+
|
|
182
|
+
for (const column of defaultColumns(this.config)) {
|
|
183
|
+
await this.sql`
|
|
184
|
+
INSERT INTO columns (id, name, position, created_at, updated_at)
|
|
185
|
+
VALUES (${generateId('c')}, ${column.name}, ${column.position}, ${nowIso()}, ${nowIso()})
|
|
186
|
+
`
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private enrichTask(row: TaskRow, commentCount = 0): TaskWithColumn {
|
|
191
|
+
return {
|
|
192
|
+
...row,
|
|
193
|
+
providerId: row.id,
|
|
194
|
+
externalRef: row.id,
|
|
195
|
+
url: null,
|
|
196
|
+
assignees: row.assignee ? [row.assignee] : [],
|
|
197
|
+
labels: [],
|
|
198
|
+
comment_count: commentCount,
|
|
199
|
+
version: String(row.revision ?? 0),
|
|
200
|
+
source_updated_at: null,
|
|
201
|
+
column_name: row.column_name ?? '',
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async commentCountsByTask(): Promise<Map<string, number>> {
|
|
206
|
+
await this.ready
|
|
207
|
+
const rows = await this.sql<{ task_id: string; count: string | number }[]>`
|
|
208
|
+
SELECT task_id, COUNT(*) AS count
|
|
209
|
+
FROM comments
|
|
210
|
+
GROUP BY task_id
|
|
211
|
+
`
|
|
212
|
+
return new Map(rows.map((row) => [row.task_id, Number(row.count)]))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async resolveColumn(idOrName: string): Promise<Column> {
|
|
216
|
+
await this.ready
|
|
217
|
+
const [byId] = await this.sql<Column[]>`SELECT * FROM columns WHERE id = ${idOrName} LIMIT 1`
|
|
218
|
+
if (byId) return byId
|
|
219
|
+
|
|
220
|
+
const [byName] = await this.sql<Column[]>`
|
|
221
|
+
SELECT * FROM columns WHERE LOWER(name) = LOWER(${idOrName}) LIMIT 1
|
|
222
|
+
`
|
|
223
|
+
if (byName) return byName
|
|
224
|
+
throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No column matching '${idOrName}'`)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async resolveDefaultTaskColumn(): Promise<Column> {
|
|
228
|
+
const configured = this.config.defaultTaskColumn?.trim()
|
|
229
|
+
if (configured) return this.resolveColumn(configured)
|
|
230
|
+
|
|
231
|
+
await this.ready
|
|
232
|
+
const [backlog] = await this.sql<Column[]>`
|
|
233
|
+
SELECT * FROM columns WHERE LOWER(name) = 'backlog' ORDER BY position LIMIT 1
|
|
234
|
+
`
|
|
235
|
+
if (backlog) return backlog
|
|
236
|
+
|
|
237
|
+
const [first] = await this.sql<Column[]>`
|
|
238
|
+
SELECT * FROM columns ORDER BY position, name LIMIT 1
|
|
239
|
+
`
|
|
240
|
+
if (first) return first
|
|
241
|
+
throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, 'No columns are configured')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async selectTask(idOrRef: string): Promise<TaskRow | null> {
|
|
245
|
+
const [row] = await this.sql<TaskRow[]>`
|
|
246
|
+
SELECT tasks.*, columns.name AS column_name
|
|
247
|
+
FROM tasks
|
|
248
|
+
JOIN columns ON columns.id = tasks.column_id
|
|
249
|
+
WHERE tasks.id = ${idOrRef}
|
|
250
|
+
LIMIT 1
|
|
251
|
+
`
|
|
252
|
+
return row ?? null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async requireTask(idOrRef: string): Promise<TaskRow> {
|
|
256
|
+
await this.ready
|
|
257
|
+
const row = await this.selectTask(idOrRef)
|
|
258
|
+
if (!row) throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${idOrRef}'`)
|
|
259
|
+
return row
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private async insertActivity(
|
|
263
|
+
taskId: string,
|
|
264
|
+
action: ActivityEntry['action'],
|
|
265
|
+
fieldChanged: string | null,
|
|
266
|
+
oldValue: string | null,
|
|
267
|
+
newValue: string | null,
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
await this.sql`
|
|
270
|
+
INSERT INTO activity_log (id, task_id, action, field_changed, old_value, new_value, timestamp)
|
|
271
|
+
VALUES (${generateId('a')}, ${taskId}, ${action}, ${fieldChanged}, ${oldValue}, ${newValue}, ${nowIso()})
|
|
272
|
+
`
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async getContext(): Promise<ProviderContext> {
|
|
276
|
+
await this.ready
|
|
277
|
+
return { provider: this.type, capabilities: LOCAL_CAPABILITIES, team: null }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async getBootstrap(): Promise<BoardBootstrap> {
|
|
281
|
+
await this.ready
|
|
282
|
+
const metrics = await this.getMetrics()
|
|
283
|
+
return {
|
|
284
|
+
provider: this.type,
|
|
285
|
+
capabilities: LOCAL_CAPABILITIES,
|
|
286
|
+
board: await this.getBoard(),
|
|
287
|
+
config: await this.getConfig(),
|
|
288
|
+
metrics,
|
|
289
|
+
activity: await this.getActivity(50),
|
|
290
|
+
team: null,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async getBoard(): Promise<BoardView> {
|
|
295
|
+
await this.ready
|
|
296
|
+
const columns = await this.listColumns()
|
|
297
|
+
const counts = await this.commentCountsByTask()
|
|
298
|
+
const rows = await this.sql<TaskRow[]>`
|
|
299
|
+
SELECT tasks.*, columns.name AS column_name
|
|
300
|
+
FROM tasks
|
|
301
|
+
JOIN columns ON columns.id = tasks.column_id
|
|
302
|
+
ORDER BY columns.position, tasks.position, tasks.created_at
|
|
303
|
+
`
|
|
304
|
+
return {
|
|
305
|
+
columns: columns.map((column) => ({
|
|
306
|
+
...column,
|
|
307
|
+
tasks: rows
|
|
308
|
+
.filter((task) => task.column_id === column.id)
|
|
309
|
+
.map((task) => this.enrichTask(task, counts.get(task.id) ?? 0)),
|
|
310
|
+
})),
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async listColumns(): Promise<Column[]> {
|
|
315
|
+
await this.ready
|
|
316
|
+
return this.sql<Column[]>`SELECT * FROM columns ORDER BY position`
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async listTasks(filters: TaskListFilters = {}): Promise<Task[]> {
|
|
320
|
+
await this.ready
|
|
321
|
+
const rows = await this.sql<TaskRow[]>`
|
|
322
|
+
SELECT tasks.*, columns.name AS column_name
|
|
323
|
+
FROM tasks
|
|
324
|
+
JOIN columns ON columns.id = tasks.column_id
|
|
325
|
+
ORDER BY tasks.created_at
|
|
326
|
+
`
|
|
327
|
+
const counts = await this.commentCountsByTask()
|
|
328
|
+
let tasks = rows.map((task) => this.enrichTask(task, counts.get(task.id) ?? 0))
|
|
329
|
+
|
|
330
|
+
if (filters.column) {
|
|
331
|
+
const column = await this.resolveColumn(filters.column)
|
|
332
|
+
tasks = tasks.filter((task) => task.column_id === column.id)
|
|
333
|
+
}
|
|
334
|
+
if (filters.priority) tasks = tasks.filter((task) => task.priority === filters.priority)
|
|
335
|
+
if (filters.assignee) tasks = tasks.filter((task) => task.assignee === filters.assignee)
|
|
336
|
+
if (filters.project) tasks = tasks.filter((task) => task.project === filters.project)
|
|
337
|
+
if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
|
|
338
|
+
if (filters.sort === 'updated')
|
|
339
|
+
tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
|
340
|
+
if (filters.limit) tasks = tasks.slice(0, filters.limit)
|
|
341
|
+
return tasks
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async getTask(idOrRef: string): Promise<Task> {
|
|
345
|
+
const row = await this.requireTask(idOrRef)
|
|
346
|
+
const counts = await this.commentCountsByTask()
|
|
347
|
+
return this.enrichTask(row, counts.get(row.id) ?? 0)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async createTask(input: CreateTaskInput): Promise<Task> {
|
|
351
|
+
await this.ready
|
|
352
|
+
const priority = input.priority ?? 'medium'
|
|
353
|
+
assertPriority(priority)
|
|
354
|
+
const metadata = parseMetadata(input.metadata)
|
|
355
|
+
const column = input.column
|
|
356
|
+
? await this.resolveColumn(input.column)
|
|
357
|
+
: await this.resolveDefaultTaskColumn()
|
|
358
|
+
const [positionRow] = await this.sql<{ next: string | number }[]>`
|
|
359
|
+
SELECT COALESCE(MAX(position), -1) + 1 AS next FROM tasks WHERE column_id = ${column.id}
|
|
360
|
+
`
|
|
361
|
+
const id = generateId('t')
|
|
362
|
+
const timestamp = nowIso()
|
|
363
|
+
await this.sql`
|
|
364
|
+
INSERT INTO tasks (
|
|
365
|
+
id, title, description, column_id, position, priority, assignee, project, metadata,
|
|
366
|
+
revision, created_at, updated_at
|
|
367
|
+
)
|
|
368
|
+
VALUES (
|
|
369
|
+
${id}, ${input.title}, ${input.description ?? ''}, ${column.id}, ${Number(positionRow?.next ?? 0)},
|
|
370
|
+
${priority}, ${input.assignee ?? ''}, ${input.project ?? ''}, ${metadata},
|
|
371
|
+
0, ${timestamp}, ${timestamp}
|
|
372
|
+
)
|
|
373
|
+
`
|
|
374
|
+
await this.insertActivity(id, 'created', null, null, input.title)
|
|
375
|
+
return this.getTask(id)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
|
|
379
|
+
await this.ready
|
|
380
|
+
const current = await this.requireTask(idOrRef)
|
|
381
|
+
if (
|
|
382
|
+
input.expectedVersion !== undefined &&
|
|
383
|
+
String(current.revision ?? 0) !== input.expectedVersion
|
|
384
|
+
) {
|
|
385
|
+
throw new KanbanError(
|
|
386
|
+
ErrorCode.CONFLICT,
|
|
387
|
+
`Task ${idOrRef} was modified since you loaded it (expected version ${input.expectedVersion}, current ${current.revision ?? 0})`,
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (input.priority !== undefined) assertPriority(input.priority)
|
|
392
|
+
const metadata = input.metadata === undefined ? undefined : parseMetadata(input.metadata)
|
|
393
|
+
const next = {
|
|
394
|
+
title: input.title ?? current.title,
|
|
395
|
+
description: input.description ?? current.description,
|
|
396
|
+
priority: input.priority ?? current.priority,
|
|
397
|
+
assignee: input.assignee ?? current.assignee,
|
|
398
|
+
project: input.project ?? current.project,
|
|
399
|
+
metadata: metadata ?? current.metadata,
|
|
400
|
+
}
|
|
401
|
+
await this.sql`
|
|
402
|
+
UPDATE tasks
|
|
403
|
+
SET title = ${next.title},
|
|
404
|
+
description = ${next.description},
|
|
405
|
+
priority = ${next.priority},
|
|
406
|
+
assignee = ${next.assignee},
|
|
407
|
+
project = ${next.project},
|
|
408
|
+
metadata = ${next.metadata},
|
|
409
|
+
revision = revision + 1,
|
|
410
|
+
updated_at = ${nowIso()}
|
|
411
|
+
WHERE id = ${current.id}
|
|
412
|
+
`
|
|
413
|
+
if (input.title !== undefined && input.title !== current.title) {
|
|
414
|
+
await this.insertActivity(current.id, 'updated', 'title', current.title, input.title)
|
|
415
|
+
}
|
|
416
|
+
if (input.assignee !== undefined && input.assignee !== current.assignee) {
|
|
417
|
+
await this.insertActivity(
|
|
418
|
+
current.id,
|
|
419
|
+
'assigned',
|
|
420
|
+
'assignee',
|
|
421
|
+
current.assignee,
|
|
422
|
+
input.assignee,
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
if (input.priority !== undefined && input.priority !== current.priority) {
|
|
426
|
+
await this.insertActivity(
|
|
427
|
+
current.id,
|
|
428
|
+
'prioritized',
|
|
429
|
+
'priority',
|
|
430
|
+
current.priority,
|
|
431
|
+
input.priority,
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
return this.getTask(current.id)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async moveTask(idOrRef: string, columnName: string): Promise<Task> {
|
|
438
|
+
await this.ready
|
|
439
|
+
const current = await this.requireTask(idOrRef)
|
|
440
|
+
const column = await this.resolveColumn(columnName)
|
|
441
|
+
const oldColumn = current.column_name ?? current.column_id
|
|
442
|
+
await this.sql`
|
|
443
|
+
UPDATE tasks
|
|
444
|
+
SET column_id = ${column.id},
|
|
445
|
+
revision = revision + 1,
|
|
446
|
+
updated_at = ${nowIso()}
|
|
447
|
+
WHERE id = ${current.id}
|
|
448
|
+
`
|
|
449
|
+
await this.insertActivity(current.id, 'moved', 'column', oldColumn, column.name)
|
|
450
|
+
return this.getTask(current.id)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async deleteTask(idOrRef: string): Promise<Task> {
|
|
454
|
+
await this.ready
|
|
455
|
+
const task = await this.getTask(idOrRef)
|
|
456
|
+
await this.sql`DELETE FROM tasks WHERE id = ${task.id}`
|
|
457
|
+
await this.insertActivity(task.id, 'deleted', null, task.title, null)
|
|
458
|
+
return task
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async listComments(idOrRef: string): Promise<TaskComment[]> {
|
|
462
|
+
const task = await this.requireTask(idOrRef)
|
|
463
|
+
return this.sql<TaskComment[]>`
|
|
464
|
+
SELECT * FROM comments WHERE task_id = ${task.id} ORDER BY created_at
|
|
465
|
+
`
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
|
|
469
|
+
const task = await this.requireTask(idOrRef)
|
|
470
|
+
const [comment] = await this.sql<TaskComment[]>`
|
|
471
|
+
SELECT * FROM comments WHERE task_id = ${task.id} AND id = ${commentId} LIMIT 1
|
|
472
|
+
`
|
|
473
|
+
if (!comment) {
|
|
474
|
+
throw new KanbanError(
|
|
475
|
+
ErrorCode.COMMENT_NOT_FOUND,
|
|
476
|
+
`No comment '${commentId}' on task '${idOrRef}'`,
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
return comment
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async comment(idOrRef: string, body: string): Promise<TaskComment> {
|
|
483
|
+
const task = await this.requireTask(idOrRef)
|
|
484
|
+
const id = generateId('cm')
|
|
485
|
+
const timestamp = nowIso()
|
|
486
|
+
const [comment] = await this.sql<TaskComment[]>`
|
|
487
|
+
INSERT INTO comments (id, task_id, body, author, created_at, updated_at)
|
|
488
|
+
VALUES (${id}, ${task.id}, ${body}, ${null}, ${timestamp}, ${timestamp})
|
|
489
|
+
RETURNING *
|
|
490
|
+
`
|
|
491
|
+
return comment!
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
|
|
495
|
+
const existing = await this.getComment(idOrRef, commentId)
|
|
496
|
+
const [comment] = await this.sql<TaskComment[]>`
|
|
497
|
+
UPDATE comments
|
|
498
|
+
SET body = ${body}, updated_at = ${nowIso()}
|
|
499
|
+
WHERE id = ${existing.id}
|
|
500
|
+
RETURNING *
|
|
501
|
+
`
|
|
502
|
+
return comment!
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async getActivity(limit = 100, taskId?: string): Promise<ActivityEntry[]> {
|
|
506
|
+
await this.ready
|
|
507
|
+
if (taskId) {
|
|
508
|
+
return this.sql<ActivityRow[]>`
|
|
509
|
+
SELECT * FROM activity_log
|
|
510
|
+
WHERE task_id = ${taskId}
|
|
511
|
+
ORDER BY timestamp DESC
|
|
512
|
+
LIMIT ${limit}
|
|
513
|
+
`
|
|
514
|
+
}
|
|
515
|
+
return this.sql<ActivityRow[]>`
|
|
516
|
+
SELECT * FROM activity_log
|
|
517
|
+
ORDER BY timestamp DESC
|
|
518
|
+
LIMIT ${limit}
|
|
519
|
+
`
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async getMetrics(): Promise<BoardMetrics> {
|
|
523
|
+
await this.ready
|
|
524
|
+
const [total] = await this.sql<
|
|
525
|
+
{ count: string | number }[]
|
|
526
|
+
>`SELECT COUNT(*) AS count FROM tasks`
|
|
527
|
+
const [doneColumn] = await this.sql<{ id: string }[]>`
|
|
528
|
+
SELECT id FROM columns WHERE LOWER(name) = 'done' LIMIT 1
|
|
529
|
+
`
|
|
530
|
+
const [completed] = doneColumn
|
|
531
|
+
? await this.sql<{ count: string | number }[]>`
|
|
532
|
+
SELECT COUNT(*) AS count FROM tasks WHERE column_id = ${doneColumn.id}
|
|
533
|
+
`
|
|
534
|
+
: [{ count: 0 }]
|
|
535
|
+
const tasksByColumn = await this.sql<{ column_name: string; count: string | number }[]>`
|
|
536
|
+
SELECT columns.name AS column_name, COUNT(tasks.id) AS count
|
|
537
|
+
FROM columns
|
|
538
|
+
LEFT JOIN tasks ON tasks.column_id = columns.id
|
|
539
|
+
GROUP BY columns.id, columns.name, columns.position
|
|
540
|
+
ORDER BY columns.position
|
|
541
|
+
`
|
|
542
|
+
const tasksByPriority = await this.sql<{ priority: string; count: string | number }[]>`
|
|
543
|
+
SELECT priority, COUNT(*) AS count FROM tasks GROUP BY priority ORDER BY priority
|
|
544
|
+
`
|
|
545
|
+
const assignees = await this.discoveredAssignees()
|
|
546
|
+
const projects = await this.discoveredProjects()
|
|
547
|
+
const totalTasks = Number(total?.count ?? 0)
|
|
548
|
+
const completedTasks = Number(completed?.count ?? 0)
|
|
549
|
+
return {
|
|
550
|
+
tasksByColumn: tasksByColumn.map((row) => ({
|
|
551
|
+
column_name: row.column_name,
|
|
552
|
+
count: Number(row.count),
|
|
553
|
+
})),
|
|
554
|
+
tasksByPriority: tasksByPriority.map((row) => ({
|
|
555
|
+
priority: row.priority,
|
|
556
|
+
count: Number(row.count),
|
|
557
|
+
})),
|
|
558
|
+
totalTasks,
|
|
559
|
+
completedTasks,
|
|
560
|
+
avgCompletionHours: null,
|
|
561
|
+
recentActivity: await this.getActivity(10),
|
|
562
|
+
tasksCreatedThisWeek: 0,
|
|
563
|
+
inProgressCount:
|
|
564
|
+
tasksByColumn.find((row) => row.column_name === 'in-progress')?.count === undefined
|
|
565
|
+
? 0
|
|
566
|
+
: Number(tasksByColumn.find((row) => row.column_name === 'in-progress')!.count),
|
|
567
|
+
completionPercent: totalTasks === 0 ? 0 : Math.round((completedTasks / totalTasks) * 100),
|
|
568
|
+
assignees,
|
|
569
|
+
projects,
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private async discoveredAssignees(): Promise<string[]> {
|
|
574
|
+
const rows = await this.sql<{ assignee: string }[]>`
|
|
575
|
+
SELECT DISTINCT assignee FROM tasks WHERE assignee != '' ORDER BY assignee
|
|
576
|
+
`
|
|
577
|
+
return rows.map((row) => row.assignee)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private async discoveredProjects(): Promise<string[]> {
|
|
581
|
+
const rows = await this.sql<{ project: string }[]>`
|
|
582
|
+
SELECT DISTINCT project FROM tasks WHERE project != '' ORDER BY project
|
|
583
|
+
`
|
|
584
|
+
return rows.map((row) => row.project)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async getConfig(): Promise<BoardConfig> {
|
|
588
|
+
await this.ready
|
|
589
|
+
return {
|
|
590
|
+
members: [],
|
|
591
|
+
projects: await this.discoveredProjects(),
|
|
592
|
+
provider: 'local',
|
|
593
|
+
discoveredAssignees: await this.discoveredAssignees(),
|
|
594
|
+
discoveredProjects: await this.discoveredProjects(),
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async patchConfig(input: Partial<BoardConfig>): Promise<BoardConfig> {
|
|
599
|
+
await this.ready
|
|
600
|
+
const config = await this.getConfig()
|
|
601
|
+
return {
|
|
602
|
+
...config,
|
|
603
|
+
members: input.members ?? config.members,
|
|
604
|
+
projects: input.projects ?? config.projects,
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async getSyncStatus(): Promise<ProviderSyncStatus | null> {
|
|
609
|
+
return null
|
|
610
|
+
}
|
|
611
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { handleRequest } from './api'
|
|
4
4
|
import type { ServerWebSocket } from 'bun'
|
|
5
5
|
import type { KanbanProvider } from './providers/types'
|
|
6
|
+
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from './sync-config'
|
|
6
7
|
|
|
7
8
|
const wsClients = new Set<ServerWebSocket<unknown>>()
|
|
8
9
|
const CORS_HEADERS = {
|
|
@@ -10,8 +11,6 @@ const CORS_HEADERS = {
|
|
|
10
11
|
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
|
|
11
12
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
12
13
|
}
|
|
13
|
-
const DEFAULT_BACKGROUND_SYNC_INTERVAL_MS = 30_000
|
|
14
|
-
|
|
15
14
|
interface BackgroundSyncState {
|
|
16
15
|
enabled: boolean
|
|
17
16
|
inFlight: boolean
|
|
@@ -64,7 +63,7 @@ export function startServer(
|
|
|
64
63
|
): StartedServer {
|
|
65
64
|
const distDir = join(import.meta.dir, '..', 'ui', 'dist')
|
|
66
65
|
const hasStatic = existsSync(distDir)
|
|
67
|
-
const syncIntervalMs = opts.syncIntervalMs ??
|
|
66
|
+
const syncIntervalMs = opts.syncIntervalMs ?? DEFAULT_POLLING_SYNC_INTERVAL_MS
|
|
68
67
|
const syncCache = provider.syncCache?.bind(provider)
|
|
69
68
|
const getSyncStatus = provider.getSyncStatus?.bind(provider)
|
|
70
69
|
const backgroundSync: BackgroundSyncState = {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getDbPath } from './db'
|
|
2
|
+
|
|
3
|
+
export type KanbanStorageMode = 'sqlite' | 'postgres'
|
|
4
|
+
|
|
5
|
+
export interface SqliteKanbanStorageConfig {
|
|
6
|
+
mode: 'sqlite'
|
|
7
|
+
sqlitePath: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PostgresKanbanStorageConfig {
|
|
11
|
+
mode: 'postgres'
|
|
12
|
+
databaseUrl: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type KanbanStorageConfig = SqliteKanbanStorageConfig | PostgresKanbanStorageConfig
|
|
16
|
+
|
|
17
|
+
export interface ResolveKanbanStorageOptions {
|
|
18
|
+
defaultSqlitePath?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveKanbanStorageConfig(
|
|
22
|
+
env: Record<string, string | undefined> = process.env,
|
|
23
|
+
options: ResolveKanbanStorageOptions = {},
|
|
24
|
+
): KanbanStorageConfig {
|
|
25
|
+
const rawMode = (env['KANBAN_STORAGE'] ?? 'sqlite').trim().toLowerCase()
|
|
26
|
+
if (rawMode !== 'sqlite' && rawMode !== 'postgres') {
|
|
27
|
+
throw new Error(
|
|
28
|
+
"Unsupported KANBAN_STORAGE '" + rawMode + "'. Expected 'sqlite' or 'postgres'.",
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (rawMode === 'postgres') {
|
|
33
|
+
const databaseUrl = env['KANBAN_DATABASE_URL']?.trim()
|
|
34
|
+
if (!databaseUrl) {
|
|
35
|
+
throw new Error('KANBAN_DATABASE_URL is required when KANBAN_STORAGE=postgres')
|
|
36
|
+
}
|
|
37
|
+
return { mode: 'postgres', databaseUrl }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { mode: 'sqlite', sqlitePath: options.defaultSqlitePath ?? getDbPath() }
|
|
41
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ErrorCode, KanbanError } from './errors'
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_POLLING_SYNC_INTERVAL_MS = 30_000
|
|
4
|
+
export const MIN_POLLING_SYNC_INTERVAL_MS = 1_000
|
|
5
|
+
|
|
6
|
+
export function resolvePollingSyncIntervalMs(
|
|
7
|
+
raw = process.env['KANBAN_SYNC_INTERVAL_MS'],
|
|
8
|
+
opts: { label?: string } = {},
|
|
9
|
+
): number {
|
|
10
|
+
const value = raw?.trim()
|
|
11
|
+
if (!value) return DEFAULT_POLLING_SYNC_INTERVAL_MS
|
|
12
|
+
|
|
13
|
+
const parsed = Number(value)
|
|
14
|
+
if (!Number.isInteger(parsed) || parsed < MIN_POLLING_SYNC_INTERVAL_MS) {
|
|
15
|
+
throw new KanbanError(
|
|
16
|
+
ErrorCode.INVALID_CONFIG,
|
|
17
|
+
`${opts.label ?? 'KANBAN_SYNC_INTERVAL_MS'} must be an integer >= ${MIN_POLLING_SYNC_INTERVAL_MS}`,
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
return parsed
|
|
21
|
+
}
|