@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.
package/README.md CHANGED
@@ -62,20 +62,26 @@ Running `kanban` with no arguments is equivalent to `kanban board view`.
62
62
 
63
63
  All operations route through a provider backend. Set `KANBAN_PROVIDER` to choose one.
64
64
 
65
- | Variable | Default | Description |
66
- | ------------------ | ------------- | ------------------------------------------------------------------------ |
67
- | `KANBAN_PROVIDER` | `local` | `local`, `linear`, or `jira` |
68
- | `KANBAN_DB_PATH` | auto-resolved | SQLite database path |
69
- | `LINEAR_API_KEY` | — | Required when `KANBAN_PROVIDER=linear` |
70
- | `LINEAR_TEAM_ID` | | Required when `KANBAN_PROVIDER=linear` |
71
- | `JIRA_BASE_URL` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `https://acme.atlassian.net`) |
72
- | `JIRA_EMAIL` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian account email) |
73
- | `JIRA_API_TOKEN` | | Required when `KANBAN_PROVIDER=jira` (Atlassian API token) |
74
- | `JIRA_PROJECT_KEY` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `ENG`) |
75
- | `JIRA_BOARD_ID` | — | Optional when `KANBAN_PROVIDER=jira` (Agile board id for column order) |
76
- | `JIRA_ISSUE_TYPE` | `Task` | Optional when `KANBAN_PROVIDER=jira` (default issue type for new tasks) |
77
-
78
- Without `KANBAN_DB_PATH`, the local provider resolves the database in this order:
65
+ | Variable | Default | Description |
66
+ | ---------------------------- | ------------- | ------------------------------------------------------------------------ |
67
+ | `KANBAN_PROVIDER` | `local` | `local`, `linear`, or `jira` |
68
+ | `KANBAN_STORAGE` | `sqlite` | `sqlite` or `postgres` |
69
+ | `KANBAN_DATABASE_URL` | — | Required when `KANBAN_STORAGE=postgres` |
70
+ | `KANBAN_DB_PATH` | auto-resolved | SQLite database path |
71
+ | `KANBAN_DEFAULT_COLUMNS` | — | Optional bootstrap column names for local/Postgres caches |
72
+ | `KANBAN_DEFAULT_TASK_COLUMN` | — | Optional created-task column override for local/Postgres caches |
73
+ | `KANBAN_SYNC_INTERVAL_MS` | `30000` | Polling sync interval for remote providers; integer milliseconds >= 1000 |
74
+ | `LINEAR_API_KEY` | — | Required when `KANBAN_PROVIDER=linear` |
75
+ | `LINEAR_TEAM_ID` | — | Required when `KANBAN_PROVIDER=linear` |
76
+ | `JIRA_BASE_URL` | | Required when `KANBAN_PROVIDER=jira` (e.g. `https://acme.atlassian.net`) |
77
+ | `JIRA_EMAIL` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian account email) |
78
+ | `JIRA_API_TOKEN` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian API token) |
79
+ | `JIRA_PROJECT_KEY` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `ENG`) |
80
+ | `JIRA_BOARD_ID` | — | Optional when `KANBAN_PROVIDER=jira` (Agile board id for column order) |
81
+ | `JIRA_ISSUE_TYPE` | `Task` | Optional when `KANBAN_PROVIDER=jira` (default issue type for new tasks) |
82
+
83
+ When `KANBAN_STORAGE=sqlite` and `KANBAN_DB_PATH` is unset, the local provider
84
+ resolves the database in this order:
79
85
 
80
86
  1. `./.kanban/board.db` if it exists in the current working directory
81
87
  2. `~/.kanban/board.db` if it exists
@@ -245,6 +251,7 @@ Default columns: `recurring`, `backlog`, `in-progress`, `review`, `done`.
245
251
  ```bash
246
252
  kanban serve # default port 3000
247
253
  kanban serve --port 8080
254
+ kanban serve --sync-interval-ms 300000
248
255
  kanban serve --tunnel # optional public URL for webhook testing
249
256
  ```
250
257
 
@@ -300,9 +307,11 @@ Starts a Bun HTTP server with:
300
307
  - **Readiness check** at `/api/ready` — reports whether the cache has warmed at least once
301
308
  - **Sync status** at `/api/sync-status` — reports background sync state plus provider sync metadata
302
309
 
