@andypai/agent-kanban 0.6.0 → 0.6.2

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 (48) hide show
  1. package/README.md +42 -19
  2. package/package.json +3 -2
  3. package/src/__tests__/activity.test.ts +12 -0
  4. package/src/__tests__/api.test.ts +4 -2
  5. package/src/__tests__/board-utils.test.ts +16 -3
  6. package/src/__tests__/column-roles.test.ts +34 -0
  7. package/src/__tests__/commands/board.test.ts +7 -0
  8. package/src/__tests__/conflict.test.ts +11 -1
  9. package/src/__tests__/db.test.ts +70 -0
  10. package/src/__tests__/index.test.ts +55 -0
  11. package/src/__tests__/jira-cache.test.ts +56 -0
  12. package/src/__tests__/jira-jql.test.ts +51 -0
  13. package/src/__tests__/jira-provider-read.test.ts +50 -0
  14. package/src/__tests__/mcp-server.test.ts +2 -2
  15. package/src/__tests__/metrics.test.ts +37 -1
  16. package/src/__tests__/postgres-local-provider.test.ts +90 -1
  17. package/src/__tests__/server.test.ts +126 -0
  18. package/src/__tests__/use-cases.test.ts +77 -0
  19. package/src/__tests__/webhooks.test.ts +91 -22
  20. package/src/api.ts +58 -36
  21. package/src/column-roles.ts +52 -0
  22. package/src/commands/board.ts +4 -4
  23. package/src/db.ts +145 -114
  24. package/src/errors.ts +1 -0
  25. package/src/index.ts +84 -33
  26. package/src/mcp/core.ts +8 -7
  27. package/src/metrics.ts +48 -23
  28. package/src/provider-runtime.ts +9 -2
  29. package/src/providers/index.ts +4 -1
  30. package/src/providers/jira-cache.ts +23 -6
  31. package/src/providers/jira-core.ts +926 -0
  32. package/src/providers/jira-jql.ts +48 -0
  33. package/src/providers/jira.ts +111 -759
  34. package/src/providers/linear-cache.ts +5 -3
  35. package/src/providers/linear-core.ts +693 -0
  36. package/src/providers/linear.ts +70 -554
  37. package/src/providers/postgres-jira-cache.ts +597 -0
  38. package/src/providers/postgres-jira.ts +15 -1312
  39. package/src/providers/postgres-linear-cache.ts +500 -0
  40. package/src/providers/postgres-linear.ts +22 -1088
  41. package/src/providers/postgres-local.ts +181 -74
  42. package/src/providers/types.ts +71 -2
  43. package/src/server.ts +118 -32
  44. package/src/use-cases.ts +139 -0
  45. package/src/webhooks.ts +19 -0
  46. package/ui/dist/assets/index-65LcV0R7.js +40 -0
  47. package/ui/dist/index.html +1 -1
  48. package/ui/dist/assets/index-CFhtfqCn.js +0 -40
package/src/mcp/core.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { KanbanProvider } from '../providers/types'
2
2
  import type { Task, TaskComment } from '../types'
3
+ import * as useCases from '../use-cases'
3
4
  import { TrackerMcpError, toTrackerMcpError } from './errors'
4
5
  import type { TrackerMcpHooks, TrackerMcpPolicy } from './types'
5
6
 
@@ -122,7 +123,7 @@ export function createTrackerCore<TScope>(input: {
122
123
  ticketId,
123
124
  execute: async () => {
124
125
  await policy.canReadTicket(scope, ticketId)
125
- return provider.getTask(ticketId)
126
+ return useCases.getTask(provider, ticketId)
126
127
  },
127
128
  })
128
129
  },
