@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 +26 -17
- package/package.json +3 -2
- package/src/__tests__/api.test.ts +3 -3
- package/src/__tests__/index.test.ts +22 -2
- package/src/__tests__/jira-provider-read.test.ts +22 -0
- package/src/__tests__/jira-wiring.test.ts +47 -17
- package/src/__tests__/linear-provider-sync.test.ts +77 -0
- package/src/__tests__/postgres-jira-provider.test.ts +315 -0
- package/src/__tests__/postgres-linear-provider.test.ts +309 -0
- package/src/__tests__/postgres-local-provider.test.ts +129 -0
- package/src/__tests__/storage-config.test.ts +39 -0
- package/src/__tests__/sync-config.test.ts +32 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +65 -28
- package/src/mcp/errors.ts +1 -0
- package/src/provider-runtime.ts +110 -0
- package/src/providers/index.ts +16 -37
- package/src/providers/jira.ts +5 -2
- package/src/providers/linear.ts +3 -2
- package/src/providers/postgres-jira.ts +1188 -0
- package/src/providers/postgres-linear.ts +1088 -0
- package/src/providers/postgres-local.ts +611 -0
- package/src/server.ts +2 -3
- package/src/storage-config.ts +41 -0
- package/src/sync-config.ts +21 -0
- package/src/tracker-config.ts +104 -0
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
|
|
66
|
-
|
|
|
67
|
-
| `KANBAN_PROVIDER`
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
"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:
|
|
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([
|
|
63
|
-
|
|
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('
|
|
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(() =>
|
|
87
|
+
expect(() => trackerConfigFromEnv()).toThrow(KanbanError)
|
|
87
88
|
try {
|
|
88
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
131
|
-
expect(provider
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
})
|