@andypai/agent-kanban 0.2.0 → 0.3.1

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.
Files changed (72) hide show
  1. package/README.md +120 -24
  2. package/package.json +4 -2
  3. package/src/__tests__/activity.test.ts +16 -10
  4. package/src/__tests__/api.test.ts +99 -3
  5. package/src/__tests__/board-utils.test.ts +100 -0
  6. package/src/__tests__/commands/board.test.ts +7 -14
  7. package/src/__tests__/commands/bulk.test.ts +3 -3
  8. package/src/__tests__/commands/column.test.ts +4 -4
  9. package/src/__tests__/conflict.test.ts +64 -0
  10. package/src/__tests__/db.test.ts +2 -2
  11. package/src/__tests__/id.test.ts +1 -1
  12. package/src/__tests__/index.test.ts +233 -56
  13. package/src/__tests__/jira-adf.test.ts +180 -0
  14. package/src/__tests__/jira-cache.test.ts +304 -0
  15. package/src/__tests__/jira-client.test.ts +169 -0
  16. package/src/__tests__/jira-provider-comment.test.ts +281 -0
  17. package/src/__tests__/jira-provider-mutations.test.ts +771 -0
  18. package/src/__tests__/jira-provider-read.test.ts +594 -0
  19. package/src/__tests__/jira-wiring.test.ts +187 -0
  20. package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
  21. package/src/__tests__/linear-provider-comment.test.ts +243 -0
  22. package/src/__tests__/linear-provider-sync.test.ts +488 -0
  23. package/src/__tests__/local-provider-comment.test.ts +60 -0
  24. package/src/__tests__/mcp-core.test.ts +164 -0
  25. package/src/__tests__/mcp-server.test.ts +252 -0
  26. package/src/__tests__/metrics.test.ts +2 -2
  27. package/src/__tests__/output.test.ts +1 -1
  28. package/src/__tests__/provider-capabilities.test.ts +40 -0
  29. package/src/__tests__/server.test.ts +291 -0
  30. package/src/__tests__/webhooks.test.ts +604 -0
  31. package/src/activity.ts +2 -12
  32. package/src/api.ts +156 -21
  33. package/src/commands/board.ts +4 -14
  34. package/src/commands/bulk.ts +4 -4
  35. package/src/commands/column.ts +4 -4
  36. package/src/commands/mcp.ts +87 -0
  37. package/src/config.ts +1 -1
  38. package/src/db.ts +118 -6
  39. package/src/errors.ts +2 -0
  40. package/src/id.ts +1 -1
  41. package/src/index.ts +83 -35
  42. package/src/mcp/core.ts +193 -0
  43. package/src/mcp/errors.ts +109 -0
  44. package/src/mcp/index.ts +13 -0
  45. package/src/mcp/server.ts +512 -0
  46. package/src/mcp/types.ts +72 -0
  47. package/src/metrics.ts +1 -1
  48. package/src/output.ts +1 -1
  49. package/src/providers/capabilities.ts +22 -17
  50. package/src/providers/errors.ts +1 -1
  51. package/src/providers/index.ts +36 -6
  52. package/src/providers/jira-adf.ts +275 -0
  53. package/src/providers/jira-cache.ts +625 -0
  54. package/src/providers/jira-client.ts +390 -0
  55. package/src/providers/jira.ts +773 -0
  56. package/src/providers/linear-cache.ts +250 -71
  57. package/src/providers/linear-client.ts +255 -15
  58. package/src/providers/linear.ts +338 -20
  59. package/src/providers/local.ts +74 -23
  60. package/src/providers/types.ts +19 -3
  61. package/src/server.ts +141 -13
  62. package/src/tunnel.ts +79 -0
  63. package/src/types.ts +19 -2
  64. package/src/webhooks.ts +36 -0
  65. package/ui/dist/assets/index-DBnoKL_k.css +1 -0
  66. package/ui/dist/assets/index-qNVJ6clH.js +40 -0
  67. package/ui/dist/index.html +2 -2
  68. package/src/__tests__/commands/task.test.ts +0 -144
  69. package/src/commands/task.ts +0 -117
  70. package/src/fixtures.ts +0 -128
  71. package/ui/dist/assets/index-B8f9NB4z.css +0 -1
  72. package/ui/dist/assets/index-zWp-rB7b.js +0 -40