@@ -134,7 +135,7 @@ export function createTrackerCore<TScope>(input: {
134
135
  ticketId,
135
136
  execute: async () => {
136
137
  await policy.canReadTicket(scope, ticketId)
137
- const comments = await provider.listComments(ticketId)
138
+ const comments = await useCases.listComments(provider, ticketId)
138
139
  return filterComments(scope, comments, policy)
139
140
  },
140
141
  resultMeta: (comments) => ({ commentCount: comments.length }),
@@ -145,7 +146,7 @@ export function createTrackerCore<TScope>(input: {
145
146
  return runTool({
146
147
  scope,
147
148
  tool: 'getBoard',
148
- execute: async () => provider.getBoard(),
149
+ execute: async () => useCases.getBoard(provider),
149
150
  })
150
151
  },
151
152
 
@@ -156,7 +157,7 @@ export function createTrackerCore<TScope>(input: {
156
157
  ticketId,
157
158
  execute: async () => {
158
159
  await policy.canPostComment(scope, ticketId, body)
159
- return provider.comment(ticketId, body)
160
+ return useCases.addComment(provider, ticketId, body)
160
161
  },
161
162
  resultMeta: (comment) => ({ commentId: comment.id }),
162
163
  })
@@ -168,9 +169,9 @@ export function createTrackerCore<TScope>(input: {
168
169
  tool: 'updateComment',
169
170
  ticketId,
170
171
  execute: async () => {
171
- const existing = await provider.getComment(ticketId, commentId)
172
+ const existing = await useCases.getComment(provider, ticketId, commentId)
172
173
  await policy.canUpdateComment(scope, ticketId, existing, body)
173
- return provider.updateComment(ticketId, commentId, body)
174
+ return useCases.updateComment(provider, ticketId, commentId, body)
174
175
  },
175
176
  resultMeta: (comment) => ({ commentId: comment.id }),
176
177
  })
@@ -183,7 +184,7 @@ export function createTrackerCore<TScope>(input: {
183
184
  ticketId,
184
185
  execute: async () => {
185
186
  await policy.canMoveTicket(scope, ticketId, column)
186
- await provider.moveTask(ticketId, column)
187
+ await useCases.moveTask(provider, ticketId, column)
187
188
  },
188
189
  resultMeta: { movedTo: column },
189
190
  })
package/src/metrics.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Database } from 'bun:sqlite'
2
2
  import type { ActivityEntry, BoardMetrics } from './types'
3
+ import { selectDoneColumnIds, selectInProgressColumnIds } from './column-roles'
3
4
 
4
5
  function getDistinctTaskFieldValues(db: Database, field: 'assignee' | 'project'): string[] {
5
6
  return (
@@ -22,13 +23,28 @@ export function getDiscoveredProjects(db: Database): string[] {
22
23
  }
23
24
 
24
25
  export function getBoardMetrics(db: Database): BoardMetrics {
25
- const tasksByColumn = db
26
+ const columnCounts = db
26
27
  .query(
27
- `SELECT c.name as column_name, COUNT(t.id) as count
28
+ `SELECT c.id as id, c.name as column_name, c.position as position, COUNT(t.id) as count
28
29
  FROM columns c LEFT JOIN tasks t ON t.column_id = c.id
29
30
  GROUP BY c.id ORDER BY c.position`,
30
31
  )
31
- .all() as { column_name: string; count: number }[]
32
+ .all() as { id: string; column_name: string; position: number; count: number }[]
33
+
34
+ const tasksByColumn = columnCounts.map((row) => ({
35
+ column_name: row.column_name,
36
+ count: row.count,
37
+ }))
38
+
39
+ // Classify columns by role (handles custom names / KANBAN_DEFAULT_COLUMNS)
40
+ // instead of matching the literal strings 'done' / 'in-progress'.
41
+ const columns = columnCounts.map((row) => ({
42
+ id: row.id,
43
+ name: row.column_name,
44
+ position: row.position,
45
+ }))
46
+ const doneColumnIds = new Set(selectDoneColumnIds(columns))
47
+ const inProgressColumnIds = new Set(selectInProgressColumnIds(columns))
32
48
 
33
49
  const tasksByPriority = db
34
50
  .query(
@@ -41,26 +57,37 @@ export function getBoardMetrics(db: Database): BoardMetrics {
41
57
 
42
58
  const totalTasks = getCount(db, 'SELECT COUNT(*) as count FROM tasks')
43
59
 
44
- const completedTasks = getCount(
45
- db,
46
- `SELECT COUNT(*) as count FROM tasks t
47
- JOIN columns c ON t.column_id = c.id WHERE LOWER(c.name) = 'done'`,
48
- )
60
+ const completedTasks = columnCounts
61
+ .filter((row) => doneColumnIds.has(row.id))
62
+ .reduce((sum, row) => sum + row.count, 0)
49
63
 
50
- const avgResult = db
51
- .query(
52
- `SELECT AVG(
53
- (julianday(ct.exited_at) - julianday(first_enter.entered_at)) * 24
64
+ // Average completion time = first time a task was tracked (creation) until the
65
+ // first time it entered a Done column. Measuring the Done *entry* (not exit)
66
+ // means tasks that reach Done and stay there are counted; MIN() over Done rows
67
+ // handles tasks that re-enter Done. Kept in lockstep with the Postgres copy in
68
+ // postgres-local.ts:getMetrics — update both together.
69
+ const donePlaceholders = [...doneColumnIds].map(() => '?').join(', ')
70
+ const avgResult = (
71
+ doneColumnIds.size === 0
72
+ ? { avg_hours: null }
73
+ : (db
74
+ .query(
75
+ `SELECT AVG(
76
+ (julianday(done_enter.entered_at) - julianday(first_enter.entered_at)) * 24
54
77
  ) as avg_hours
55
- FROM column_time_tracking ct
56
- JOIN columns c ON ct.column_id = c.id
78
+ FROM (
79
+ SELECT ct.task_id, MIN(ct.entered_at) as entered_at
80
+ FROM column_time_tracking ct
81
+ WHERE ct.column_id IN (${donePlaceholders})
82
+ GROUP BY ct.task_id
83
+ ) done_enter
57
84
  JOIN (
58
85
  SELECT task_id, MIN(entered_at) as entered_at
59
86
  FROM column_time_tracking GROUP BY task_id
60
- ) first_enter ON first_enter.task_id = ct.task_id
61
- WHERE LOWER(c.name) = 'done' AND ct.exited_at IS NOT NULL`,
62
- )
63
- .get() as { avg_hours: number | null }
87
+ ) first_enter ON first_enter.task_id = done_enter.task_id`,
88
+ )
89
+ .get(...doneColumnIds) as { avg_hours: number | null })
90
+ ) as { avg_hours: number | null }
64
91
 
65
92
  const recentActivity = db
66
93
  .query('SELECT * FROM activity_log ORDER BY timestamp DESC, rowid DESC LIMIT 20')
@@ -71,11 +98,9 @@ export function getBoardMetrics(db: Database): BoardMetrics {
71
98
  "SELECT COUNT(*) as count FROM tasks WHERE created_at >= datetime('now', '-7 days')",
72
99
  )
73
100
 
74
- const inProgressCount = getCount(
75
- db,
76
- `SELECT COUNT(*) as count FROM tasks t
77
- JOIN columns c ON t.column_id = c.id WHERE LOWER(c.name) = 'in-progress'`,
78
- )
101
+ const inProgressCount = columnCounts
102
+ .filter((row) => inProgressColumnIds.has(row.id))
103
+ .reduce((sum, row) => sum + row.count, 0)
79
104
 
80
105
  const completionPercent = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
81
106
 
@@ -22,7 +22,12 @@ export interface KanbanRuntime {
22
22
  }
23
23
 
24
24
  export async function openKanbanRuntime(
25
- opts: { dbPath?: string; storage?: KanbanStorageConfig; tracker?: TrackerConfig } = {},
25
+ opts: {
26
+ dbPath?: string
27
+ storage?: KanbanStorageConfig
28
+ tracker?: TrackerConfig
29
+ seedLocalColumns?: boolean
30
+ } = {},
26
31
  ): Promise<KanbanRuntime> {
27
32
  const dbPath = opts.dbPath ?? getDbPath()
28
33
  const storage =
@@ -98,7 +103,9 @@ export async function openKanbanRuntime(
98
103
  const db = openDb(storage.sqlitePath)
99
104
  migrateSchema(db)
100
105
  return {
101
- provider: createProvider(db, trackerConfig, storage.sqlitePath),
106
+ provider: createProvider(db, trackerConfig, storage.sqlitePath, {
107
+ seedLocalColumns: opts.seedLocalColumns,
108
+ }),
102
109
  dbPath: storage.sqlitePath,
103
110
  trackerConfig,
104
111
  sqliteDb: db,
@@ -10,6 +10,7 @@ export function createProvider(
10
10
  db: Database,
11
11
  config: TrackerConfig,
12
12
  dbPath = getDbPath(),
13
+ options: { seedLocalColumns?: boolean } = {},
13
14
  ): KanbanProvider {
14
15
  if (config.provider === 'linear') {
15
16
  return new LinearProvider(db, config.teamId, config.apiKey, config.syncIntervalMs)
@@ -28,6 +29,8 @@ export function createProvider(
28
29
  }
29
30
 
30
31
  initSchema(db)
31
- seedDefaultColumns(db)
32
+ if (options.seedLocalColumns !== false) {
33
+ seedDefaultColumns(db, config.defaultColumns)
34
+ }
32
35
  return new LocalProvider(db, dbPath)
33
36
  }
@@ -297,12 +297,21 @@ export function replaceJiraColumns(
297
297
  statusIds: string[]
298
298
  source: 'board' | 'status'
299
299
  }>,
300
+ prune = true,
300
301
  ): void {
302
+ // prune=true (full reconcile / direct callers): replace the whole catalog.
303
+ // prune=false (delta sync): upsert the current rows and leave the rest, so a
304
+ // stale delta snapshot never deletes a row a fresher sync just added.
301
305
  const run = db.transaction(() => {
302
- db.run('DELETE FROM jira_columns')
306
+ if (prune) db.run('DELETE FROM jira_columns')
303
307
  const stmt = db.prepare(
304
308
  `INSERT INTO jira_columns (id, name, position, status_ids, source)
305
- VALUES ($id, $name, $position, $status_ids, $source)`,
309
+ VALUES ($id, $name, $position, $status_ids, $source)
310
+ ON CONFLICT(id) DO UPDATE SET
311
+ name = excluded.name,
312
+ position = excluded.position,
313
+ status_ids = excluded.status_ids,
314
+ source = excluded.source`,
306
315
  )
307
316
  for (const column of columns) {
308
317
  stmt.run({
@@ -341,10 +350,14 @@ export function upsertJiraUsers(
341
350
  export function replaceJiraPriorities(
342
351
  db: Database,
343
352
  priorities: Array<{ id: string; name: string }>,
353
+ prune = true,
344
354
  ): void {
345
355
  const run = db.transaction(() => {
346
- db.run('DELETE FROM jira_priorities')
347
- const stmt = db.prepare('INSERT INTO jira_priorities (id, name) VALUES ($id, $name)')
356
+ if (prune) db.run('DELETE FROM jira_priorities')
357
+ const stmt = db.prepare(
358
+ `INSERT INTO jira_priorities (id, name) VALUES ($id, $name)
359
+ ON CONFLICT(id) DO UPDATE SET name = excluded.name`,
360
+ )
348
361
  for (const priority of priorities) {
349
362
  stmt.run({ $id: priority.id, $name: priority.name })
350
363
  }
@@ -355,10 +368,14 @@ export function replaceJiraPriorities(
355
368
  export function replaceJiraIssueTypes(
356
369
  db: Database,
357
370
  types: Array<{ id: string; name: string }>,
371
+ prune = true,
358
372
  ): void {
359
373
  const run = db.transaction(() => {
360
- db.run('DELETE FROM jira_issue_types')
361
- const stmt = db.prepare('INSERT INTO jira_issue_types (id, name) VALUES ($id, $name)')
374
+ if (prune) db.run('DELETE FROM jira_issue_types')
375
+ const stmt = db.prepare(
376
+ `INSERT INTO jira_issue_types (id, name) VALUES ($id, $name)
377
+ ON CONFLICT(id) DO UPDATE SET name = excluded.name`,
378
+ )
362
379
  for (const type of types) {
363
380
  stmt.run({ $id: type.id, $name: type.name })
364
381
  }