@andypai/agent-kanban 0.5.1 → 0.6.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 (52) 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-client.test.ts +98 -1
  13. package/src/__tests__/jira-jql.test.ts +51 -0
  14. package/src/__tests__/jira-provider-mutations.test.ts +84 -59
  15. package/src/__tests__/jira-provider-read.test.ts +227 -20
  16. package/src/__tests__/mcp-server.test.ts +2 -2
  17. package/src/__tests__/metrics.test.ts +37 -1
  18. package/src/__tests__/postgres-jira-provider.test.ts +155 -0
  19. package/src/__tests__/postgres-local-provider.test.ts +90 -1
  20. package/src/__tests__/server.test.ts +126 -0
  21. package/src/__tests__/use-cases.test.ts +77 -0
  22. package/src/__tests__/webhooks.test.ts +75 -22
  23. package/src/api.ts +58 -36
  24. package/src/column-roles.ts +52 -0
  25. package/src/commands/board.ts +4 -4
  26. package/src/db.ts +145 -114
  27. package/src/errors.ts +1 -0
  28. package/src/index.ts +84 -33
  29. package/src/mcp/core.ts +8 -7
  30. package/src/metrics.ts +48 -23
  31. package/src/provider-runtime.ts +9 -2
  32. package/src/providers/index.ts +4 -1
  33. package/src/providers/jira-cache.ts +23 -6
  34. package/src/providers/jira-client.ts +70 -4
  35. package/src/providers/jira-core.ts +921 -0
  36. package/src/providers/jira-jql.ts +48 -0
  37. package/src/providers/jira.ts +111 -677
  38. package/src/providers/linear-cache.ts +5 -3
  39. package/src/providers/linear-core.ts +688 -0
  40. package/src/providers/linear.ts +70 -554
  41. package/src/providers/postgres-jira-cache.ts +597 -0
  42. package/src/providers/postgres-jira.ts +15 -1188
  43. package/src/providers/postgres-linear-cache.ts +500 -0
  44. package/src/providers/postgres-linear.ts +22 -1088
  45. package/src/providers/postgres-local.ts +181 -74
  46. package/src/providers/types.ts +71 -2
  47. package/src/server.ts +118 -32
  48. package/src/use-cases.ts +139 -0
  49. package/src/webhooks.ts +26 -0
  50. package/ui/dist/assets/index-65LcV0R7.js +40 -0
  51. package/ui/dist/index.html +1 -1
  52. 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
  }
@@ -45,12 +45,73 @@ export interface JiraIssue {
45
45
  }
46
46
 
47
47
  export interface JiraSearchPage {
48
- startAt: number
49
- maxResults: number
50
- total: number
48
+ // The legacy /rest/api/3/search endpoint returned startAt/maxResults/total.
49
+ // The current /rest/api/3/search/jql endpoint omits `total` and paginates by
50
+ // an opaque `nextPageToken` (with `isLast`), so these are optional now.
51
+ startAt?: number
52
+ maxResults?: number
53
+ total?: number
54
+ nextPageToken?: string
55
+ isLast?: boolean
51
56
  issues: JiraIssue[]
52
57
  }
53
58
 
59
+ export interface JiraPaginationDecision {
60
+ // When set, advance the scan to this cursor. When absent, the scan is over and
61
+ // `complete` says whether it ended definitively (safe to prune against the
62
+ // accumulated issue set) or was cut short (must NOT prune — issues on unfetched
63
+ // pages would be wrongly deleted).
64
+ nextToken?: string
65
+ complete: boolean
66
+ }
67
+
68
+ // Decide how a `/rest/api/3/search/jql` cursor scan should proceed after a page.
69
+ // The endpoint signals continuation with `isLast`/`nextPageToken`, but real and
70
+ // degraded servers vary, so termination must be conservative: a scan is reported
71
+ // `complete` (safe to prune against the accumulated issue set) ONLY when the
72
+ // server proves the end, never from a page-size guess.
73
+ // - usable cursor (fresh `nextPageToken`, isLast !== true) → advance.
74
+ // - isLast === true → definitive end (complete).
75
+ // - isLast === false → more pages exist; if the
76
+ // cursor is missing/stale we
77
+ // cannot fetch them, so the
78
+ // scan is incomplete.
79
+ // - isLast absent, `total` present → legacy total/startAt proof:
80
+ // complete once startAt+count
81
+ // reaches `total`.
82
+ // - isLast absent, no `total`, no usable cursor → NO completeness proof. A
83
+ // short/empty page must NOT be
84
+ // read as "the end": a degraded
85
+ // `{ issues: [] }` would then
86
+ // prune the entire cache on a
87
+ // full reconcile. Treat as
88
+ // incomplete and retry instead.
89
+ // A cursor already in `seenPageTokens` counts as not usable (a stalled cursor
90
+ // that would otherwise loop forever).
91
+ export function decideJiraPagination(
92
+ page: Pick<JiraSearchPage, 'isLast' | 'nextPageToken' | 'issues' | 'total' | 'startAt'>,
93
+ seenPageTokens: ReadonlySet<string>,
94
+ ): JiraPaginationDecision {
95
+ const token = page.nextPageToken
96
+ const canAdvance = !!token && page.isLast !== true && !seenPageTokens.has(token)
97
+ if (canAdvance) return { nextToken: token, complete: false }
98
+ if (page.isLast === true) return { complete: true }
99
+ if (page.isLast === false) return { complete: false }
100
+ // isLast absent below. Prefer the legacy total/startAt signal when a
101
+ // (non-standard) server supplies it: complete once we have fetched everything
102
+ // `total` promises. The live /search/jql endpoint omits `total` and ignores
103
+ // `startAt`, so the loop never advances by offset — a `total` promising more
104
+ // than this page is reported incomplete rather than fetched.
105
+ if (typeof page.total === 'number') {
106
+ const issueCount = page.issues?.length ?? 0
107
+ return { complete: (page.startAt ?? 0) + issueCount >= page.total }
108
+ }
109
+ // No completeness signal at all (no isLast, no total, no usable cursor). We
110
+ // cannot prove the scan reached the end, so never guess `complete` from page
111
+ // size — that would prune the whole cache on a degraded short/empty response.
112
+ return { complete: false }
113
+ }
114
+
54
115
  export interface JiraCreatePayload {
55
116
  fields: Record<string, unknown>
56
117
  }
@@ -254,12 +315,17 @@ export class JiraClient {
254
315
  startAt: number
255
316
  maxResults: number
256
317
  fields?: string[]
318
+ nextPageToken?: string
257
319
  }): Promise<JiraSearchPage> {
258
320
  const query: QueryParams = {
259
321
  jql: params.jql,
260
- startAt: params.startAt,
261
322
  maxResults: params.maxResults,
262
323
  }
324
+ // /rest/api/3/search/jql ignores startAt and paginates by nextPageToken.
325
+ // Send the cursor when we have one; only send startAt on the first page for
326
+ // back-compat with the legacy endpoint.
327
+ if (params.nextPageToken) query.nextPageToken = params.nextPageToken
328
+ else query.startAt = params.startAt
263
329
  if (params.fields && params.fields.length > 0) {
264
330
  query.fields = params.fields.join(',')
265
331
  }