package/src/api.ts CHANGED
@@ -1,11 +1,19 @@
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'
1
+ import { KanbanError, ErrorCode } from './errors'
2
+ import type { BoardConfig, CliOutput, Task } from './types'
3
+ import type { CreateTaskInput, UpdateTaskInput, KanbanProvider } from './providers/types'
4
+
5
+ export type WsEvent =
6
+ | { type: 'task:upsert'; task: Task; columnName: string }
7
+ | { type: 'task:delete'; id: string }
4
8
 
5
9
  interface MoveTaskBody {
6
10
  column?: string
7
11
  }
8
12
 
13
+ interface CommentBody {
14
+ body?: string
15
+ }
16
+
9
17
  function json(data: unknown, status = 200): Response {
10
18
  return Response.json(data, { status })
11
19
  }
@@ -22,9 +30,15 @@ function missingArgument(field: string): Response {
22
30
  }
23
31
 
24
32
  function statusForCode(code: string): number {
25
- if (code === ErrorCode.TASK_NOT_FOUND || code === ErrorCode.COLUMN_NOT_FOUND) return 404
33
+ if (
34
+ code === ErrorCode.TASK_NOT_FOUND ||
35
+ code === ErrorCode.COLUMN_NOT_FOUND ||
36
+ code === ErrorCode.COMMENT_NOT_FOUND
37
+ )
38
+ return 404
26
39
  if (code === ErrorCode.PROVIDER_AUTH_FAILED) return 401
27
40
  if (code === ErrorCode.PROVIDER_RATE_LIMITED) return 429
41
+ if (code === ErrorCode.CONFLICT) return 409
28
42
  if (code === ErrorCode.UNSUPPORTED_OPERATION) return 400
29
43
  if (code === ErrorCode.PROVIDER_NOT_CONFIGURED) return 500
30
44
  return 400
@@ -53,6 +67,20 @@ async function wrapHandler(fn: () => Promise<CliOutput> | CliOutput): Promise<Re
53
67
  export interface ApiResult {
54
68
  response: Response
55
69
  mutated: boolean
70
+ event?: WsEvent
71
+ }
72
+
73
+ async function resolveColumnName(
74
+ provider: KanbanProvider,
75
+ columnId: string,
76
+ ): Promise<string | null> {
77
+ const columns = await provider.listColumns()
78
+ return columns.find((column) => column.id === columnId)?.name ?? null
79
+ }
80
+
81
+ async function upsertEvent(provider: KanbanProvider, task: Task): Promise<WsEvent | undefined> {
82
+ const columnName = await resolveColumnName(provider, task.column_id)
83
+ return columnName ? { type: 'task:upsert', task, columnName } : undefined
56
84
  }
57
85
 
58
86
  export async function handleRequest(provider: KanbanProvider, req: Request): Promise<ApiResult> {
@@ -109,9 +137,9 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
109
137
  if (path === '/api/tasks' && method === 'POST') {
110
138
  const body = (await req.json()) as Partial<CreateTaskInput>
111
139
  if (!body.title) return { response: missingArgument('title'), mutated: false }
112
- const response = await wrapHandler(async () => ({
113
- ok: true,
114
- data: await provider.createTask({
140
+ let created: Task | null = null
141
+ const response = await wrapHandler(async () => {
142
+ created = await provider.createTask({
115
143
  title: body.title!,
116
144
  description: body.description,
117
145
  column: body.column,
@@ -119,9 +147,11 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
119
147
  assignee: body.assignee,
120
148
  project: body.project,
121
149
  metadata: body.metadata,
122
- }),
123
- }))
124
- return { response, mutated: response.ok }
150
+ })
151
+ return { ok: true, data: created }
152
+ })
153
+ const event = response.ok && created ? await upsertEvent(provider, created) : undefined
154
+ return { response, mutated: response.ok, event }
125
155
  }
126
156
 
127
157
  const taskMatch = path.match(/^\/api\/tasks\/([^/]+)$/)
@@ -137,11 +167,13 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
137
167
 
138
168
  if (method === 'PATCH') {
139
169
  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 }
170
+ let updated: Task | null = null
171
+ const response = await wrapHandler(async () => {
172
+ updated = await provider.updateTask(id, body)
173
+ return { ok: true, data: updated }
174
+ })
175
+ const event = response.ok && updated ? await upsertEvent(provider, updated) : undefined
176
+ return { response, mutated: response.ok, event }
145
177
  }
146
178
 
147
179
  if (method === 'DELETE') {
@@ -149,7 +181,8 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
149
181
  ok: true,
150
182
  data: await provider.deleteTask(id),
151
183
  }))
