@andypai/agent-kanban 0.3.3 → 0.3.4
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 +16 -14
- package/package.json +1 -1
- package/src/__tests__/jira-provider-read.test.ts +22 -0
- package/src/__tests__/linear-provider-sync.test.ts +77 -0
- package/src/__tests__/sync-config.test.ts +32 -0
- package/src/errors.ts +1 -0
- package/src/mcp/errors.ts +1 -0
- package/src/providers/index.ts +3 -1
- package/src/providers/jira.ts +5 -2
- package/src/providers/linear.ts +3 -2
- package/src/server.ts +2 -3
- package/src/sync-config.ts +18 -0
package/README.md
CHANGED
|
@@ -62,18 +62,19 @@ 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
|
-
| `KANBAN_DB_PATH`
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
65
|
+
| Variable | Default | Description |
|
|
66
|
+
| ------------------------- | ------------- | ------------------------------------------------------------------------ |
|
|
67
|
+
| `KANBAN_PROVIDER` | `local` | `local`, `linear`, or `jira` |
|
|
68
|
+
| `KANBAN_DB_PATH` | auto-resolved | SQLite database path |
|
|
69
|
+
| `KANBAN_SYNC_INTERVAL_MS` | `30000` | Polling sync interval for remote providers; integer milliseconds >= 1000 |
|
|
70
|
+
| `LINEAR_API_KEY` | — | Required when `KANBAN_PROVIDER=linear` |
|
|
71
|
+
| `LINEAR_TEAM_ID` | — | Required when `KANBAN_PROVIDER=linear` |
|
|
72
|
+
| `JIRA_BASE_URL` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `https://acme.atlassian.net`) |
|
|
73
|
+
| `JIRA_EMAIL` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian account email) |
|
|
74
|
+
| `JIRA_API_TOKEN` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian API token) |
|
|
75
|
+
| `JIRA_PROJECT_KEY` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `ENG`) |
|
|
76
|
+
| `JIRA_BOARD_ID` | — | Optional when `KANBAN_PROVIDER=jira` (Agile board id for column order) |
|
|
77
|
+
| `JIRA_ISSUE_TYPE` | `Task` | Optional when `KANBAN_PROVIDER=jira` (default issue type for new tasks) |
|
|
77
78
|
|
|
78
79
|
Without `KANBAN_DB_PATH`, the local provider resolves the database in this order:
|
|
79
80
|
|
|
@@ -301,8 +302,9 @@ Starts a Bun HTTP server with:
|
|
|
301
302
|
- **Sync status** at `/api/sync-status` — reports background sync state plus provider sync metadata
|
|
302
303
|
|
|
303
304
|
In `serve` mode, remote providers now warm once on startup and continue syncing
|
|
304
|
-
in the background every
|
|
305
|
-
provider-specific logic on top of that
|
|
305
|
+
in the background every `KANBAN_SYNC_INTERVAL_MS` milliseconds. Full
|
|
306
|
+
reconciliation is still handled by the provider-specific logic on top of that
|
|
307
|
+
steady cadence.
|
|
306
308
|
|
|
307
309
|
Comment routes:
|
|
308
310
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andypai/agent-kanban",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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": {
|
|
@@ -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.
|
|
@@ -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
|
})
|
|
@@ -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/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:
|
package/src/providers/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { JiraProvider } from './jira'
|
|
|
5
5
|
import { LinearProvider } from './linear'
|
|
6
6
|
import { LocalProvider } from './local'
|
|
7
7
|
import type { KanbanProvider } from './types'
|
|
8
|
+
import { resolvePollingSyncIntervalMs } from '../sync-config'
|
|
8
9
|
|
|
9
10
|
export function createProvider(db: Database, dbPath = getDbPath()): KanbanProvider {
|
|
10
11
|
const providerType = (process.env['KANBAN_PROVIDER'] ?? 'local') as 'local' | 'linear' | 'jira'
|
|
@@ -17,7 +18,7 @@ export function createProvider(db: Database, dbPath = getDbPath()): KanbanProvid
|
|
|
17
18
|
'LINEAR_API_KEY and LINEAR_TEAM_ID are required when KANBAN_PROVIDER=linear',
|
|
18
19
|
)
|
|
19
20
|
}
|
|
20
|
-
return new LinearProvider(db, teamId!, apiKey
|
|
21
|
+
return new LinearProvider(db, teamId!, apiKey!, resolvePollingSyncIntervalMs())
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
if (providerType === 'jira') {
|
|
@@ -45,6 +46,7 @@ export function createProvider(db: Database, dbPath = getDbPath()): KanbanProvid
|
|
|
45
46
|
projectKey: projectKey!,
|
|
46
47
|
boardId: Number.isFinite(boardId) ? boardId : undefined,
|
|
47
48
|
defaultIssueType,
|
|
49
|
+
pollingSyncIntervalMs: resolvePollingSyncIntervalMs(),
|
|
48
50
|
})
|
|
49
51
|
}
|
|
50
52
|
|
package/src/providers/jira.ts
CHANGED
|
@@ -49,8 +49,8 @@ import type {
|
|
|
49
49
|
TaskListFilters,
|
|
50
50
|
UpdateTaskInput,
|
|
51
51
|
} from './types'
|
|
52
|
+
import { resolvePollingSyncIntervalMs } 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 ?? resolvePollingSyncIntervalMs()
|
|
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 <
|
|
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.
|
package/src/providers/linear.ts
CHANGED
|
@@ -41,8 +41,8 @@ import type {
|
|
|
41
41
|
TaskListFilters,
|
|
42
42
|
UpdateTaskInput,
|
|
43
43
|
} from './types'
|
|
44
|
+
import { resolvePollingSyncIntervalMs } 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 = resolvePollingSyncIntervalMs(),
|
|
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 <
|
|
109
|
+
if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
|
|
109
110
|
|
|
110
111
|
const shouldFullSync =
|
|
111
112
|
force ||
|
package/src/server.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { handleRequest } from './api'
|
|
4
4
|
import type { ServerWebSocket } from 'bun'
|
|
5
5
|
import type { KanbanProvider } from './providers/types'
|
|
6
|
+
import { resolvePollingSyncIntervalMs } from './sync-config'
|
|
6
7
|
|
|
7
8
|
const wsClients = new Set<ServerWebSocket<unknown>>()
|
|
8
9
|
const CORS_HEADERS = {
|
|
@@ -10,8 +11,6 @@ const CORS_HEADERS = {
|
|
|
10
11
|
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
|
|
11
12
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
12
13
|
}
|
|
13
|
-
const DEFAULT_BACKGROUND_SYNC_INTERVAL_MS = 30_000
|
|
14
|
-
|
|
15
14
|
interface BackgroundSyncState {
|
|
16
15
|
enabled: boolean
|
|
17
16
|
inFlight: boolean
|
|
@@ -64,7 +63,7 @@ export function startServer(
|
|
|
64
63
|
): StartedServer {
|
|
65
64
|
const distDir = join(import.meta.dir, '..', 'ui', 'dist')
|
|
66
65
|
const hasStatic = existsSync(distDir)
|
|
67
|
-
const syncIntervalMs = opts.syncIntervalMs ??
|
|
66
|
+
const syncIntervalMs = opts.syncIntervalMs ?? resolvePollingSyncIntervalMs()
|
|
68
67
|
const syncCache = provider.syncCache?.bind(provider)
|
|
69
68
|
const getSyncStatus = provider.getSyncStatus?.bind(provider)
|
|
70
69
|
const backgroundSync: BackgroundSyncState = {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ErrorCode, KanbanError } from './errors'
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_POLLING_SYNC_INTERVAL_MS = 30_000
|
|
4
|
+
export const MIN_POLLING_SYNC_INTERVAL_MS = 1_000
|
|
5
|
+
|
|
6
|
+
export function resolvePollingSyncIntervalMs(raw = process.env['KANBAN_SYNC_INTERVAL_MS']): number {
|
|
7
|
+
const value = raw?.trim()
|
|
8
|
+
if (!value) return DEFAULT_POLLING_SYNC_INTERVAL_MS
|
|
9
|
+
|
|
10
|
+
const parsed = Number(value)
|
|
11
|
+
if (!Number.isInteger(parsed) || parsed < MIN_POLLING_SYNC_INTERVAL_MS) {
|
|
12
|
+
throw new KanbanError(
|
|
13
|
+
ErrorCode.INVALID_CONFIG,
|
|
14
|
+
`KANBAN_SYNC_INTERVAL_MS must be an integer >= ${MIN_POLLING_SYNC_INTERVAL_MS}`,
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
return parsed
|
|
18
|
+
}
|