303
- In `serve` mode, remote providers now warm once on startup and continue syncing
304
- in the background every 30 seconds. Full reconciliation is still handled by the
305
- provider-specific logic on top of that steady cadence.
310
+ In `serve` mode, remote providers warm once on startup and continue syncing in
311
+ the background on the runtime's configured polling cadence. Use
312
+ `KANBAN_SYNC_INTERVAL_MS` or `kanban serve --sync-interval-ms` to tune it. Full
313
+ reconciliation is still handled by the provider-specific logic on top of that
314
+ steady cadence.
306
315
 
307
316
  Comment routes:
308
317
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andypai/agent-kanban",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structured JSON output.",
5
5
  "homepage": "https://github.com/abpai/agent-kanban#readme",
6
6
  "repository": {
@@ -55,7 +55,8 @@
55
55
  "prepack": "cd ui && bun install --frozen-lockfile && bun run build"
56
56
  },
57
57
  "dependencies": {
58
- "@modelcontextprotocol/sdk": "^1.29.0"
58
+ "@modelcontextprotocol/sdk": "^1.29.0",
59
+ "postgres": "^3.4.4"
59
60
  },
60
61
  "devDependencies": {
61
62
  "@eslint/js": "^9.39.1",
@@ -3,17 +3,17 @@ import { Database } from 'bun:sqlite'
3
3
  import { initSchema, seedDefaultColumns, addTask } from '../db'
4
4
  import { handleRequest } from '../api'
5
5
  import { createProvider } from '../providers/index'
6
+ import type { KanbanProvider } from '../providers/types'
6
7
 
7
8
  let db: Database
8
- let provider: ReturnType<typeof createProvider>
9
+ let provider: KanbanProvider
9
10
 
10
11
  beforeEach(() => {
11
- process.env['KANBAN_PROVIDER'] = 'local'
12
12
  db = new Database(':memory:')
13
13
  db.run('PRAGMA foreign_keys = ON')
14
14
  initSchema(db)
15
15
  seedDefaultColumns(db)
16
- provider = createProvider(db, ':memory:')
16
+ provider = createProvider(db, { provider: 'local' }, ':memory:')
17
17
  })
18
18
 
19
19
  describe('handleRequest', () => {
@@ -59,8 +59,28 @@ describe('parseServeArgs', () => {
59
59
  })
60
60
 
61
61
  test('--tunnel opts in; --port and --db override', () => {
62
- const opts = parseServeArgs(['serve', '--tunnel', '--port', '5050', '--db', '/tmp/b.db'])
63
- expect(opts).toEqual({ db: '/tmp/b.db', port: 5050, tunnel: true })
62
+ const opts = parseServeArgs([
63
+ 'serve',
64
+ '--tunnel',
65
+ '--port',
66
+ '5050',
67
+ '--db',
68
+ '/tmp/b.db',
69
+ '--sync-interval-ms',
70
+ '600000',
71
+ ])
72
+ expect(opts).toEqual({
73
+ db: '/tmp/b.db',
74
+ port: 5050,
75
+ syncIntervalMs: 600_000,
76
+ tunnel: true,
77
+ })
78
+ })
79
+
80
+ test('--sync-interval-ms rejects invalid values', () => {
81
+ for (const raw of ['999', '0']) {
82
+ expect(() => parseServeArgs(['serve', '--sync-interval-ms', raw])).toThrow(KanbanError)
83
+ }
64
84
  })
65
85
  })
66
86
 
@@ -240,6 +240,28 @@ describe('JiraProvider read path', () => {
240
240
  }
241
241
  })
242
242
 