152
- return { response, mutated: response.ok }
184
+ const event: WsEvent | undefined = response.ok ? { type: 'task:delete', id } : undefined
185
+ return { response, mutated: response.ok, event }
153
186
  }
154
187
  }
155
188
 
@@ -158,11 +191,53 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
158
191
  const id = decodeURIComponent(moveMatch[1]!)
159
192
  const body = (await req.json()) as MoveTaskBody
160
193
  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 }
194
+ let moved: Task | null = null
195
+ const response = await wrapHandler(async () => {
196
+ moved = await provider.moveTask(id, body.column!)
197
+ return { ok: true, data: moved }
198
+ })
199
+ const event = response.ok && moved ? await upsertEvent(provider, moved) : undefined
200
+ return { response, mutated: response.ok, event }
201
+ }
202
+
203
+ const commentsMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments$/)
204
+ if (commentsMatch) {
205
+ const id = decodeURIComponent(commentsMatch[1]!)
206
+ if (method === 'GET') {
207
+ return {
208
+ response: await wrapHandler(async () => ({
209
+ ok: true,
210
+ data: await provider.listComments(id),
211
+ })),
212
+ mutated: false,
213
+ }
214
+ }
215
+
216
+ if (method === 'POST') {
217
+ const body = (await req.json()) as CommentBody
218
+ if (!body.body) return { response: missingArgument('body'), mutated: false }
219
+ const response = await wrapHandler(async () => ({
220
+ ok: true,
221
+ data: await provider.comment(id, body.body!),
222
+ }))
223
+ return { response, mutated: response.ok }
224
+ }
225
+ }
226
+
227
+ const commentMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments\/([^/]+)$/)
228
+ if (commentMatch) {
229
+ const id = decodeURIComponent(commentMatch[1]!)
230
+ const commentId = decodeURIComponent(commentMatch[2]!)
231
+
232
+ if (method === 'PATCH') {
233
+ const body = (await req.json()) as CommentBody
234
+ if (!body.body) return { response: missingArgument('body'), mutated: false }
235
+ const response = await wrapHandler(async () => ({
236
+ ok: true,
237
+ data: await provider.updateComment(id, commentId, body.body!),
238
+ }))
239
+ return { response, mutated: response.ok }
240
+ }
166
241
  }
167
242
 
168
243
  if (path === '/api/activity' && method === 'GET') {
@@ -199,6 +274,66 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
199
274
  return { response, mutated: response.ok }
200
275
  }
201
276
 
