@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.
@@ -0,0 +1,129 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import postgres from 'postgres'
3
+
4
+ import { run } from '../index'
5
+ import type { Task, TaskComment, TaskWithColumn } from '../types'
6
+
7
+ const databaseUrl = process.env['KANBAN_PG_TEST_URL'] ?? process.env['DATABASE_URL']
8
+ const pgTest = databaseUrl ? test : test.skip
9
+
10
+ function expectOk<T>(result: Awaited<ReturnType<typeof run>>): T {
11
+ expect(result.exitCode).toBe(0)
12
+ expect(result.output.ok).toBe(true)
13
+ if (!result.output.ok) throw new Error('expected successful CLI output')
14
+ return result.output.data as T
15
+ }
16
+
17
+ describe('postgres local provider', () => {
18
+ let previousStorage: string | undefined
19
+ let previousDatabaseUrl: string | undefined
20
+ let previousProvider: string | undefined
21
+ let previousDbPath: string | undefined
22
+ let previousDefaultColumns: string | undefined
23
+ let sql: postgres.Sql | null = null
24
+
25
+ beforeEach(async () => {
26
+ previousStorage = process.env['KANBAN_STORAGE']
27
+ previousDatabaseUrl = process.env['KANBAN_DATABASE_URL']
28
+ previousProvider = process.env['KANBAN_PROVIDER']
29
+ previousDbPath = process.env['KANBAN_DB_PATH']
30
+ previousDefaultColumns = process.env['KANBAN_DEFAULT_COLUMNS']
31
+
32
+ process.env['KANBAN_STORAGE'] = 'postgres'
33
+ process.env['KANBAN_DATABASE_URL'] = databaseUrl
34
+ process.env['KANBAN_PROVIDER'] = 'local'
35
+ delete process.env['KANBAN_DB_PATH']
36
+
37
+ if (databaseUrl) {
38
+ sql = postgres(databaseUrl, { max: 1, onnotice: () => {} })
39
+ await sql`DROP TABLE IF EXISTS comments`
40
+ await sql`DROP TABLE IF EXISTS column_time_tracking`
41
+ await sql`DROP TABLE IF EXISTS activity_log`
42
+ await sql`DROP TABLE IF EXISTS tasks`
43
+ await sql`DROP TABLE IF EXISTS columns`
44
+ }
45
+ })
46
+
47
+ afterEach(async () => {
48
+ if (sql) {
49
+ await sql.end({ timeout: 1 })
50
+ sql = null
51
+ }
52
+
53
+ if (previousStorage === undefined) delete process.env['KANBAN_STORAGE']
54
+ else process.env['KANBAN_STORAGE'] = previousStorage
55
+ if (previousDatabaseUrl === undefined) delete process.env['KANBAN_DATABASE_URL']
56
+ else process.env['KANBAN_DATABASE_URL'] = previousDatabaseUrl
57
+ if (previousProvider === undefined) delete process.env['KANBAN_PROVIDER']
58
+ else process.env['KANBAN_PROVIDER'] = previousProvider
59
+ if (previousDbPath === undefined) delete process.env['KANBAN_DB_PATH']
60
+ else process.env['KANBAN_DB_PATH'] = previousDbPath
61
+ if (previousDefaultColumns === undefined) delete process.env['KANBAN_DEFAULT_COLUMNS']
62
+ else process.env['KANBAN_DEFAULT_COLUMNS'] = previousDefaultColumns
63
+ })
64
+
65
+ pgTest('runs task and comment commands through Postgres storage', async () => {
66
+ const created = expectOk<TaskWithColumn>(
67
+ await run([
68
+ 'task',
69
+ 'add',
70
+ 'Postgres-backed task',
71
+ '-d',
72
+ 'Stored in Postgres',
73
+ '-c',
74
+ 'recurring',
75
+ '-p',
76
+ 'high',
77
+ '-a',
78
+ 'garage',
79
+ '--project',
80
+ 'Dispatch',
81
+ '-m',
82
+ '{"storage":"postgres"}',
83
+ ]),
84
+ )
85
+
86
+ expect(created.title).toBe('Postgres-backed task')
87
+ expect(created.column_name).toBe('recurring')
88
+ expect(created.version).toBe('0')
89
+
90
+ const listed = expectOk<Task[]>(await run(['task', 'list', '-c', 'recurring']))
91
+ expect(listed.map((task) => task.id)).toContain(created.id)
92
+
93
+ const updated = expectOk<Task>(
94
+ await run(['task', 'update', created.id, '--title', 'Updated from Postgres', '-p', 'urgent']),
95
+ )
96
+ expect(updated.title).toBe('Updated from Postgres')
97
+ expect(updated.priority).toBe('urgent')
98
+ expect(updated.version).toBe('1')
99
+
100
+ const comment = expectOk<TaskComment>(
101
+ await run(['comment', 'add', created.id, 'Projection comment stored in Postgres']),
102
+ )
103
+ expect(comment.task_id).toBe(created.id)
104
+
105
+ const comments = expectOk<TaskComment[]>(await run(['comment', 'list', created.id]))
106
+ expect(comments).toHaveLength(1)
107
+ expect(comments[0]!.body).toBe('Projection comment stored in Postgres')
108
+ })
109
+
110
+ pgTest('seeds custom default columns for Garage local compose', async () => {
111
+ process.env['KANBAN_DEFAULT_COLUMNS'] = 'Todo,In Progress,Human Review,Merging,Done'
112
+
113
+ const created = expectOk<TaskWithColumn>(
114
+ await run(['task', 'add', 'Garage column task', '-c', 'Todo']),
115
+ )
116
+
117
+ expect(created.column_name).toBe('Todo')
118
+ })
119
+
120
+ pgTest('defaults new tasks to the first configured column when backlog is absent', async () => {
121
+ process.env['KANBAN_DEFAULT_COLUMNS'] = 'Todo,In Progress,Human Review,Merging,Done'
122
+
123
+ const created = expectOk<TaskWithColumn>(
124
+ await run(['task', 'add', 'Garage default column task']),
125
+ )
126
+
127
+ expect(created.column_name).toBe('Todo')
128
+ })
129
+ })
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { resolveKanbanStorageConfig } from '../storage-config'
4
+
5
+ describe('resolveKanbanStorageConfig', () => {
6
+ test('defaults to sqlite using the resolved board path', () => {
7
+ const config = resolveKanbanStorageConfig(
8
+ { HOME: '/tmp/kanban-home' },
9
+ { defaultSqlitePath: '/tmp/board.db' },
10
+ )
11
+
12
+ expect(config).toEqual({ mode: 'sqlite', sqlitePath: '/tmp/board.db' })
13
+ })
14
+
15
+ test('accepts uppercase postgres mode and requires a database URL', () => {
16
+ expect(() =>
17
+ resolveKanbanStorageConfig({ KANBAN_STORAGE: 'POSTGRES' }, { defaultSqlitePath: 'ignored' }),
18
+ ).toThrow('KANBAN_DATABASE_URL is required when KANBAN_STORAGE=postgres')
19
+
20
+ expect(
21
+ resolveKanbanStorageConfig(
22
+ {
23
+ KANBAN_STORAGE: 'POSTGRES',
24
+ KANBAN_DATABASE_URL: 'postgres://garage:garage@localhost:5432/garage',
25
+ },
26
+ { defaultSqlitePath: 'ignored' },
27
+ ),
28
+ ).toEqual({
29
+ mode: 'postgres',
30
+ databaseUrl: 'postgres://garage:garage@localhost:5432/garage',
31
+ })
32
+ })
33
+
34
+ test('rejects unknown storage modes', () => {
35
+ expect(() =>
36
+ resolveKanbanStorageConfig({ KANBAN_STORAGE: 'mysql' }, { defaultSqlitePath: 'ignored' }),
37
+ ).toThrow("Unsupported KANBAN_STORAGE 'mysql'")
38
+ })
39
+ })
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { ErrorCode, KanbanError } from '../errors'
3
+ import {
4
+ DEFAULT_POLLING_SYNC_INTERVAL_MS,
5
+ MIN_POLLING_SYNC_INTERVAL_MS,
6
+ resolvePollingSyncIntervalMs,
7
+ } from '../sync-config'
8
+
9
+ describe('sync config', () => {
10
+ test('defaults when unset or blank', () => {
11
+ expect(resolvePollingSyncIntervalMs(undefined)).toBe(DEFAULT_POLLING_SYNC_INTERVAL_MS)
12
+ expect(resolvePollingSyncIntervalMs(' ')).toBe(DEFAULT_POLLING_SYNC_INTERVAL_MS)
13
+ })
14
+
15
+ test('accepts an integer millisecond interval', () => {
16
+ expect(resolvePollingSyncIntervalMs('5000')).toBe(5_000)
17
+ })
18
+
19
+ test('rejects invalid or too-aggressive intervals', () => {
20
+ for (const raw of ['999', '5s', '1000.5', '0']) {
21
+ let err: unknown
22
+ try {
23
+ resolvePollingSyncIntervalMs(raw)
24
+ } catch (caught) {
25
+ err = caught
26
+ }
27
+ expect(err).toBeInstanceOf(KanbanError)
28
+ expect((err as KanbanError).code).toBe(ErrorCode.INVALID_CONFIG)
29
+ expect((err as Error).message).toContain(String(MIN_POLLING_SYNC_INTERVAL_MS))
30
+ }
31
+ })
32
+ })
package/src/errors.ts CHANGED
@@ -9,6 +9,7 @@ export const ErrorCode = {
9
9
  INVALID_PRIORITY: 'INVALID_PRIORITY',
10
10
  INVALID_METADATA: 'INVALID_METADATA',
11
11
  INVALID_POSITION: 'INVALID_POSITION',
12
+ INVALID_CONFIG: 'INVALID_CONFIG',
12
13
  CONFLICT: 'CONFLICT',
13
14
  MISSING_ARGUMENT: 'MISSING_ARGUMENT',
14
15
  UNKNOWN_COMMAND: 'UNKNOWN_COMMAND',
package/src/index.ts CHANGED
@@ -4,14 +4,17 @@ import { parseArgs } from 'node:util'
4
4
  import { Database } from 'bun:sqlite'
5
5
  import { KanbanError, ErrorCode } from './errors'
6
6
  import { formatOutput, error, success } from './output'
7
- import { openDb, getDbPath, initSchema, migrateSchema, seedDefaultColumns } from './db'
7
+ import { getDbPath, initSchema, seedDefaultColumns } from './db'
8
8
  import { boardInit, boardReset } from './commands/board'
9
9
  import { columnAdd, columnDelete, columnList, columnRename, columnReorder } from './commands/column'
10
10
  import { bulkClearDoneCmd, bulkMoveAllCmd } from './commands/bulk'
11
11
  import { getConfigPath, loadConfig, saveConfig } from './config'
12
12
  import type { CliOutput, Priority } from './types'
13
- import { createProvider } from './providers/index'
14
13
  import { unsupportedOperation } from './providers/errors'
14
+ import { openKanbanRuntime } from './provider-runtime'
15
+ import { trackerConfigFromEnv } from './tracker-config'
16
+ import type { KanbanProvider } from './providers/types'
17
+ import { resolvePollingSyncIntervalMs } from './sync-config'
15
18
 
16
19
  interface ParsedArgs {
17
20
  values: Record<string, unknown>
@@ -48,7 +51,7 @@ function requireLocalProvider(providerType: string, feature: string): void {
48
51
  }
49
52
 
50
53
  async function routeTask(
51
- provider: ReturnType<typeof createProvider>,
54
+ provider: KanbanProvider,
52
55
  action: string | undefined,
53
56
  positionals: string[],
54
57
  values: Record<string, unknown>,
@@ -140,7 +143,7 @@ async function routeTask(
140
143
  }
141
144
 
142
145
  async function routeComment(
143
- provider: ReturnType<typeof createProvider>,
146
+ provider: KanbanProvider,
144
147
  action: string | undefined,
145
148
  positionals: string[],
146
149
  ): Promise<CliOutput> {
@@ -224,7 +227,7 @@ function routeBulk(
224
227
  }
225
228
 
226
229
  async function routeConfig(
227
- provider: ReturnType<typeof createProvider>,
230
+ provider: KanbanProvider,
228
231
  dbPath: string,
229
232
  action: string | undefined,
230
233
  positionals: string[],
@@ -288,7 +291,7 @@ async function routeConfig(
288
291
 
289
292
  async function routeBoard(
290
293
  db: Database,
291
- provider: ReturnType<typeof createProvider>,
294
+ provider: KanbanProvider,
292
295
  action: string | undefined,
293
296
  ): Promise<CliOutput> {
294
297
  switch (action) {
@@ -316,23 +319,29 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
316
319
  return { output: { ok: true, data: { message: HELP_TEXT } }, exitCode: 0 }
317
320
  }
318
321
 
319
- const dbPath = (values.db as string | undefined) ?? getDbPath()
320
- const db = openDb(dbPath)
321
- migrateSchema(db)
322
+ const runtime = await openKanbanRuntime({
323
+ dbPath: (values.db as string | undefined) ?? getDbPath(),
324
+ })
322
325
 
323
326
  try {
324
- const provider = createProvider(db, dbPath)
327
+ const { provider, sqliteDb, dbPath } = runtime
325
328
  const group = positionals[0]
326
329
  const action = positionals[1]
327
330
 
328
331
  if (!group) {
329
- return { output: await routeBoard(db, provider, undefined), exitCode: 0 }
332
+ if (sqliteDb) return { output: await routeBoard(sqliteDb, provider, undefined), exitCode: 0 }
333
+ return { output: success(await provider.getBoard()), exitCode: 0 }
330
334
  }
331
335
 
332
336
  let output: CliOutput
333
337
  switch (group) {
334
338
  case 'board':
335
- output = await routeBoard(db, provider, action)
339
+ if (sqliteDb) {
340
+ output = await routeBoard(sqliteDb, provider, action)
341
+ } else {
342
+ if (action === 'view' || action === undefined) output = success(await provider.getBoard())
343
+ else unsupportedOperation(`board ${action} is not available with KANBAN_STORAGE=postgres`)
344
+ }
336
345
  break
337
346
  case 'task':
338
347
  output = await routeTask(provider, action, positionals, values)
@@ -341,13 +350,24 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
341
350
  output = await routeComment(provider, action, positionals)
342
351
  break
343
352
  case 'column':
344
- output = routeColumn(db, provider.type, action, positionals, values)
353
+ if (!sqliteDb)
354
+ unsupportedOperation('Column commands are not available with KANBAN_STORAGE=postgres')
355
+ output = routeColumn(sqliteDb, provider.type, action, positionals, values)
345
356
  break
346
357
  case 'bulk':
347
- output = routeBulk(db, provider.type, action, positionals)
358
+ if (!sqliteDb)
359
+ unsupportedOperation('Bulk commands are not available with KANBAN_STORAGE=postgres')
360
+ output = routeBulk(sqliteDb, provider.type, action, positionals)
348
361
  break
349
362
  case 'config':
350
- output = await routeConfig(provider, dbPath, action, positionals, values)
363
+ if (sqliteDb) {
364
+ output = await routeConfig(provider, dbPath, action, positionals, values)
365
+ } else {
366
+ if (action === 'show' || action === undefined)
367
+ output = success(await provider.getConfig())
368
+ else
369
+ unsupportedOperation(`config ${action} is not available with KANBAN_STORAGE=postgres`)
370
+ }
351
371
  break
352
372
  default:
353
373
  throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown command group '${group}'`)
@@ -355,7 +375,7 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
355
375
 
356
376
  return { output, exitCode: 0 }
357
377
  } finally {
358
- db.close()
378
+ await runtime.close()
359
379
  }
360
380
  }
361
381
 
@@ -397,18 +417,19 @@ Commands:
397
417
  config add-project <name> Add project
398
418
  config remove-project <name> Remove project
399
419
 
400
- serve Start web dashboard [--port 3000]
420
+ serve Start web dashboard [--port 3000] [--sync-interval-ms ms]
401
421
  mcp Run as an MCP server over stdio (for Claude Desktop, etc.)
402
422
 
403
423
  Options:
404
424
  --pretty Human-readable output (default: JSON)
405
- --db <path> Database path (default: local ./.kanban if present, else ~/.kanban if present, else create ./.kanban)
425
+ --db <path> SQLite database path (default: local ./.kanban if present, else ~/.kanban if present, else create ./.kanban)
406
426
  --project <n> Filter/set project
407
427
  -h, --help Show this help`
408
428
 
409
429
  export interface ServeOptions {
410
430
  db?: string
411
431
  port: number
432
+ syncIntervalMs?: number
412
433
  tunnel: boolean
413
434
  }
414
435
 
@@ -418,6 +439,7 @@ export function parseServeArgs(argv: string[]): ServeOptions {
418
439
  options: {
419
440
  db: { type: 'string' },
420
441
  port: { type: 'string' },
442
+ 'sync-interval-ms': { type: 'string' },
421
443
  tunnel: { type: 'boolean', default: false },
422
444
  },
423
445
  strict: false,
@@ -429,10 +451,17 @@ export function parseServeArgs(argv: string[]): ServeOptions {
429
451
  return {
430
452
  db: values.db as string | undefined,
431
453
  port,
454
+ ...(values['sync-interval-ms']
455
+ ? { syncIntervalMs: parseSyncIntervalMs(values['sync-interval-ms'] as string) }
456
+ : {}),
432
457
  tunnel: Boolean(values.tunnel),
433
458
  }
434
459
  }
435
460
 
461
+ function parseSyncIntervalMs(raw: string): number {
462
+ return resolvePollingSyncIntervalMs(raw, { label: '--sync-interval-ms' })
463
+ }
464
+
436
465
  export interface McpOptions {
437
466
  db?: string
438
467
  }
@@ -452,21 +481,29 @@ if (import.meta.main) {
452
481
 
453
482
  if (argv[0] === 'mcp') {
454
483
  const opts = parseMcpArgs(argv)
455
- const dbPath = opts.db ?? getDbPath()
456
- const db = openDb(dbPath)
457
- migrateSchema(db)
458
- const provider = createProvider(db, dbPath)
484
+ const runtime = await openKanbanRuntime({ dbPath: opts.db ?? getDbPath() })
459
485
  const { startStdioMcpServer } = await import('./commands/mcp')
460
- await startStdioMcpServer(provider)
486
+ try {
487
+ await startStdioMcpServer(runtime.provider)
488
+ } finally {
489
+ await runtime.close()
490
+ }
461
491
  } else if (argv[0] === 'serve') {
462
492
  const opts = parseServeArgs(argv)
463
493
 
464
- const dbPath = opts.db ?? getDbPath()
465
- const db = openDb(dbPath)
466
- migrateSchema(db)
467
- const provider = createProvider(db, dbPath)
494
+ const runtime = await openKanbanRuntime({
495
+ dbPath: opts.db ?? getDbPath(),
496
+ ...(opts.syncIntervalMs !== undefined
497
+ ? {
498
+ tracker: {
499
+ ...trackerConfigFromEnv(process.env),
500
+ syncIntervalMs: opts.syncIntervalMs,
501
+ },
502
+ }
503
+ : {}),
504
+ })
468
505
  const { startServer } = await import('./server')
469
- startServer(provider, opts.port)
506
+ startServer(runtime.provider, opts.port, { syncIntervalMs: runtime.syncIntervalMs })
470
507
 
471
508
  if (opts.tunnel) {
472
509
  const { startCloudflareTunnel } = await import('./tunnel')
package/src/mcp/errors.ts CHANGED
@@ -39,6 +39,7 @@ function providerError(code: ErrorCodeValue): TrackerMcpErrorCode {
39
39
  case ErrorCode.INVALID_METADATA:
40
40
  case ErrorCode.INVALID_POSITION:
41
41
  case ErrorCode.INVALID_PRIORITY:
42
+ case ErrorCode.INVALID_CONFIG:
42
43
  case ErrorCode.MISSING_ARGUMENT:
43
44
  case ErrorCode.UNSUPPORTED_OPERATION:
44
45
  case ErrorCode.CONFLICT:
@@ -0,0 +1,110 @@
1
+ import { Database } from 'bun:sqlite'
2
+ import postgres from 'postgres'
3
+
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'
10
+ import type { KanbanProvider } from './providers/types'
11
+ import { resolveKanbanStorageConfig } from './storage-config'
12
+ import type { KanbanStorageConfig } from './storage-config'
13
+ import { trackerConfigFromEnv, type TrackerConfig } from './tracker-config'
14
+
15
+ export interface KanbanRuntime {
16
+ provider: KanbanProvider
17
+ dbPath: string
18
+ trackerConfig: TrackerConfig
19
+ sqliteDb?: Database
20
+ syncIntervalMs?: number
21
+ close(): Promise<void>
22
+ }
23
+
24
+ export async function openKanbanRuntime(
25
+ opts: { dbPath?: string; storage?: KanbanStorageConfig; tracker?: TrackerConfig } = {},
26
+ ): Promise<KanbanRuntime> {
27
+ const dbPath = opts.dbPath ?? getDbPath()
28
+ const storage =
29
+ opts.storage ?? resolveKanbanStorageConfig(process.env, { defaultSqlitePath: dbPath })
30
+ const trackerConfig = opts.tracker ?? trackerConfigFromEnv(process.env)
31
+
32
+ if (storage.mode === 'postgres') {
33
+ const sql = postgres(storage.databaseUrl, { max: 5, onnotice: () => {} })
34
+ if (trackerConfig.provider === 'local') {
35
+ const provider = new PostgresLocalProvider(sql, trackerConfig)
36
+ await provider.initialize()
37
+ return {
38
+ provider,
39
+ dbPath,
40
+ trackerConfig,
41
+ syncIntervalMs: trackerConfig.syncIntervalMs,
42
+ async close() {
43
+ await sql.end({ timeout: 1 })
44
+ },
45
+ }
46
+ }
47
+ if (trackerConfig.provider === 'jira') {
48
+ const provider = new PostgresJiraProvider(sql, {
49
+ baseUrl: trackerConfig.baseUrl,
50
+ email: trackerConfig.email,
51
+ apiToken: trackerConfig.apiToken,
52
+ projectKey: trackerConfig.projectKey,
53
+ ...(trackerConfig.boardId !== undefined ? { boardId: trackerConfig.boardId } : {}),
54
+ defaultIssueType: trackerConfig.defaultIssueType ?? 'Task',
55
+ pollingSyncIntervalMs: trackerConfig.syncIntervalMs,
56
+ })
57
+ await provider.initialize()
58
+ return {
59
+ provider,
60
+ dbPath,
61
+ trackerConfig,
62
+ syncIntervalMs: trackerConfig.syncIntervalMs,
63
+ async close() {
64
+ await sql.end({ timeout: 1 })
65
+ },
66
+ }
67
+ }
68
+ if (trackerConfig.provider === 'linear') {
69
+ const provider = new PostgresLinearProvider(
70
+ sql,
71
+ trackerConfig.teamId,
72
+ trackerConfig.apiKey,
73
+ trackerConfig.syncIntervalMs,
74
+ )
75
+ await provider.initialize()
76
+ return {
77
+ provider,
78
+ dbPath,
79
+ trackerConfig,
80
+ syncIntervalMs: trackerConfig.syncIntervalMs,
81
+ async close() {
82
+ await sql.end({ timeout: 1 })
83
+ },
84
+ }
85
+ }
86
+ try {
87
+ const _exhaustive: never = trackerConfig
88
+ unsupportedOperation(
89
+ `KANBAN_STORAGE=postgres currently supports KANBAN_PROVIDER=local, linear, or jira in agent-kanban.`,
90
+ )
91
+ void _exhaustive
92
+ } catch (err) {
93
+ await sql.end({ timeout: 1 })
94
+ throw err
95
+ }
96
+ }
97
+
98
+ const db = openDb(storage.sqlitePath)
99
+ migrateSchema(db)
100
+ return {
101
+ provider: createProvider(db, trackerConfig, storage.sqlitePath),
102
+ dbPath: storage.sqlitePath,
103
+ trackerConfig,
104
+ sqliteDb: db,
105
+ syncIntervalMs: trackerConfig.syncIntervalMs,
106
+ async close() {
107
+ db.close()
108
+ },
109
+ }
110
+ }
@@ -1,50 +1,29 @@
1
1
  import type { Database } from 'bun:sqlite'
2
2
  import { getDbPath, initSchema, seedDefaultColumns } from '../db'
3
- import { providerNotConfigured } from './errors'
4
3
  import { JiraProvider } from './jira'
5
4
  import { LinearProvider } from './linear'
6
5
  import { LocalProvider } from './local'
6
+ import type { TrackerConfig } from '../tracker-config'
7
7
  import type { KanbanProvider } from './types'
8
8
 
9
- export function createProvider(db: Database, dbPath = getDbPath()): KanbanProvider {
10
- const providerType = (process.env['KANBAN_PROVIDER'] ?? 'local') as 'local' | 'linear' | 'jira'
11
-
12
- if (providerType === 'linear') {
13
- const apiKey = process.env['LINEAR_API_KEY']
14
- const teamId = process.env['LINEAR_TEAM_ID']
15
- if (!apiKey || !teamId) {
16
- providerNotConfigured(
17
- 'LINEAR_API_KEY and LINEAR_TEAM_ID are required when KANBAN_PROVIDER=linear',
18
- )
19
- }
20
- return new LinearProvider(db, teamId!, apiKey!)
9
+ export function createProvider(
10
+ db: Database,
11
+ config: TrackerConfig,
12
+ dbPath = getDbPath(),
13
+ ): KanbanProvider {
14
+ if (config.provider === 'linear') {
15
+ return new LinearProvider(db, config.teamId, config.apiKey, config.syncIntervalMs)
21
16
  }
22
17
 
23
- if (providerType === 'jira') {
24
- const baseUrl = process.env['JIRA_BASE_URL']
25
- const email = process.env['JIRA_EMAIL']
26
- const apiToken = process.env['JIRA_API_TOKEN']
27
- const projectKey = process.env['JIRA_PROJECT_KEY']
28
- const missing: string[] = []
29
- if (!baseUrl) missing.push('JIRA_BASE_URL')
30
- if (!email) missing.push('JIRA_EMAIL')
31
- if (!apiToken) missing.push('JIRA_API_TOKEN')
32
- if (!projectKey) missing.push('JIRA_PROJECT_KEY')
33
- if (missing.length > 0) {
34
- providerNotConfigured(
35
- `${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} required when KANBAN_PROVIDER=jira`,
36
- )
37
- }
38
- const boardIdRaw = process.env['JIRA_BOARD_ID']
39
- const boardId = boardIdRaw ? Number.parseInt(boardIdRaw, 10) : undefined
40
- const defaultIssueType = process.env['JIRA_ISSUE_TYPE'] ?? 'Task'
18
+ if (config.provider === 'jira') {
41
19
  return new JiraProvider(db, {
42
- baseUrl: baseUrl!,
43
- email: email!,
44
- apiToken: apiToken!,
45
- projectKey: projectKey!,
46
- boardId: Number.isFinite(boardId) ? boardId : undefined,
47
- defaultIssueType,
20
+ baseUrl: config.baseUrl,
21
+ email: config.email,
22
+ apiToken: config.apiToken,
23
+ projectKey: config.projectKey,
24
+ ...(config.boardId !== undefined ? { boardId: config.boardId } : {}),
25
+ defaultIssueType: config.defaultIssueType ?? 'Task',
26
+ pollingSyncIntervalMs: config.syncIntervalMs,
48
27
  })
49
28
  }
50
29
 
@@ -49,8 +49,8 @@ import type {
49
49
  TaskListFilters,
50
50
  UpdateTaskInput,
51
51
  } from './types'
52
+ import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
52
53
 
53
- const SYNC_INTERVAL_MS = 30_000
54
54
  const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
55
55
 
56
56
  function shouldRunFullReconcile(lastFullSyncAt: string | null, now: number): boolean {
@@ -78,11 +78,13 @@ export interface JiraProviderConfig {
78
78
  projectKey: string
79
79
  boardId?: number
80
80
  defaultIssueType?: string
81
+ pollingSyncIntervalMs?: number
81
82
  }
82
83
 
83
84
  export class JiraProvider implements KanbanProvider {
84
85
  readonly type = 'jira' as const
85
86
  private readonly client: JiraClient
87
+ private readonly pollingSyncIntervalMs: number
86
88
 
87
89
  constructor(
88
90
  private readonly db: Database,
@@ -90,6 +92,7 @@ export class JiraProvider implements KanbanProvider {
90
92
  client?: JiraClient,
91
93
  ) {
92
94
  initJiraCacheSchema(db)
95
+ this.pollingSyncIntervalMs = config.pollingSyncIntervalMs ?? DEFAULT_POLLING_SYNC_INTERVAL_MS
93
96
  this.client =
94
97
  client ??
95
98
  new JiraClient({
@@ -103,7 +106,7 @@ export class JiraProvider implements KanbanProvider {
103
106
  const meta = loadJiraSyncMeta(this.db)
104
107
  const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
105
108
  const now = Date.now()
106
- if (!force && lastSyncAtMs && now - lastSyncAtMs < SYNC_INTERVAL_MS) return
109
+ if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
107
110
  const fullReconcile = force || shouldRunFullReconcile(meta.lastFullSyncAt, now)
108
111
 
109
112
  // 1. Resolve project.
@@ -41,8 +41,8 @@ import type {
41
41
  TaskListFilters,
42
42
  UpdateTaskInput,
43
43
  } from './types'
44
+ import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
44
45
 
45
- const SYNC_INTERVAL_MS = 30_000
46
46
  const FULL_RECONCILIATION_INTERVAL_MS = 5 * 60_000
47
47
 
48
48
  function parseTimestamp(value: string | null | undefined): number {
@@ -81,6 +81,7 @@ export class LinearProvider implements KanbanProvider {
81
81
  private readonly db: Database,
82
82
  private readonly teamId: string,
83
83
  apiKey: string,
84
+ private readonly pollingSyncIntervalMs = DEFAULT_POLLING_SYNC_INTERVAL_MS,
84
85
  ) {
85
86
  initLinearCacheSchema(db)
86
87
  this.client = new LinearClient(apiKey)
@@ -105,7 +106,7 @@ export class LinearProvider implements KanbanProvider {
105
106
  const lastSyncAtMs = parseTimestamp(meta.lastSyncAt)
106
107
  const lastFullSyncAtMs = parseTimestamp(meta.lastFullSyncAt)
107
108
  const now = Date.now()
108
- if (!force && lastSyncAtMs && now - lastSyncAtMs < SYNC_INTERVAL_MS) return
109
+ if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
109
110
 
110
111
  const shouldFullSync =
111
112
  force ||