@andypai/agent-kanban 0.6.4 → 0.7.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.
Files changed (45) hide show
  1. package/package.json +18 -3
  2. package/src/__tests__/index.test.ts +27 -1
  3. package/src/__tests__/jira-cache.test.ts +68 -0
  4. package/src/__tests__/jira-provider-read.test.ts +130 -0
  5. package/src/__tests__/jira-wiring.test.ts +41 -0
  6. package/src/__tests__/linear-provider-comment.test.ts +81 -0
  7. package/src/__tests__/linear-provider-sync.test.ts +107 -0
  8. package/src/__tests__/linear-state-resolution.test.ts +60 -0
  9. package/src/__tests__/local-provider-core.test.ts +245 -0
  10. package/src/__tests__/postgres-jira-provider.test.ts +55 -0
  11. package/src/__tests__/postgres-linear-provider.test.ts +90 -0
  12. package/src/__tests__/postgres-local-provider.test.ts +26 -0
  13. package/src/__tests__/provider-sync-core.test.ts +150 -0
  14. package/src/__tests__/provider-team-info.test.ts +37 -0
  15. package/src/__tests__/ui-board-slice.test.ts +14 -0
  16. package/src/__tests__/use-cases.test.ts +21 -42
  17. package/src/api.ts +78 -132
  18. package/src/column-roles.ts +3 -2
  19. package/src/index.ts +135 -92
  20. package/src/mcp/core.ts +7 -8
  21. package/src/provider-runtime.ts +12 -56
  22. package/src/providers/cache-task-mappers.ts +123 -0
  23. package/src/providers/factory.ts +103 -0
  24. package/src/providers/index.ts +4 -25
  25. package/src/providers/jira-cache.ts +41 -99
  26. package/src/providers/jira-core.ts +78 -80
  27. package/src/providers/linear-cache.ts +42 -69
  28. package/src/providers/linear-core.ts +41 -55
  29. package/src/providers/local-core.ts +226 -0
  30. package/src/providers/local.ts +6 -182
  31. package/src/providers/postgres-batch.ts +10 -0
  32. package/src/providers/postgres-jira-cache.ts +255 -213
  33. package/src/providers/postgres-jira.ts +2 -20
  34. package/src/providers/postgres-linear-cache.ts +279 -183
  35. package/src/providers/postgres-linear.ts +2 -20
  36. package/src/providers/postgres-local.ts +22 -47
  37. package/src/providers/sqlite-local-store.ts +121 -0
  38. package/src/providers/sync-core.ts +91 -0
  39. package/src/providers/team-info.ts +24 -0
  40. package/src/providers/warn-once.ts +6 -0
  41. package/src/use-cases.ts +7 -131
  42. package/src/webhook-events.ts +29 -1
  43. package/ui/dist/assets/index-DcZH7fI3.js +40 -0
  44. package/ui/dist/index.html +1 -1
  45. package/ui/dist/assets/index-Cigv8a9S.js +0 -40
package/src/index.ts CHANGED
@@ -10,13 +10,13 @@ import { columnAdd, columnDelete, columnList, columnRename, columnReorder } from
10
10
  import { bulkClearDoneCmd, bulkMoveAllCmd } from './commands/bulk'
11
11
  import { getConfigPath, loadConfig, saveConfig } from './config'
12
12
  import { parsePositiveInt } from './transport-input'
13
- import type { CliOutput, Priority } from './types'
13
+ import type { CliOutput, Priority, ProviderCapabilities } from './types'
14
14
  import { unsupportedOperation } from './providers/errors'
15
15
  import { openKanbanRuntime } from './provider-runtime'
16
16
  import { trackerConfigFromEnv } from './tracker-config'
17
17
  import type { KanbanProvider } from './providers/types'
18
18
  import { resolvePollingSyncIntervalMs } from './sync-config'
19
- import * as useCases from './use-cases'
19
+ import { normalizeCreateTaskInput } from './use-cases'
20
20
 