277
+ const webhookMatch = path.match(/^\/api\/webhooks\/([^/]+)$/)
278
+ if (webhookMatch && method === 'POST') {
279
+ const target = decodeURIComponent(webhookMatch[1]!)
280
+ if (target !== provider.type) {
281
+ return {
282
+ response: json(
283
+ {
284
+ ok: false,
285
+ error: {
286
+ code: 'UNSUPPORTED_OPERATION',
287
+ message: `Webhook target '${target}' does not match active provider '${provider.type}'`,
288
+ },
289
+ },
290
+ 400,
291
+ ),
292
+ mutated: false,
293
+ }
294
+ }
295
+ if (!provider.handleWebhook) {
296
+ return {
297
+ response: json(
298
+ {
299
+ ok: false,
300
+ error: {
301
+ code: 'UNSUPPORTED_OPERATION',
302
+ message: `Provider '${provider.type}' does not accept webhooks`,
303
+ },
304
+ },
305
+ 400,
306
+ ),
307
+ mutated: false,
308
+ }
309
+ }
310
+ const rawBody = await req.text()
311
+ const headers: Record<string, string> = {}
312
+ req.headers.forEach((value, key) => {
313
+ headers[key] = value
314
+ })
315
+ const result = await provider.handleWebhook({ headers, rawBody })
316
+ if (result.unauthorized) {
317
+ return {
318
+ response: json(
319
+ {
320
+ ok: false,
321
+ error: { code: 'PROVIDER_AUTH_FAILED', message: result.message ?? 'Unauthorized' },
322
+ },
323
+ 401,
324
+ ),
325
+ mutated: false,
326
+ }
327
+ }
328
+ return {
329
+ response: json({
330
+ ok: true,
331
+ data: { handled: result.handled, message: result.message ?? null },
332
+ }),
333
+ mutated: result.handled,
334
+ }
335
+ }
336
+
202
337
  return {
203
338
  response: json(
204
339
  { ok: false, error: { code: 'NOT_FOUND', message: `No route: ${method} ${path}` } },
@@ -1,8 +1,8 @@
1
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'
2
+ import { initSchema, seedDefaultColumns, isInitialized, resetBoard } from '../db'
3
+ import { ErrorCode, KanbanError } from '../errors'
4
+ import { success } from '../output'
5
+ import type { CliOutput } from '../types'
6
6
 
7
7
  export function boardInit(db: Database): CliOutput {
8
8
  if (isInitialized(db)) {
@@ -13,16 +13,6 @@ export function boardInit(db: Database): CliOutput {
13
13
  return success({ message: 'Board initialized with default columns.' })
14
14
  }
15
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
16
  export function boardReset(db: Database): CliOutput {
27
17
  resetBoard(db)
28
18
  return success({ message: 'Board reset. All data cleared and defaults restored.' })
@@ -1,8 +1,8 @@
1
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'
2
+ import { bulkMoveAll, bulkClearDone } from '../db'
3
+ import { ErrorCode, KanbanError } from '../errors'
4
+ import { success } from '../output'
5
+ import type { CliOutput } from '../types'
6
6
 
7
7
  export function bulkMoveAllCmd(db: Database, args: { from?: string; to?: string }): CliOutput {
8
8
  if (!args.from || !args.to) {
@@ -1,8 +1,8 @@
1
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'
2
+ import { addColumn, listColumns, renameColumn, reorderColumn, deleteColumn } from '../db'
3
+ import { ErrorCode, KanbanError } from '../errors'
4
+ import { success } from '../output'
5
+ import type { CliOutput } from '../types'
6
6
 
7
7
  export function columnAdd(
8
8
  db: Database,
@@ -0,0 +1,87 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server'
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ type CallToolResult,
7
+ } from '@modelcontextprotocol/sdk/types.js'
8
+ import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'
9
+ import { createTrackerCore, defaultTools, TrackerMcpError } from '../mcp/index'
10
+ import type { TrackerMcpPolicy, TrackerMcpTool } from '../mcp/index'
11
+ import type { KanbanProvider } from '../providers/types'
12
+
13
+ type LocalScope = Record<string, never>
14
+
15
+ const allowAllPolicy: TrackerMcpPolicy<LocalScope> = {
16
+ canReadTicket() {},
17
+ canPostComment() {},
18
+ canUpdateComment() {},
19
+ canMoveTicket() {},
20
+ }
21
+
22
+ function toCallToolResult(result: unknown): CallToolResult {
23
+ const text = typeof result === 'string' ? result : JSON.stringify(result ?? null)
24
+ return {
25
+ content: [{ type: 'text', text }],
26
+ structuredContent: { result: result ?? null },
27
+ }
28
+ }
29
+
30
+ export async function startStdioMcpServer(provider: KanbanProvider): Promise<void> {
31
+ const core = createTrackerCore<LocalScope>({ provider, policy: allowAllPolicy })
32
+ const scope: LocalScope = {} as LocalScope
33
+ const tools = defaultTools(core)
34
+ const byName = new Map<string, TrackerMcpTool<LocalScope>>(tools.map((tool) => [tool.name, tool]))
35
+ const validators = new AjvJsonSchemaValidator()
36
+ const validateByName = new Map(
37
+ tools.map((tool) => [tool.name, validators.getValidator(tool.inputSchema)]),
38
+ )
39
+
40
+ const server = new Server(
41
+ { name: 'agent-kanban', version: '1.0.0' },
42
+ { capabilities: { tools: {} } },
43
+ )
44
+
45
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
46
+ tools: tools.map((tool) => ({
47
+ name: tool.name,
48
+ description: tool.description,
49
+ inputSchema: tool.inputSchema,
50
+ })),
51
+ }))
52
+
53
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
54
+ const tool = byName.get(request.params.name)
55
+ if (!tool) {
56
+ throw new TrackerMcpError({
57
+ code: 'validation_failed',
58
+ publicMessage: `Unknown tool '${request.params.name}'`,
59
+ })
60
+ }
61
+ const validated = validateByName.get(tool.name)!(request.params.arguments ?? {})
62
+ if (!validated.valid) {
63
+ throw new TrackerMcpError({
64
+ code: 'validation_failed',
65
+ publicMessage: validated.errorMessage ?? `Invalid arguments for '${tool.name}'`,
66
+ })
67
+ }
68
+ const result = await tool.handler({
69
+ scope,
70
+ args: validated.data as Record<string, unknown>,
71
+ })
72
+ return toCallToolResult(result)
73
+ })
74
+
75
+ const transport = new StdioServerTransport()
76
+ await server.connect(transport)
77
+
78
+ const shutdown = async (): Promise<void> => {
79
+ try {
80
+ await server.close()
81
+ } finally {
82
+ process.exit(0)
83
+ }
84
+ }
85
+ process.on('SIGINT', shutdown)
86
+ process.on('SIGTERM', shutdown)
87
+ }
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync, renameSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
- import type { BoardConfig } from './types.ts'
3
+ import type { BoardConfig } from './types'
4
4
 
5
5
  const DEFAULT_CONFIG: BoardConfig = { members: [], projects: [] }
6
6
 
package/src/db.ts CHANGED
@@ -2,10 +2,10 @@ import { Database } from 'bun:sqlite'
2
2
  import { existsSync, mkdirSync } from 'node:fs'
3
3
  import { homedir } from 'node:os'
4
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'
5
+ import { generateId } from './id'
6
+ import { ErrorCode, KanbanError } from './errors'
7
+ import type { BoardView, Column, Priority, Task, TaskComment, TaskWithColumn } from './types'
8
+ import { logActivity, enterColumn, exitColumn } from './activity'
9
9
 
10
10
  const DEFAULT_COLUMNS = [
11
11
  { name: 'recurring', position: 0 },
@@ -64,6 +64,7 @@ export function initSchema(db: Database): void {
64
64
  assignee TEXT NOT NULL DEFAULT '',
65
65
  project TEXT NOT NULL DEFAULT '',
66
66
  metadata TEXT NOT NULL DEFAULT '{}',
67
+ revision INTEGER NOT NULL DEFAULT 0,
67
68
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
68
69
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
69
70
  )
@@ -79,6 +80,16 @@ export function initSchema(db: Database): void {
79
80
  timestamp TEXT NOT NULL DEFAULT (datetime('now'))
80
81
  )
81
82
  `)
83
+ db.run(`
84
+ CREATE TABLE IF NOT EXISTS comments (
85
+ id TEXT PRIMARY KEY,
86
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
87
+ body TEXT NOT NULL,
88
+ author TEXT,
89
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
90
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
91
+ )
92
+ `)
82
93
  db.run(`
83
94
  CREATE TABLE IF NOT EXISTS column_time_tracking (
84
95
  id TEXT PRIMARY KEY,
@@ -97,6 +108,7 @@ export function initSchema(db: Database): void {
97
108
  db.run('CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project)')
98
109
  db.run('CREATE INDEX IF NOT EXISTS idx_activity_task_id ON activity_log(task_id)')
99
110
  db.run('CREATE INDEX IF NOT EXISTS idx_activity_timestamp ON activity_log(timestamp)')
111
+ db.run('CREATE INDEX IF NOT EXISTS idx_comments_task_id ON comments(task_id)')
100
112
  db.run('CREATE INDEX IF NOT EXISTS idx_column_time_task_id ON column_time_tracking(task_id)')
101
113
  }
102
114
 
@@ -112,6 +124,10 @@ export function migrateSchema(db: Database): void {
112
124
  if (!hasProject) {
113
125
  db.run("ALTER TABLE tasks ADD COLUMN project TEXT NOT NULL DEFAULT ''")
114
126
  }
127
+ const hasRevision = columns.some((c) => c.name === 'revision')
128
+ if (!hasRevision) {
129
+ db.run('ALTER TABLE tasks ADD COLUMN revision INTEGER NOT NULL DEFAULT 0')
130
+ }
115
131
  db.run('CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project)')
116
132
  }
117
133
 
@@ -399,7 +415,7 @@ export function updateTask(
399
415
  }
400
416
  }
401
417
 
402
- const sets: string[] = ["updated_at = datetime('now')"]
418
+ const sets: string[] = ["updated_at = datetime('now')", 'revision = revision + 1']
403
419
  const params: Record<string, string> = { $id: id }
404
420
 
405
421
  if (updates.title !== undefined) {
@@ -477,6 +493,101 @@ export function deleteTask(db: Database, id: string): TaskWithColumn {
477
493
  return task
478
494
  }
479
495
 
496
+ export function getComment(db: Database, taskId: string, commentId: string): TaskComment {
497
+ const row = db
498
+ .query(
499
+ `SELECT id, task_id, body, author, created_at, updated_at
500
+ FROM comments
501
+ WHERE id = $id AND task_id = $task_id`,
502
+ )
503
+ .get({
504
+ $id: commentId,
505
+ $task_id: taskId,
506
+ }) as TaskComment | null
507
+ if (!row) {
508
+ throw new KanbanError(
509
+ ErrorCode.COMMENT_NOT_FOUND,
510
+ `No comment '${commentId}' exists on task '${taskId}'`,
511
+ )
512
+ }
513
+ return row
514
+ }
515
+
516
+ export function listComments(db: Database, taskId: string): TaskComment[] {
517
+ getTask(db, taskId)
518
+ return db
519
+ .query(
520
+ `SELECT id, task_id, body, author, created_at, updated_at
521
+ FROM comments
522
+ WHERE task_id = $task_id
523
+ ORDER BY created_at ASC, rowid ASC`,
524
+ )
525
+ .all({ $task_id: taskId }) as TaskComment[]
526
+ }
527
+
528
+ export function countComments(db: Database, taskId: string): number {
529
+ const row = db
530
+ .query('SELECT COUNT(*) as count FROM comments WHERE task_id = $task_id')
531
+ .get({ $task_id: taskId }) as { count: number }
532
+ return row.count
533
+ }
534
+
535
+ export function countCommentsByTask(db: Database): Map<string, number> {
536
+ const rows = db
537
+ .query('SELECT task_id, COUNT(*) as count FROM comments GROUP BY task_id')
538
+ .all() as Array<{ task_id: string; count: number }>
539
+ return new Map(rows.map((row) => [row.task_id, row.count]))
540
+ }
541
+
542
+ export function addComment(
543
+ db: Database,
544
+ taskId: string,
545
+ body: string,
546
+ author: string | null = null,
547
+ ): TaskComment {
548
+ getTask(db, taskId)
549
+ const id = generateId('cm')
550
+ db.query(
551
+ `INSERT INTO comments (id, task_id, body, author)
552
+ VALUES ($id, $task_id, $body, $author)`,
553
+ ).run({
554
+ $id: id,
555
+ $task_id: taskId,
556
+ $body: body,
557
+ $author: author,
558
+ })
559
+ logActivity(db, taskId, 'updated', {
560
+ field: 'comment',
561
+ new_value: body,
562
+ })
563
+ return getComment(db, taskId, id)
564
+ }
565
+
566
+ export function updateComment(
567
+ db: Database,
568
+ taskId: string,
569
+ commentId: string,
570
+ body: string,
571
+ ): TaskComment {
572
+ const existing = getComment(db, taskId, commentId)
573
+ db.query(
574
+ `UPDATE comments
575
+ SET body = $body,
576
+ updated_at = datetime('now')
577
+ WHERE id = $id AND task_id = $task_id`,
578
+ ).run({
579
+ $id: commentId,
580
+ $task_id: taskId,
581
+ $body: body,
582
+ })
583
+ logActivity(db, taskId, 'updated', {
584
+ field: 'comment',
585
+ old_value: existing.body,
586
+ new_value: body,
587
+ })
588
+ return getComment(db, taskId, commentId)
589
+ }
590
+
480
591
  export function moveTask(db: Database, id: string, columnIdOrName: string): TaskWithColumn {
481
592
  const task = getTask(db, id)
482
593
  const column = resolveColumn(db, columnIdOrName)
@@ -487,7 +598,7 @@ export function moveTask(db: Database, id: string, columnIdOrName: string): Task
487
598
 
488
599
  const oldColumnId = task.column_id
489
600
  db.query(
490
- "UPDATE tasks SET column_id = $col, position = $pos, updated_at = datetime('now') WHERE id = $id",
601
+ "UPDATE tasks SET column_id = $col, position = $pos, updated_at = datetime('now'), revision = revision + 1 WHERE id = $id",
491
602
  ).run({ $col: column.id, $pos: maxPos.next, $id: id })
492
603
  renumberTasksInColumn(db, oldColumnId)
493
604
 
@@ -578,6 +689,7 @@ export function bulkClearDone(db: Database): { deleted: number } {
578
689
  }
579
690
 
580
691
  export function resetBoard(db: Database): void {
692
+ db.run('DROP TABLE IF EXISTS comments')
581
693
  db.run('DROP TABLE IF EXISTS column_time_tracking')
582
694
  db.run('DROP TABLE IF EXISTS activity_log')
583
695
  db.run('DROP TABLE IF EXISTS tasks')
package/src/errors.ts CHANGED
@@ -2,12 +2,14 @@ export const ErrorCode = {
2
2
  BOARD_NOT_INITIALIZED: 'BOARD_NOT_INITIALIZED',
3
3
  BOARD_ALREADY_INITIALIZED: 'BOARD_ALREADY_INITIALIZED',
4
4
  TASK_NOT_FOUND: 'TASK_NOT_FOUND',
5
+ COMMENT_NOT_FOUND: 'COMMENT_NOT_FOUND',
5
6
  COLUMN_NOT_FOUND: 'COLUMN_NOT_FOUND',
6
7
  COLUMN_NOT_EMPTY: 'COLUMN_NOT_EMPTY',
7
8
  COLUMN_NAME_EXISTS: 'COLUMN_NAME_EXISTS',
8
9
  INVALID_PRIORITY: 'INVALID_PRIORITY',
9
10
  INVALID_METADATA: 'INVALID_METADATA',
10
11
  INVALID_POSITION: 'INVALID_POSITION',
12
+ CONFLICT: 'CONFLICT',
11
13
  MISSING_ARGUMENT: 'MISSING_ARGUMENT',
12
14
  UNKNOWN_COMMAND: 'UNKNOWN_COMMAND',
13
15
  UNSUPPORTED_OPERATION: 'UNSUPPORTED_OPERATION',
package/src/id.ts CHANGED
@@ -1,4 +1,4 @@
1
- export function generateId(prefix: 't' | 'c' | 'a' | 'ct'): string {
1
+ export function generateId(prefix: 't' | 'c' | 'a' | 'ct' | 'cm'): string {
2
2
  const bytes = new Uint8Array(5)
3
3
  crypto.getRandomValues(bytes)
4
4
  let num = 0n