243
+ test('custom polling sync interval refreshes before the default 30 seconds', async () => {
244
+ const { fn, calls } = jiraFetchStub(standardRoutes({}))
245
+ globalThis.fetch = fn
246
+ const client = new JiraClient({
247
+ baseUrl: baseConfig.baseUrl,
248
+ email: baseConfig.email,
249
+ apiToken: baseConfig.apiToken,
250
+ })
251
+ const provider = new JiraProvider(db, { ...baseConfig, pollingSyncIntervalMs: 5_000 }, client)
252
+ saveJiraSyncMeta(db, {
253
+ projectKey: 'ENG',
254
+ lastSyncAt: '2026-01-01T00:00:00.000Z',
255
+ lastFullSyncAt: '2026-01-01T00:00:00.000Z',
256
+ lastIssueUpdatedAt: '2026-01-01T00:00:00.000Z',
257
+ })
258
+ Date.now = () => Date.parse('2026-01-01T00:00:06.000Z')
259
+
260
+ await provider.listTasks()
261
+
262
+ expect(calls.some((call) => call.url.includes('/rest/api/3/search/jql'))).toBe(true)
263
+ })
264
+
243
265
  test('sync delta JQL is exactly project = KEY AND updated >= "<ts>" ORDER BY updated ASC', async () => {
244
266
  const capturedJql: string[] = []
245
267
  // First sync: one issue returned so lastIssueUpdatedAt is set.
@@ -6,6 +6,7 @@ import { join } from 'node:path'
6
6
  import { ErrorCode, KanbanError } from '../errors'
7
7
  import { run } from '../index'
8
8
  import { createProvider } from '../providers/index'
9
+ import { trackerConfigFromEnv } from '../tracker-config'
9
10
 
10
11
  const ENV_KEYS = [
11
12
  'KANBAN_PROVIDER',
@@ -17,6 +18,7 @@ const ENV_KEYS = [
17
18
  'JIRA_ISSUE_TYPE',
18
19
  'LINEAR_API_KEY',
19
20
  'LINEAR_TEAM_ID',
21
+ 'KANBAN_SYNC_INTERVAL_MS',
20
22
  ] as const
21
23
 
22
24
  const tempRoot = mkdtempSync(join(tmpdir(), 'jira-wiring-'))
@@ -75,17 +77,16 @@ describe('jira-wiring', () => {
75
77
  }
76
78
  })
77
79
 
78
- test('createProvider throws PROVIDER_NOT_CONFIGURED listing missing JIRA_API_TOKEN', () => {
79
- const { db, dbPath } = makeDb()
80
+ test('trackerConfigFromEnv throws PROVIDER_NOT_CONFIGURED listing missing JIRA_API_TOKEN', () => {
80
81
  process.env['KANBAN_PROVIDER'] = 'jira'
81
82
  process.env['JIRA_BASE_URL'] = 'https://example.atlassian.net'
82
83
  process.env['JIRA_EMAIL'] = 'a@example.com'
83
84
  delete process.env['JIRA_API_TOKEN']
84
85
  process.env['JIRA_PROJECT_KEY'] = 'ENG'
85
86
 
86
- expect(() => createProvider(db, dbPath)).toThrow(KanbanError)
87
+ expect(() => trackerConfigFromEnv()).toThrow(KanbanError)
87
88
  try {
88
- createProvider(db, dbPath)
89
+ trackerConfigFromEnv()
89
90
  } catch (err) {
90
91
  expect((err as KanbanError).code).toBe(ErrorCode.PROVIDER_NOT_CONFIGURED)
91
92
  expect((err as Error).message).toContain('JIRA_API_TOKEN')
@@ -93,8 +94,7 @@ describe('jira-wiring', () => {
93
94
  }
94
95
  })
95
96
 
96
- test('createProvider throws PROVIDER_NOT_CONFIGURED listing multiple missing vars', () => {
97
- const { db, dbPath } = makeDb()
97
+ test('trackerConfigFromEnv throws PROVIDER_NOT_CONFIGURED listing multiple missing vars', () => {
98
98
  process.env['KANBAN_PROVIDER'] = 'jira'
99
99
  process.env['JIRA_BASE_URL'] = 'https://example.atlassian.net'
100
100
  delete process.env['JIRA_EMAIL']
@@ -104,7 +104,7 @@ describe('jira-wiring', () => {
104
104
  let msg = ''
105
105
  let code: string | undefined
106
106
  try {
107
- createProvider(db, dbPath)
107
+ trackerConfigFromEnv()
108
108
  } catch (err) {
109
109
  msg = (err as Error).message
110
110
  code = (err as KanbanError).code
@@ -118,17 +118,42 @@ describe('jira-wiring', () => {
118
118
 
119
119
  test('createProvider builds JiraProvider when all four required vars are set', () => {
120
120
  const { db, dbPath } = makeDb()
121
- setJiraRequiredEnv()
122
- const provider = createProvider(db, dbPath)
121
+ const provider = createProvider(
122
+ db,
123
+ {
124
+ provider: 'jira',
125
+ baseUrl: 'https://example.atlassian.net',
126
+ email: 'a@example.com',
127
+ apiToken: 'tok-test',
128
+ projectKey: 'ENG',
129
+ },
130
+ dbPath,
131
+ )
123
132
  expect(provider.type).toBe('jira')
124
133
  })
125
134
 
126
- test('JIRA_BOARD_ID non-numeric falls back to undefined', () => {
127
- const { db, dbPath } = makeDb()
135
+ test('JIRA_BOARD_ID non-numeric falls back to undefined in env loader', () => {
128
136
  setJiraRequiredEnv()
129
137
  process.env['JIRA_BOARD_ID'] = 'notanumber'
130
- const provider = createProvider(db, dbPath)
131
- expect(provider.type).toBe('jira')
138
+ const config = trackerConfigFromEnv()
139
+ expect(config.provider).toBe('jira')
140
+ expect(config).not.toHaveProperty('boardId')
141
+ })
142
+
143
+ test('trackerConfigFromEnv carries remote polling sync interval', () => {
144
+ setJiraRequiredEnv()
145
+ process.env['KANBAN_SYNC_INTERVAL_MS'] = '60000'
146
+
147
+ const jiraConfig = trackerConfigFromEnv()
148
+ expect(jiraConfig.provider).toBe('jira')
149
+ expect(jiraConfig.syncIntervalMs).toBe(60_000)
150
+
151
+ process.env['KANBAN_PROVIDER'] = 'linear'
152
+ process.env['LINEAR_API_KEY'] = 'lin_api_test'
153
+ process.env['LINEAR_TEAM_ID'] = 'team-test'
154
+ const linearConfig = trackerConfigFromEnv()
155
+ expect(linearConfig.provider).toBe('linear')
156
+ expect(linearConfig.syncIntervalMs).toBe(60_000)
132
157
  })
133
158
 
134
159
  test('kanban column add under KANBAN_PROVIDER=jira exits with UNSUPPORTED_OPERATION', async () => {
@@ -178,10 +203,15 @@ describe('jira-wiring', () => {
178
203
 
179
204
  test('KANBAN_PROVIDER=linear still builds LinearProvider (regression guard)', () => {
180
205
  const { db, dbPath } = makeDb()
181
- process.env['KANBAN_PROVIDER'] = 'linear'
182
- process.env['LINEAR_API_KEY'] = 'lin_api_test'
183
- process.env['LINEAR_TEAM_ID'] = 'team-test'
184
- const provider = createProvider(db, dbPath)
206
+ const provider = createProvider(
207
+ db,
208
+ {
209
+ provider: 'linear',
210
+ apiKey: 'lin_api_test',
211
+ teamId: 'team-test',
212
+ },
213
+ dbPath,
214
+ )
185
215
  expect(provider.type).toBe('linear')
186
216
  })
187
217
  })
@@ -485,4 +485,81 @@ describe('LinearProvider sync', () => {
485
485
 
486
486
  expect(issueQueries).toBe(1)
487
487
  })
488
+
489
+ test('custom polling sync interval refreshes before the default 30 seconds', async () => {
490
+ let issueQueries = 0
491
+
492
+ saveSyncMeta(db, {
493
+ team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
494
+ lastSyncAt: '2026-01-01T00:00:00.000Z',
495
+ lastFullSyncAt: '2026-01-01T00:00:00.000Z',
496
+ lastIssueUpdatedAt: '2026-01-01T00:00:00.000Z',
497
+ })
498
+
499
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
500
+ const body = JSON.parse(String(init?.body)) as {
501
+ query: string
502
+ variables: Record<string, unknown>
503
+ }
504
+
505
+ if (body.query.includes('query TeamSnapshot')) {
506
+ return new Response(
507
+ JSON.stringify({
508
+ data: {
509
+ team: {
510
+ id: 'team-1',
511
+ key: 'R2P',
512
+ name: 'R2pi',
513
+ states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
514
+ },
515
+ },
516
+ }),
517
+ { status: 200, headers: { 'content-type': 'application/json' } },
518
+ )
519
+ }
520
+
521
+ if (body.query.includes('query Users')) {
522
+ return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
523
+ status: 200,
524
+ headers: { 'content-type': 'application/json' },
525
+ })
526
+ }
527
+
528
+ if (body.query.includes('query Projects')) {
529
+ return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
530
+ status: 200,
531
+ headers: { 'content-type': 'application/json' },
532
+ })
533
+ }
534
+
535
+ if (body.query.includes('query Issues')) {
536
+ issueQueries += 1
537
+ return new Response(
538
+ JSON.stringify({
539
+ data: {
540
+ issues: {
541
+ nodes: [],
542
+ pageInfo: { hasNextPage: false, endCursor: null },
543
+ },
544
+ },
545
+ }),
546
+ { status: 200, headers: { 'content-type': 'application/json' } },
547
+ )
548
+ }
549
+
550
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
551
+ }) as unknown as typeof fetch
552
+
553
+ const originalDateNow = Date.now
554
+ Date.now = () => Date.parse('2026-01-01T00:00:06.000Z')
555
+
556
+ try {
557
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test', 5_000)
558
+ await provider.getBoard()
559
+ } finally {
560
+ Date.now = originalDateNow
561
+ }
562
+
563
+ expect(issueQueries).toBe(1)
564
+ })
488
565
  })