21
21
  interface ParsedArgs {
22
22
  values: Record<string, unknown>
@@ -24,30 +24,46 @@ interface ParsedArgs {
24
24
  }
25
25
 
26
26
  function parseCliArgs(argv: string[]): ParsedArgs {
27
- return parseArgs({
28
- args: argv,
29
- options: {
30
- pretty: { type: 'boolean', default: false },
31
- db: { type: 'string' },
32
- help: { type: 'boolean', short: 'h', default: false },
33
- d: { type: 'string' },
34
- c: { type: 'string' },
35
- p: { type: 'string' },
36
- a: { type: 'string' },
37
- m: { type: 'string' },
38
- l: { type: 'string' },
39
- label: { type: 'string', multiple: true },
40
- labels: { type: 'string', multiple: true },
41
- sort: { type: 'string' },
42
- title: { type: 'string' },
43
- position: { type: 'string' },
44
- color: { type: 'string' },
45
- project: { type: 'string' },
46
- role: { type: 'string' },
47
- },
48
- strict: false,
49
- allowPositionals: true,
50
- })
27
+ try {
28
+ return parseArgs({
29
+ args: argv,
30
+ options: {
31
+ pretty: { type: 'boolean', default: false },
32
+ db: { type: 'string' },
33
+ help: { type: 'boolean', short: 'h', default: false },
34
+ d: { type: 'string' },
35
+ c: { type: 'string' },
36
+ p: { type: 'string' },
37
+ a: { type: 'string' },
38
+ m: { type: 'string' },
39
+ l: { type: 'string' },
40
+ label: { type: 'string', multiple: true },
41
+ labels: { type: 'string', multiple: true },
42
+ sort: { type: 'string' },
43
+ title: { type: 'string' },
44
+ position: { type: 'string' },
45
+ color: { type: 'string' },
46
+ project: { type: 'string' },
47
+ role: { type: 'string' },
48
+ },
49
+ strict: true,
50
+ allowPositionals: true,
51
+ })
52
+ } catch (err) {
53
+ throw new KanbanError(
54
+ ErrorCode.INVALID_ARGUMENT,
55
+ err instanceof Error ? err.message : String(err),
56
+ )
57
+ }
58
+ }
59
+
60
+ function requireCapability(
61
+ capabilities: ProviderCapabilities,
62
+ capability: keyof ProviderCapabilities,
63
+ feature: string,
64
+ ): void {
65
+ if (!capabilities[capability])
66
+ unsupportedOperation(`${feature} is not supported by this provider`)
51
67
  }
52
68
 
53
69
  function requireLocalProvider(providerType: string, feature: string): void {
@@ -65,21 +81,23 @@ async function routeTask(
65
81
  const title = positionals[2]
66
82
  if (!title) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task title is required')
67
83
  return success(
68
- await useCases.createTask(provider, {
69
- title,
70
- description: values.d as string | undefined,
71
- column: values.c as string | undefined,
72
- priority: values.p as Priority | undefined,
73
- assignee: values.a as string | undefined,
74
- project: values.project as string | undefined,
75
- labels: [values.label, values.labels],
76
- metadata: values.m as string | undefined,
77
- }),
84
+ await provider.createTask(
85
+ normalizeCreateTaskInput({
86
+ title,
87
+ description: values.d as string | undefined,
88
+ column: values.c as string | undefined,
89
+ priority: values.p as Priority | undefined,
90
+ assignee: values.a as string | undefined,
91
+ project: values.project as string | undefined,
92
+ labels: [values.label, values.labels],
93
+ metadata: values.m as string | undefined,
94
+ }),
95
+ ),
78
96
  )
79
97
  }
80
98
  case 'list':
81
99
  return success(
82
- await useCases.listTasks(provider, {
100
+ await provider.listTasks({
83
101
  column: values.c as string | undefined,
84
102
  priority: values.p as string | undefined,
85
103
  assignee: values.a as string | undefined,
@@ -91,13 +109,13 @@ async function routeTask(
91
109
  case 'view': {
92
110
  const id = positionals[2]
93
111
  if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
94
- return success(await useCases.getTask(provider, id))
112
+ return success(await provider.getTask(id))
95
113
  }
96
114
  case 'update': {
97
115
  const id = positionals[2]
98
116
  if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
99
117
  return success(
100
- await useCases.updateTask(provider, id, {
118
+ await provider.updateTask(id, {
101
119
  title: values.title as string | undefined,
102
120
  description: values.d as string | undefined,
103
121
  priority: values.p as Priority | undefined,
@@ -110,7 +128,7 @@ async function routeTask(
110
128
  case 'delete': {
111
129
  const id = positionals[2]
112
130
  if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
113
- return success(await useCases.deleteTask(provider, id))
131
+ return success(await provider.deleteTask(id))
114
132
  }
115
133
  case 'move': {
116
134
  const id = positionals[2]
@@ -118,7 +136,7 @@ async function routeTask(
118
136
  if (!id || !column) {
119
137
  throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Usage: kanban task move <id> <column>')
120
138
  }
121
- return success(await useCases.moveTask(provider, id, column))
139
+ return success(await provider.moveTask(id, column))
122
140
  }
123
141
  case 'assign': {
124
142
  const id = positionals[2]
@@ -129,7 +147,7 @@ async function routeTask(
129
147
  'Usage: kanban task assign <id> <assignee>',
130
148
  )
131
149
  }
132
- return success(await useCases.updateTask(provider, id, { assignee }))
150
+ return success(await provider.updateTask(id, { assignee }))
133
151
  }
134
152
  case 'prioritize': {
135
153
  const id = positionals[2]
@@ -140,7 +158,7 @@ async function routeTask(
140
158
  'Usage: kanban task prioritize <id> <level>',
141
159
  )
142
160
  }
143
- return success(await useCases.updateTask(provider, id, { priority: priority as Priority }))
161
+ return success(await provider.updateTask(id, { priority: priority as Priority }))
144
162
  }
145
163
  default:
146
164
  throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown task command '${action}'`)
@@ -156,7 +174,7 @@ async function routeComment(
156
174
  case 'list': {
157
175
  const id = positionals[2]
158
176
  if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
159
- return success(await useCases.listComments(provider, id))
177
+ return success(await provider.listComments(id))
160
178
  }
161
179
  case 'add': {
162
180
  const id = positionals[2]
@@ -167,7 +185,7 @@ async function routeComment(
167
185
  'Usage: kanban comment add <task-id> <body>',
168
186
  )
169
187
  }
170
- return success(await useCases.addComment(provider, id, body))
188
+ return success(await provider.comment(id, body))
171
189
  }
172
190
  case 'update': {
173
191
  const id = positionals[2]
@@ -179,7 +197,7 @@ async function routeComment(
179
197
  'Usage: kanban comment update <task-id> <comment-id> <body>',
180
198
  )
181
199
  }
182
- return success(await useCases.updateComment(provider, id, commentId, body))
200
+ return success(await provider.updateComment(id, commentId, body))
183
201
  }
184
202
  default:
185
203
  throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown comment command '${action}'`)
@@ -188,12 +206,12 @@ async function routeComment(
188
206
 
189
207
  function routeColumn(
190
208
  db: Database,
191
- providerType: string,
209
+ capabilities: ProviderCapabilities,
192
210
  action: string | undefined,
193
211
  positionals: string[],
194
212
  values: Record<string, unknown>,
195
213
  ): CliOutput {
196
- requireLocalProvider(providerType, 'Column commands')
214
+ requireCapability(capabilities, 'columnCrud', 'Column commands')
197
215
  switch (action) {
198
216
  case 'add':
199
217
  return columnAdd(db, {
@@ -216,11 +234,11 @@ function routeColumn(
216
234
 
217
235
  function routeBulk(
218
236
  db: Database,
219
- providerType: string,
237
+ capabilities: ProviderCapabilities,
220
238
  action: string | undefined,
221
239
  positionals: string[],
222
240
  ): CliOutput {
223
- requireLocalProvider(providerType, 'Bulk commands')
241
+ requireCapability(capabilities, 'bulk', 'Bulk commands')
224
242
  switch (action) {
225
243
  case 'move-all':
226
244
  return bulkMoveAllCmd(db, { from: positionals[2], to: positionals[3] })
@@ -233,25 +251,21 @@ function routeBulk(
233
251
 
234
252
  async function routeConfig(
235
253
  provider: KanbanProvider,
254
+ capabilities: ProviderCapabilities,
236
255
  dbPath: string,
237
256
  action: string | undefined,
238
257
  positionals: string[],
239
258
  values: Record<string, unknown>,
240
259
  ): Promise<CliOutput> {
241
- if (provider.type !== 'local') {
242
- if (action === 'show' || action === undefined) {
243
- return success(await useCases.getConfig(provider))
244
- }
245
- unsupportedOperation('Config mutation is only available in local mode')
260
+ if (action === 'show' || action === undefined) {
261
+ return success(await provider.getConfig())
246
262
  }
263
+ requireCapability(capabilities, 'configEdit', 'Config mutation')
247
264
 
248
265
  const configPath = getConfigPath(dbPath)
249
266
  const config = loadConfig(dbPath)
250
267
 
251
268
  switch (action) {
252
- case 'show':
253
- case undefined:
254
- return success(await useCases.getConfig(provider))
255
269
  case 'set-member': {
256
270
  const name = positionals[2]
257
271
  if (!name) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Member name is required')
@@ -294,7 +308,7 @@ async function routeConfig(
294
308
  }
295
309
  }
296
310
 
297
- async function routeBoard(
311
+ async function routeLocalBoard(
298
312
  db: Database,
299
313
  provider: KanbanProvider,
300
314
  action: string | undefined,
@@ -310,7 +324,7 @@ async function routeBoard(
310
324
  initSchema(db)
311
325
  seedDefaultColumns(db, columnNames)
312
326
  }
313
- return success(await useCases.getBoard(provider))
327
+ return success(await provider.getBoard())
314
328
  case 'reset':
315
329
  requireLocalProvider(provider.type, 'Board reset')
316
330
  return boardReset(db, columnNames)
@@ -332,29 +346,28 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
332
346
  })
333
347
 
334
348
  try {
335
- const { provider, sqliteDb, dbPath, trackerConfig } = runtime
349
+ const { provider, sqliteDb, dbPath, trackerConfig, capabilities } = runtime
336
350
  const group = positionals[0]
337
351
  const action = positionals[1]
338
352
  const defaultColumns =
339
353
  trackerConfig.provider === 'local' ? trackerConfig.defaultColumns : undefined
340
354
 
341
355
  if (!group) {
342
- if (sqliteDb)
356
+ if (sqliteDb && provider.type === 'local')
343
357
  return {
344
- output: await routeBoard(sqliteDb, provider, undefined, defaultColumns),
358
+ output: await routeLocalBoard(sqliteDb, provider, undefined, defaultColumns),
345
359
  exitCode: 0,
346
360
  }
347
- return { output: success(await useCases.getBoard(provider)), exitCode: 0 }
361
+ return { output: success(await provider.getBoard()), exitCode: 0 }
348
362
  }
349
363
 
350
364
  let output: CliOutput
351
365
  switch (group) {
352
366
  case 'board':
353
367
  if (sqliteDb) {
354
- output = await routeBoard(sqliteDb, provider, action, defaultColumns)
368
+ output = await routeLocalBoard(sqliteDb, provider, action, defaultColumns)
355
369
  } else {
356
- if (action === 'view' || action === undefined)
357
- output = success(await useCases.getBoard(provider))
370
+ if (action === 'view' || action === undefined) output = success(await provider.getBoard())
358
371
  else unsupportedOperation(`board ${action} is not available with KANBAN_STORAGE=postgres`)
359
372
  }
360
373
  break
@@ -367,12 +380,12 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
367
380
  case 'column':
368
381
  if (!sqliteDb)
369
382
  unsupportedOperation('Column commands are not available with KANBAN_STORAGE=postgres')
370
- output = routeColumn(sqliteDb, provider.type, action, positionals, values)
383
+ output = routeColumn(sqliteDb, capabilities, action, positionals, values)
371
384
  break
372
385
  case 'bulk':
373
386
  if (!sqliteDb)
374
387
  unsupportedOperation('Bulk commands are not available with KANBAN_STORAGE=postgres')
375
- output = routeBulk(sqliteDb, provider.type, action, positionals)
388
+ output = routeBulk(sqliteDb, capabilities, action, positionals)
376
389
  break
377
390
  case 'config':
378
391
  // routeConfig persists to the SQLite-side config file, so it only runs
@@ -380,10 +393,10 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
380
393
  // repository (configEdit:false), so edits are refused here — matching the
381
394
  // HTTP API, which fails through the provider's patchConfig.
382
395
  if (sqliteDb) {
383
- output = await routeConfig(provider, dbPath, action, positionals, values)
396
+ output = await routeConfig(provider, capabilities, dbPath, action, positionals, values)
384
397
  } else {
385
398
  if (action === 'show' || action === undefined)
386
- output = success(await useCases.getConfig(provider))
399
+ output = success(await provider.getConfig())
387
400
  else
388
401
  unsupportedOperation(`config ${action} is not available with KANBAN_STORAGE=postgres`)
389
402
  }
@@ -455,19 +468,27 @@ export interface ServeOptions {
455
468
  }
456
469
 
457
470
  export function parseServeArgs(argv: string[]): ServeOptions {
458
- const { values } = parseArgs({
459
- args: argv,
460
- options: {
461
- db: { type: 'string' },
462
- port: { type: 'string' },
463
- 'sync-interval-ms': { type: 'string' },
464
- tunnel: { type: 'boolean', default: false },
465
- token: { type: 'string' },
466
- 'allowed-origin': { type: 'string' },
467
- },
468
- strict: false,
469
- allowPositionals: true,
470
- })
471
+ let values: Record<string, unknown>
472
+ try {
473
+ values = parseArgs({
474
+ args: argv,
475
+ options: {
476
+ db: { type: 'string' },
477
+ port: { type: 'string' },
478
+ 'sync-interval-ms': { type: 'string' },
479
+ tunnel: { type: 'boolean', default: false },
480
+ token: { type: 'string' },
481
+ 'allowed-origin': { type: 'string' },
482
+ },
483
+ strict: true,
484
+ allowPositionals: true,
485
+ }).values
486
+ } catch (err) {
487
+ throw new KanbanError(
488
+ ErrorCode.INVALID_ARGUMENT,
489
+ err instanceof Error ? err.message : String(err),
490
+ )
491
+ }
471
492
  const port = values.port
472
493
  ? parseInt(values.port as string, 10)
473
494
  : parseInt(process.env['PORT'] || '3000', 10)
@@ -496,20 +517,42 @@ export interface McpOptions {
496
517
  }
497
518
 
498
519
  export function parseMcpArgs(argv: string[]): McpOptions {
499
- const { values } = parseArgs({
500
- args: argv,
501
- options: { db: { type: 'string' } },
502
- strict: false,
503
- allowPositionals: true,
504
- })
520
+ let values: Record<string, unknown>
521
+ try {
522
+ values = parseArgs({
523
+ args: argv,
524
+ options: { db: { type: 'string' } },
525
+ strict: true,
526
+ allowPositionals: true,
527
+ }).values
528
+ } catch (err) {
529
+ throw new KanbanError(
530
+ ErrorCode.INVALID_ARGUMENT,
531
+ err instanceof Error ? err.message : String(err),
532
+ )
533
+ }
505
534
  return { db: values.db as string | undefined }
506
535
  }
507
536
 
537
+ // Bad serve/mcp flags should print the same structured error envelope as the
538
+ // plain CLI branch, not an uncaught stack trace.
539
+ function parseEntryArgs<T>(parse: () => T): T {
540
+ try {
541
+ return parse()
542
+ } catch (err) {
543
+ if (err instanceof KanbanError) {
544
+ console.error(formatOutput(error(err.code, err.message), false))
545
+ process.exit(1)
546
+ }
547
+ throw err
548
+ }
549
+ }
550
+
508
551
  if (import.meta.main) {
509
552
  const argv = process.argv.slice(2)
510
553
 
511
554
  if (argv[0] === 'mcp') {
512
- const opts = parseMcpArgs(argv)
555
+ const opts = parseEntryArgs(() => parseMcpArgs(argv))
513
556
  const runtime = await openKanbanRuntime({ dbPath: opts.db ?? getDbPath() })
514
557
  const { startStdioMcpServer } = await import('./commands/mcp')
515
558
  try {
@@ -518,7 +561,7 @@ if (import.meta.main) {
518
561
  await runtime.close()
519
562
  }
520
563
  } else if (argv[0] === 'serve') {
521
- const opts = parseServeArgs(argv)
564
+ const opts = parseEntryArgs(() => parseServeArgs(argv))
522
565
 
523
566
  // A tunnel exposes the dashboard publicly, so refuse to start one without a
524
567
  // token. Plain localhost serve stays open for backward compatibility.
package/src/mcp/core.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { KanbanProvider } from '../providers/types'
2
2
  import type { BoardView, Task, TaskComment } from '../types'
3
- import * as useCases from '../use-cases'
4
3
  import { TrackerMcpError, toTrackerMcpError } from './errors'
5
4
  import type { TrackerMcpHooks, TrackerMcpPolicy } from './types'
6
5
 
@@ -138,7 +137,7 @@ export function createTrackerCore<TScope>(input: {
138
137
  ticketId,
139
138
  execute: async () => {
140
139
  await policy.canReadTicket(scope, ticketId)
141
- return useCases.getTask(provider, ticketId)
140
+ return provider.getTask(ticketId)
142
141
  },
143
142
  })
144
143
  },
@@ -150,7 +149,7 @@ export function createTrackerCore<TScope>(input: {
150
149
  ticketId,
151
150
  execute: async () => {
152
151
  await policy.canReadTicket(scope, ticketId)
153
- const comments = await useCases.listComments(provider, ticketId)
152
+ const comments = await provider.listComments(ticketId)
154
153
  return filterComments(scope, comments, policy)
155
154
  },
156
155
  resultMeta: (comments) => ({ commentCount: comments.length }),
@@ -163,7 +162,7 @@ export function createTrackerCore<TScope>(input: {
163
162
  tool: 'getBoard',
164
163
  execute: async () => {
165
164
  await policy.canReadBoard?.(scope)
166
- const board = await useCases.getBoard(provider)
165
+ const board = await provider.getBoard()
167
166
  return filterBoard(scope, board, policy)
168
167
  },
169
168
  resultMeta: (board) => ({
@@ -179,7 +178,7 @@ export function createTrackerCore<TScope>(input: {
179
178
  ticketId,
180
179
  execute: async () => {
181
180
  await policy.canPostComment(scope, ticketId, body)
182
- return useCases.addComment(provider, ticketId, body)
181
+ return provider.comment(ticketId, body)
183
182
  },
184
183
  resultMeta: (comment) => ({ commentId: comment.id }),
185
184
  })
@@ -191,9 +190,9 @@ export function createTrackerCore<TScope>(input: {
191
190
  tool: 'updateComment',
192
191
  ticketId,
193
192
  execute: async () => {
194
- const existing = await useCases.getComment(provider, ticketId, commentId)
193
+ const existing = await provider.getComment(ticketId, commentId)
195
194
  await policy.canUpdateComment(scope, ticketId, existing, body)
196
- return useCases.updateComment(provider, ticketId, commentId, body)
195
+ return provider.updateComment(ticketId, commentId, body)
197
196
  },
198
197
  resultMeta: (comment) => ({ commentId: comment.id }),
199
198
  })
@@ -206,7 +205,7 @@ export function createTrackerCore<TScope>(input: {
206
205
  ticketId,
207
206
  execute: async () => {
208
207
  await policy.canMoveTicket(scope, ticketId, column)
209
- await useCases.moveTask(provider, ticketId, column)
208
+ await provider.moveTask(ticketId, column)
210
209
  },
211
210
  resultMeta: { movedTo: column },
212
211
  })
@@ -2,18 +2,16 @@ import { Database } from 'bun:sqlite'
2
2
  import postgres from 'postgres'
3
3
 
4
4
  import { getDbPath, migrateSchema, openDb } from './db'
5
- import { unsupportedOperation } from './providers/errors'
6
- import { createProvider } from './providers/index'
7
- import { PostgresJiraProvider } from './providers/postgres-jira'
8
- import { PostgresLinearProvider } from './providers/postgres-linear'
9
- import { PostgresLocalProvider } from './providers/postgres-local'
5
+ import { createPostgresProvider, createSqliteProvider } from './providers/factory'
10
6
  import type { KanbanProvider } from './providers/types'
7
+ import type { ProviderCapabilities } from './types'
11
8
  import { resolveKanbanStorageConfig } from './storage-config'
12
9
  import type { KanbanStorageConfig } from './storage-config'
13
10
  import { trackerConfigFromEnv, type TrackerConfig } from './tracker-config'
14
11
 
15
12
  export interface KanbanRuntime {
16
13
  provider: KanbanProvider
14
+ capabilities: ProviderCapabilities
17
15
  dbPath: string
18
16
  trackerConfig: TrackerConfig
19
17
  sqliteDb?: Database
@@ -36,50 +34,12 @@ export async function openKanbanRuntime(
36
34
 
37
35
  if (storage.mode === 'postgres') {
38
36
  const sql = postgres(storage.databaseUrl, { max: 5, onnotice: () => {} })
39
- if (trackerConfig.provider === 'local') {
40
- const provider = new PostgresLocalProvider(sql, trackerConfig)
41
- await provider.initialize()
42
- return {
43
- provider,
44
- dbPath,
45
- trackerConfig,
46
- syncIntervalMs: trackerConfig.syncIntervalMs,
47
- async close() {
48
- await sql.end({ timeout: 1 })
49
- },
50
- }
51
- }
52
- if (trackerConfig.provider === 'jira') {
53
- const provider = new PostgresJiraProvider(sql, {
54
- baseUrl: trackerConfig.baseUrl,
55
- email: trackerConfig.email,
56
- apiToken: trackerConfig.apiToken,
57
- projectKey: trackerConfig.projectKey,
58
- ...(trackerConfig.boardId !== undefined ? { boardId: trackerConfig.boardId } : {}),
59
- defaultIssueType: trackerConfig.defaultIssueType ?? 'Task',
60
- pollingSyncIntervalMs: trackerConfig.syncIntervalMs,
61
- })
62
- await provider.initialize()
63
- return {
64
- provider,
65
- dbPath,
66
- trackerConfig,
67
- syncIntervalMs: trackerConfig.syncIntervalMs,
68
- async close() {
69
- await sql.end({ timeout: 1 })
70
- },
71
- }
72
- }
73
- if (trackerConfig.provider === 'linear') {
74
- const provider = new PostgresLinearProvider(
75
- sql,
76
- trackerConfig.teamId,
77
- trackerConfig.apiKey,
78
- trackerConfig.syncIntervalMs,
79
- )
37
+ try {
38
+ const { provider, capabilities } = createPostgresProvider(sql, trackerConfig)
80
39
  await provider.initialize()
81
40
  return {
82
41
  provider,
42
+ capabilities,
83
43
  dbPath,
84
44
  trackerConfig,
85
45
  syncIntervalMs: trackerConfig.syncIntervalMs,
@@ -87,13 +47,6 @@ export async function openKanbanRuntime(
87
47
  await sql.end({ timeout: 1 })
88
48
  },
89
49
  }
90
- }
91
- try {
92
- const _exhaustive: never = trackerConfig
93
- unsupportedOperation(
94
- `KANBAN_STORAGE=postgres currently supports KANBAN_PROVIDER=local, linear, or jira in agent-kanban.`,
95
- )
96
- void _exhaustive
97
50
  } catch (err) {
98
51
  await sql.end({ timeout: 1 })
99
52
  throw err
@@ -102,10 +55,13 @@ export async function openKanbanRuntime(
102
55
 
103
56
  const db = openDb(storage.sqlitePath)
104
57
  migrateSchema(db)
58
+ const { provider, capabilities } = createSqliteProvider(db, trackerConfig, {
59
+ dbPath: storage.sqlitePath,
60
+ seedLocalColumns: opts.seedLocalColumns,
61
+ })
105
62
  return {
106
- provider: createProvider(db, trackerConfig, storage.sqlitePath, {
107
- seedLocalColumns: opts.seedLocalColumns,
108
- }),
63
+ provider,
64
+ capabilities,
109
65
  dbPath: storage.sqlitePath,
110
66
  trackerConfig,
111
67
  sqliteDb: db,