@andypai/agent-kanban 0.3.2 → 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 +26 -16
- package/package.json +1 -1
- package/src/__tests__/index.test.ts +47 -1
- package/src/__tests__/jira-adf.test.ts +139 -0
- package/src/__tests__/jira-provider-read.test.ts +22 -0
- package/src/__tests__/linear-provider-sync.test.ts +77 -0
- package/src/__tests__/output.test.ts +17 -0
- package/src/__tests__/sync-config.test.ts +32 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +47 -0
- package/src/mcp/errors.ts +1 -0
- package/src/output.ts +22 -1
- package/src/providers/index.ts +3 -1
- package/src/providers/jira-adf.ts +71 -11
- 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
|
|
|
@@ -127,8 +128,8 @@ surface.
|
|
|
127
128
|
|
|
128
129
|
Unsupported operations return error code `UNSUPPORTED_OPERATION` with exit code 1.
|
|
129
130
|
|
|
130
|
-
Task comments are
|
|
131
|
-
detail flows
|
|
131
|
+
Task comments are exposed through the CLI, REST API, MCP, and dashboard task
|
|
132
|
+
detail flows.
|
|
132
133
|
|
|
133
134
|
In Linear and Jira modes, webhooks update the cache immediately when configured,
|
|
134
135
|
and the normal poll loop still runs as a fallback so missed deliveries and
|
|
@@ -192,6 +193,14 @@ Default columns: `recurring`, `backlog`, `in-progress`, `review`, `done`.
|
|
|
192
193
|
| `--project <name>` | New project |
|
|
193
194
|
| `-m <json>` | New metadata |
|
|
194
195
|
|
|
196
|
+
### comment
|
|
197
|
+
|
|
198
|
+
| Command | Description |
|
|
199
|
+
| ----------------------------------------------------- | --------------------- |
|
|
200
|
+
| `kanban comment list <task-id>` | List task comments |
|
|
201
|
+
| `kanban comment add <task-id> <body>` | Create a task comment |
|
|
202
|
+
| `kanban comment update <task-id> <comment-id> <body>` | Update a task comment |
|
|
203
|
+
|
|
195
204
|
### column
|
|
196
205
|
|
|
197
206
|
| Command | Description |
|
|
@@ -293,8 +302,9 @@ Starts a Bun HTTP server with:
|
|
|
293
302
|
- **Sync status** at `/api/sync-status` — reports background sync state plus provider sync metadata
|
|
294
303
|
|
|
295
304
|
In `serve` mode, remote providers now warm once on startup and continue syncing
|
|
296
|
-
in the background every
|
|
297
|
-
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.
|
|
298
308
|
|
|
299
309
|
Comment routes:
|
|
300
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": {
|
|
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
|
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { ErrorCode, KanbanError, type ErrorCodeValue } from '../errors'
|
|
7
|
-
import type { Task } from '../types'
|
|
7
|
+
import type { Task, TaskComment } from '../types'
|
|
8
8
|
import { parseServeArgs, run } from '../index'
|
|
9
9
|
|
|
10
10
|
async function withTempDb(runTest: (dbPath: string) => Promise<void>): Promise<void> {
|
|
@@ -234,6 +234,28 @@ describe('run', () => {
|
|
|
234
234
|
})
|
|
235
235
|
})
|
|
236
236
|
|
|
237
|
+
test('runs comment commands through the real CLI path', async () => {
|
|
238
|
+
await withTempDb(async (dbPath) => {
|
|
239
|
+
const task = expectOk<Task>(await run(['--db', dbPath, 'task', 'add', 'Comment target']))
|
|
240
|
+
|
|
241
|
+
const created = expectOk<TaskComment>(
|
|
242
|
+
await run(['--db', dbPath, 'comment', 'add', task.id, 'hello', 'from', 'cli']),
|
|
243
|
+
)
|
|
244
|
+
expect(created.task_id).toBe(task.id)
|
|
245
|
+
expect(created.body).toBe('hello from cli')
|
|
246
|
+
|
|
247
|
+
const listed = expectOk<TaskComment[]>(
|
|
248
|
+
await run(['--db', dbPath, 'comment', 'list', task.id]),
|
|
249
|
+
)
|
|
250
|
+
expect(listed.map((comment) => comment.id)).toEqual([created.id])
|
|
251
|
+
|
|
252
|
+
const updated = expectOk<TaskComment>(
|
|
253
|
+
await run(['--db', dbPath, 'comment', 'update', task.id, created.id, 'edited', 'comment']),
|
|
254
|
+
)
|
|
255
|
+
expect(updated.body).toBe('edited comment')
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
237
259
|
test('raises CLI errors for missing task arguments', async () => {
|
|
238
260
|
await withTempDb(async (dbPath) => {
|
|
239
261
|
const missingTitle = await expectKanbanError(
|
|
@@ -249,4 +271,28 @@ describe('run', () => {
|
|
|
249
271
|
expect(missingId.message).toContain('Task ID is required')
|
|
250
272
|
})
|
|
251
273
|
})
|
|
274
|
+
|
|
275
|
+
test('raises CLI errors for missing comment arguments', async () => {
|
|
276
|
+
await withTempDb(async (dbPath) => {
|
|
277
|
+
const missingListId = await expectKanbanError(
|
|
278
|
+
run(['--db', dbPath, 'comment', 'list']),
|
|
279
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
280
|
+
)
|
|
281
|
+
expect(missingListId.message).toContain('Task ID is required')
|
|
282
|
+
|
|
283
|
+
const missingAddBody = await expectKanbanError(
|
|
284
|
+
run(['--db', dbPath, 'comment', 'add', 't_1']),
|
|
285
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
286
|
+
)
|
|
287
|
+
expect(missingAddBody.message).toContain('kanban comment add <task-id> <body>')
|
|
288
|
+
|
|
289
|
+
const missingUpdateBody = await expectKanbanError(
|
|
290
|
+
run(['--db', dbPath, 'comment', 'update', 't_1', 'c_1']),
|
|
291
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
292
|
+
)
|
|
293
|
+
expect(missingUpdateBody.message).toContain(
|
|
294
|
+
'kanban comment update <task-id> <comment-id> <body>',
|
|
295
|
+
)
|
|
296
|
+
})
|
|
297
|
+
})
|
|
252
298
|
})
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type AdfBulletListNode,
|
|
6
6
|
type AdfCodeBlockNode,
|
|
7
7
|
type AdfDocument,
|
|
8
|
+
type AdfExpandNode,
|
|
8
9
|
type AdfInlineNode,
|
|
9
10
|
type AdfParagraphNode,
|
|
10
11
|
} from '../providers/jira-adf'
|
|
@@ -432,3 +433,141 @@ describe('plainTextToAdf / adfToPlainText', () => {
|
|
|
432
433
|
expect(adfToPlainText(doc)).toBe(input)
|
|
433
434
|
})
|
|
434
435
|
})
|
|
436
|
+
|
|
437
|
+
describe('jira-adf expand block', () => {
|
|
438
|
+
test('write: expand wrapping a fenced code block produces nested ADF', () => {
|
|
439
|
+
const input =
|
|
440
|
+
'::: expand title="garage-baton (machine-readable)"\n' +
|
|
441
|
+
'```garage-baton\n' +
|
|
442
|
+
'{"v":1}\n' +
|
|
443
|
+
'```\n' +
|
|
444
|
+
':::'
|
|
445
|
+
const doc = plainTextToAdf(input)
|
|
446
|
+
expect(doc.content).toHaveLength(1)
|
|
447
|
+
const expand = doc.content[0] as AdfExpandNode
|
|
448
|
+
expect(expand.type).toBe('expand')
|
|
449
|
+
expect(expand.attrs?.title).toBe('garage-baton (machine-readable)')
|
|
450
|
+
expect(expand.content).toHaveLength(1)
|
|
451
|
+
const code = expand.content[0] as AdfCodeBlockNode
|
|
452
|
+
expect(code.type).toBe('codeBlock')
|
|
453
|
+
expect(code.attrs?.language).toBe('garage-baton')
|
|
454
|
+
expect(code.content?.[0]).toEqual({ type: 'text', text: '{"v":1}' })
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
test('read: ADF expand wrapping a codeBlock renders back to the wire format', () => {
|
|
458
|
+
const doc: AdfDocument = {
|
|
459
|
+
version: 1,
|
|
460
|
+
type: 'doc',
|
|
461
|
+
content: [
|
|
462
|
+
{
|
|
463
|
+
type: 'expand',
|
|
464
|
+
attrs: { title: 'garage-baton (machine-readable)' },
|
|
465
|
+
content: [
|
|
466
|
+
{
|
|
467
|
+
type: 'codeBlock',
|
|
468
|
+
attrs: { language: 'garage-baton' },
|
|
469
|
+
content: [{ type: 'text', text: '{"v":1}' }],
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
},
|
|
473
|
+
],
|
|
474
|
+
}
|
|
475
|
+
expect(adfToPlainText(doc)).toBe(
|
|
476
|
+
'::: expand title="garage-baton (machine-readable)"\n' +
|
|
477
|
+
'```garage-baton\n' +
|
|
478
|
+
'{"v":1}\n' +
|
|
479
|
+
'```\n' +
|
|
480
|
+
':::',
|
|
481
|
+
)
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
test('write: bare ::: expand emits empty title', () => {
|
|
485
|
+
const input = '::: expand\nbody paragraph\n:::'
|
|
486
|
+
const doc = plainTextToAdf(input)
|
|
487
|
+
const expand = doc.content[0] as AdfExpandNode
|
|
488
|
+
expect(expand.type).toBe('expand')
|
|
489
|
+
expect(expand.attrs?.title).toBe('')
|
|
490
|
+
expect(expand.content[0]?.type).toBe('paragraph')
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
test('read: bare ::: expand renders without title attribute', () => {
|
|
494
|
+
const doc: AdfDocument = {
|
|
495
|
+
version: 1,
|
|
496
|
+
type: 'doc',
|
|
497
|
+
content: [
|
|
498
|
+
{
|
|
499
|
+
type: 'expand',
|
|
500
|
+
attrs: { title: '' },
|
|
501
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'body' }] }],
|
|
502
|
+
},
|
|
503
|
+
],
|
|
504
|
+
}
|
|
505
|
+
expect(adfToPlainText(doc)).toBe('::: expand\nbody\n:::')
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
test('read: expand without attrs renders as a bare expand block', () => {
|
|
509
|
+
const doc: AdfDocument = {
|
|
510
|
+
version: 1,
|
|
511
|
+
type: 'doc',
|
|
512
|
+
content: [
|
|
513
|
+
{
|
|
514
|
+
type: 'expand',
|
|
515
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'body' }] }],
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
}
|
|
519
|
+
expect(adfToPlainText(doc)).toBe('::: expand\nbody\n:::')
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
test('write: unterminated ::: expand falls through to paragraph', () => {
|
|
523
|
+
const input = '::: expand title="oops"\nstray text without close'
|
|
524
|
+
const doc = plainTextToAdf(input)
|
|
525
|
+
// Both lines collapse into a single paragraph; no expand node.
|
|
526
|
+
expect(doc.content.every((b) => b.type !== 'expand')).toBe(true)
|
|
527
|
+
expect(doc.content[0]?.type).toBe('paragraph')
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
test('round-trip: typed-comment-shaped string is byte-stable', () => {
|
|
531
|
+
const input =
|
|
532
|
+
'garage-triage: **Accepted** — abpai/garage-band\n' +
|
|
533
|
+
'\n' +
|
|
534
|
+
'Increment current_count by 1.\n' +
|
|
535
|
+
'\n' +
|
|
536
|
+
'**Next:** Handing off to planner.\n' +
|
|
537
|
+
'\n' +
|
|
538
|
+
'::: expand title="garage-baton (machine-readable)"\n' +
|
|
539
|
+
'```garage-baton\n' +
|
|
540
|
+
'{"v":1,"accepted":true}\n' +
|
|
541
|
+
'```\n' +
|
|
542
|
+
':::'
|
|
543
|
+
expect(adfToPlainText(plainTextToAdf(input))).toBe(input)
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
test('round-trip: nested paragraph and bullet list inside expand survive', () => {
|
|
547
|
+
const input =
|
|
548
|
+
'::: expand title="details"\n' +
|
|
549
|
+
'first paragraph\n' +
|
|
550
|
+
'\n' +
|
|
551
|
+
'- bullet one\n' +
|
|
552
|
+
'- bullet two\n' +
|
|
553
|
+
':::'
|
|
554
|
+
expect(adfToPlainText(plainTextToAdf(input))).toBe(input)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
test('write: expand title preserves escaped quote', () => {
|
|
558
|
+
const input = '::: expand title="quoted \\"title\\""\nx\n:::'
|
|
559
|
+
const doc = plainTextToAdf(input)
|
|
560
|
+
const expand = doc.content[0] as AdfExpandNode
|
|
561
|
+
expect(expand.attrs?.title).toBe('quoted "title"')
|
|
562
|
+
// Round-trip emits the same escaping.
|
|
563
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
test('write: text followed by ::: expand on the next line is two blocks', () => {
|
|
567
|
+
const input = 'leading paragraph\n' + '::: expand title="x"\n' + 'inside\n' + ':::'
|
|
568
|
+
const doc = plainTextToAdf(input)
|
|
569
|
+
expect(doc.content).toHaveLength(2)
|
|
570
|
+
expect(doc.content[0]?.type).toBe('paragraph')
|
|
571
|
+
expect(doc.content[1]?.type).toBe('expand')
|
|
572
|
+
})
|
|
573
|
+
})
|
|
@@ -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
|
})
|
|
@@ -36,4 +36,21 @@ describe('formatOutput', () => {
|
|
|
36
36
|
const output = formatOutput(result, true)
|
|
37
37
|
expect(output).toBe('Board initialized.')
|
|
38
38
|
})
|
|
39
|
+
|
|
40
|
+
test('formats comments in pretty mode', () => {
|
|
41
|
+
const output = formatOutput(
|
|
42
|
+
success([
|
|
43
|
+
{
|
|
44
|
+
id: 'c_1',
|
|
45
|
+
task_id: 't_1',
|
|
46
|
+
body: 'hello from cli',
|
|
47
|
+
author: 'alice',
|
|
48
|
+
created_at: '2026-04-26T00:00:00.000Z',
|
|
49
|
+
updated_at: '2026-04-26T00:00:00.000Z',
|
|
50
|
+
},
|
|
51
|
+
]),
|
|
52
|
+
true,
|
|
53
|
+
)
|
|
54
|
+
expect(output).toBe(' c_1 @alice hello from cli')
|
|
55
|
+
})
|
|
39
56
|
})
|
|
@@ -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
|
@@ -139,6 +139,45 @@ async function routeTask(
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
async function routeComment(
|
|
143
|
+
provider: ReturnType<typeof createProvider>,
|
|
144
|
+
action: string | undefined,
|
|
145
|
+
positionals: string[],
|
|
146
|
+
): Promise<CliOutput> {
|
|
147
|
+
switch (action) {
|
|
148
|
+
case 'list': {
|
|
149
|
+
const id = positionals[2]
|
|
150
|
+
if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
|
|
151
|
+
return success(await provider.listComments(id))
|
|
152
|
+
}
|
|
153
|
+
case 'add': {
|
|
154
|
+
const id = positionals[2]
|
|
155
|
+
const body = positionals.slice(3).join(' ')
|
|
156
|
+
if (!id || !body) {
|
|
157
|
+
throw new KanbanError(
|
|
158
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
159
|
+
'Usage: kanban comment add <task-id> <body>',
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
return success(await provider.comment(id, body))
|
|
163
|
+
}
|
|
164
|
+
case 'update': {
|
|
165
|
+
const id = positionals[2]
|
|
166
|
+
const commentId = positionals[3]
|
|
167
|
+
const body = positionals.slice(4).join(' ')
|
|
168
|
+
if (!id || !commentId || !body) {
|
|
169
|
+
throw new KanbanError(
|
|
170
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
171
|
+
'Usage: kanban comment update <task-id> <comment-id> <body>',
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
return success(await provider.updateComment(id, commentId, body))
|
|
175
|
+
}
|
|
176
|
+
default:
|
|
177
|
+
throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown comment command '${action}'`)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
142
181
|
function routeColumn(
|
|
143
182
|
db: Database,
|
|
144
183
|
providerType: string,
|
|
@@ -298,6 +337,9 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
|
|
|
298
337
|
case 'task':
|
|
299
338
|
output = await routeTask(provider, action, positionals, values)
|
|
300
339
|
break
|
|
340
|
+
case 'comment':
|
|
341
|
+
output = await routeComment(provider, action, positionals)
|
|
342
|
+
break
|
|
301
343
|
case 'column':
|
|
302
344
|
output = routeColumn(db, provider.type, action, positionals, values)
|
|
303
345
|
break
|
|
@@ -335,6 +377,11 @@ Commands:
|
|
|
335
377
|
task assign <id> <user> Assign task
|
|
336
378
|
task prioritize <id> <lvl> Set priority
|
|
337
379
|
|
|
380
|
+
comment list <task-id> List comments on a task
|
|
381
|
+
comment add <task-id> <body> Create a comment
|
|
382
|
+
comment update <task-id> <comment-id> <body>
|
|
383
|
+
Update a comment
|
|
384
|
+
|
|
338
385
|
column add <name> Add column [--position n] [--color hex]
|
|
339
386
|
column list List columns
|
|
340
387
|
column rename <id> <name> Rename column
|
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/output.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CliOutput, BoardView, Task, TaskWithColumn, Column } from './types'
|
|
1
|
+
import type { CliOutput, BoardView, Task, TaskWithColumn, TaskComment, Column } from './types'
|
|
2
2
|
|
|
3
3
|
export function success<T>(data: T): CliOutput<T> {
|
|
4
4
|
return { ok: true, data }
|
|
@@ -25,12 +25,16 @@ function formatPrettyData(data: unknown): string {
|
|
|
25
25
|
if (Array.isArray(data)) {
|
|
26
26
|
if (data.length === 0) return 'No items found.'
|
|
27
27
|
if ('column_id' in data[0]) return data.map(formatTaskLine).join('\n')
|
|
28
|
+
if ('task_id' in data[0]) return data.map(formatCommentLine).join('\n')
|
|
28
29
|
if ('position' in data[0]) return data.map(formatColumnLine).join('\n')
|
|
29
30
|
return JSON.stringify(data, null, 2)
|
|
30
31
|
}
|
|
31
32
|
if (data && typeof data === 'object' && 'column_id' in data) {
|
|
32
33
|
return formatTaskDetail(data as TaskWithColumn)
|
|
33
34
|
}
|
|
35
|
+
if (data && typeof data === 'object' && 'task_id' in data) {
|
|
36
|
+
return formatCommentDetail(data as TaskComment)
|
|
37
|
+
}
|
|
34
38
|
if (data && typeof data === 'object' && 'moved' in data) {
|
|
35
39
|
return `Moved ${(data as { moved: number }).moved} task(s).`
|
|
36
40
|
}
|
|
@@ -79,6 +83,23 @@ function formatTaskDetail(task: Task): string {
|
|
|
79
83
|
return lines.join('\n')
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
function formatCommentLine(comment: TaskComment): string {
|
|
87
|
+
const author = comment.author ? ` @${comment.author}` : ''
|
|
88
|
+
return ` ${comment.id}${author} ${comment.body}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatCommentDetail(comment: TaskComment): string {
|
|
92
|
+
const lines = [
|
|
93
|
+
`Comment: ${comment.id}`,
|
|
94
|
+
`Task: ${comment.task_id}`,
|
|
95
|
+
...(comment.author ? [`Author: ${comment.author}`] : []),
|
|
96
|
+
`Body: ${comment.body}`,
|
|
97
|
+
`Created: ${comment.created_at}`,
|
|
98
|
+
`Updated: ${comment.updated_at}`,
|
|
99
|
+
]
|
|
100
|
+
return lines.join('\n')
|
|
101
|
+
}
|
|
102
|
+
|
|
82
103
|
function formatColumnLine(col: Column): string {
|
|
83
104
|
const color = col.color ? ` (${col.color})` : ''
|
|
84
105
|
return ` ${col.position}. ${col.name}${color} [${col.id}]`
|
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
|
|
|
@@ -68,6 +68,12 @@ export interface AdfHeadingNode {
|
|
|
68
68
|
content?: AdfInlineNode[]
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
export interface AdfExpandNode {
|
|
72
|
+
type: 'expand'
|
|
73
|
+
attrs?: { title?: string }
|
|
74
|
+
content: AdfBlockNode[]
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
export interface AdfUnknownBlockNode {
|
|
72
78
|
type: string
|
|
73
79
|
[key: string]: unknown
|
|
@@ -79,6 +85,7 @@ export type AdfBlockNode =
|
|
|
79
85
|
| AdfOrderedListNode
|
|
80
86
|
| AdfCodeBlockNode
|
|
81
87
|
| AdfHeadingNode
|
|
88
|
+
| AdfExpandNode
|
|
82
89
|
| AdfUnknownBlockNode
|
|
83
90
|
|
|
84
91
|
// Public AdfNode union covers every node shape this module recognizes.
|
|
@@ -90,6 +97,18 @@ const ORDERED_MARKER = /^(\d+)\. (.*)$/
|
|
|
90
97
|
// no whitespace before it. Fence must occupy the whole line.
|
|
91
98
|
const FENCE_OPEN = /^```([^\s`]*)$/
|
|
92
99
|
const FENCE_CLOSE = /^```$/
|
|
100
|
+
// Expand wrapper: `::: expand` or `::: expand title="..."`. Quotes and
|
|
101
|
+
// backslashes in the title are escaped with a leading `\`.
|
|
102
|
+
const EXPAND_OPEN = /^::: expand(?: title="((?:\\.|[^"\\])*)")?$/
|
|
103
|
+
const EXPAND_CLOSE = /^:::$/
|
|
104
|
+
|
|
105
|
+
function unescapeTitle(raw: string): string {
|
|
106
|
+
return raw.replace(/\\(.)/g, '$1')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function escapeTitle(value: string): string {
|
|
110
|
+
return value.replace(/(["\\])/g, '\\$1')
|
|
111
|
+
}
|
|
93
112
|
|
|
94
113
|
function paragraphFromText(text: string): AdfParagraphNode {
|
|
95
114
|
if (text.length === 0) {
|
|
@@ -145,13 +164,26 @@ export function plainTextToAdf(text: string): AdfDocument {
|
|
|
145
164
|
if (text.length === 0) {
|
|
146
165
|
return { version: 1, type: 'doc', content: [] }
|
|
147
166
|
}
|
|
148
|
-
|
|
149
167
|
const lines = text.split('\n')
|
|
150
|
-
const blocks
|
|
168
|
+
const { blocks } = parseBlocks(lines, 0, () => false)
|
|
169
|
+
return { version: 1, type: 'doc', content: blocks }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface ParseResult {
|
|
173
|
+
blocks: AdfBlockNode[]
|
|
174
|
+
// Index just past the last consumed line. When `stop` matches, this points
|
|
175
|
+
// at the stop line itself (the caller is responsible for stepping past it).
|
|
176
|
+
endIndex: number
|
|
177
|
+
}
|
|
151
178
|
|
|
152
|
-
|
|
179
|
+
function parseBlocks(lines: string[], start: number, stop: (line: string) => boolean): ParseResult {
|
|
180
|
+
const blocks: AdfBlockNode[] = []
|
|
181
|
+
let i = start
|
|
153
182
|
while (i < lines.length) {
|
|
154
183
|
const line = lines[i] ?? ''
|
|
184
|
+
if (stop(line)) {
|
|
185
|
+
return { blocks, endIndex: i }
|
|
186
|
+
}
|
|
155
187
|
|
|
156
188
|
// Blank line separates blocks — consume and move on.
|
|
157
189
|
if (line === '') {
|
|
@@ -159,6 +191,19 @@ export function plainTextToAdf(text: string): AdfDocument {
|
|
|
159
191
|
continue
|
|
160
192
|
}
|
|
161
193
|
|
|
194
|
+
// Expand wrapper.
|
|
195
|
+
const expandOpen = line.match(EXPAND_OPEN)
|
|
196
|
+
if (expandOpen) {
|
|
197
|
+
const title = unescapeTitle(expandOpen[1] ?? '')
|
|
198
|
+
const inner = parseBlocks(lines, i + 1, (l) => EXPAND_CLOSE.test(l))
|
|
199
|
+
if (inner.endIndex < lines.length) {
|
|
200
|
+
blocks.push({ type: 'expand', attrs: { title }, content: inner.blocks })
|
|
201
|
+
i = inner.endIndex + 1
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
// Unterminated `::: expand` — fall through to paragraph.
|
|
205
|
+
}
|
|
206
|
+
|
|
162
207
|
// Fenced code block.
|
|
163
208
|
const fenceOpen = line.match(FENCE_OPEN)
|
|
164
209
|
if (fenceOpen) {
|
|
@@ -227,16 +272,19 @@ export function plainTextToAdf(text: string): AdfDocument {
|
|
|
227
272
|
continue
|
|
228
273
|
}
|
|
229
274
|
|
|
230
|
-
// Paragraph: consume until blank line or a
|
|
275
|
+
// Paragraph: consume until blank line, stop predicate, or a
|
|
276
|
+
// block-starting line.
|
|
231
277
|
const paragraphLines: string[] = [line]
|
|
232
278
|
i += 1
|
|
233
279
|
while (i < lines.length) {
|
|
234
280
|
const current = lines[i] ?? ''
|
|
235
281
|
if (
|
|
236
282
|
current === '' ||
|
|
283
|
+
stop(current) ||
|
|
237
284
|
BULLET_MARKER.test(current) ||
|
|
238
285
|
ORDERED_MARKER.test(current) ||
|
|
239
|
-
FENCE_OPEN.test(current)
|
|
286
|
+
FENCE_OPEN.test(current) ||
|
|
287
|
+
EXPAND_OPEN.test(current)
|
|
240
288
|
) {
|
|
241
289
|
break
|
|
242
290
|
}
|
|
@@ -246,7 +294,7 @@ export function plainTextToAdf(text: string): AdfDocument {
|
|
|
246
294
|
blocks.push(paragraphFromText(paragraphLines.join('\n')))
|
|
247
295
|
}
|
|
248
296
|
|
|
249
|
-
return {
|
|
297
|
+
return { blocks, endIndex: i }
|
|
250
298
|
}
|
|
251
299
|
|
|
252
300
|
function inlineText(
|
|
@@ -357,6 +405,14 @@ function renderBlock(node: AdfBlockNode): string | null {
|
|
|
357
405
|
}
|
|
358
406
|
case 'heading':
|
|
359
407
|
return inlineText((node as AdfHeadingNode).content)
|
|
408
|
+
case 'expand': {
|
|
409
|
+
const expand = node as AdfExpandNode
|
|
410
|
+
const title = expand.attrs?.title ?? ''
|
|
411
|
+
const open = title.length > 0 ? `::: expand title="${escapeTitle(title)}"` : '::: expand'
|
|
412
|
+
const inner = renderBlocks(expand.content)
|
|
413
|
+
const body = inner.length > 0 ? `${open}\n${inner}\n:::` : `${open}\n:::`
|
|
414
|
+
return body
|
|
415
|
+
}
|
|
360
416
|
case 'blockCard':
|
|
361
417
|
case 'embedCard': {
|
|
362
418
|
const url = readCardUrl(node)
|
|
@@ -368,12 +424,16 @@ function renderBlock(node: AdfBlockNode): string | null {
|
|
|
368
424
|
}
|
|
369
425
|
}
|
|
370
426
|
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
for (const block of
|
|
427
|
+
function renderBlocks(nodes: AdfBlockNode[]): string {
|
|
428
|
+
const out: string[] = []
|
|
429
|
+
for (const block of nodes) {
|
|
374
430
|
const text = renderBlock(block)
|
|
375
431
|
if (text === null) continue
|
|
376
|
-
|
|
432
|
+
out.push(text)
|
|
377
433
|
}
|
|
378
|
-
return
|
|
434
|
+
return out.join('\n\n')
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function adfToPlainText(doc: AdfDocument): string {
|
|
438
|
+
return renderBlocks(doc.content)
|
|
379
439
|
}
|
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
|
+
}
|