@andypai/agent-kanban 0.2.0 → 0.3.0

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.
Files changed (59) hide show
  1. package/README.md +89 -22
  2. package/package.json +4 -2
  3. package/src/__tests__/activity.test.ts +15 -9
  4. package/src/__tests__/api.test.ts +96 -0
  5. package/src/__tests__/board-utils.test.ts +100 -0
  6. package/src/__tests__/commands/board.test.ts +6 -13
  7. package/src/__tests__/conflict.test.ts +64 -0
  8. package/src/__tests__/index.test.ts +233 -56
  9. package/src/__tests__/jira-adf.test.ts +168 -0
  10. package/src/__tests__/jira-cache.test.ts +304 -0
  11. package/src/__tests__/jira-client.test.ts +169 -0
  12. package/src/__tests__/jira-provider-comment.test.ts +281 -0
  13. package/src/__tests__/jira-provider-mutations.test.ts +771 -0
  14. package/src/__tests__/jira-provider-read.test.ts +594 -0
  15. package/src/__tests__/jira-wiring.test.ts +187 -0
  16. package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
  17. package/src/__tests__/linear-provider-comment.test.ts +243 -0
  18. package/src/__tests__/linear-provider-sync.test.ts +493 -0
  19. package/src/__tests__/local-provider-comment.test.ts +60 -0
  20. package/src/__tests__/mcp-core.test.ts +164 -0
  21. package/src/__tests__/mcp-server.test.ts +252 -0
  22. package/src/__tests__/server.test.ts +298 -0
  23. package/src/__tests__/webhooks.test.ts +604 -0
  24. package/src/activity.ts +1 -11
  25. package/src/api.ts +154 -19
  26. package/src/commands/board.ts +1 -11
  27. package/src/commands/mcp.ts +87 -0
  28. package/src/db.ts +115 -3
  29. package/src/errors.ts +2 -0
  30. package/src/id.ts +1 -1
  31. package/src/index.ts +72 -18
  32. package/src/mcp/core.ts +193 -0
  33. package/src/mcp/errors.ts +109 -0
  34. package/src/mcp/index.ts +13 -0
  35. package/src/mcp/server.ts +512 -0
  36. package/src/mcp/types.ts +72 -0
  37. package/src/providers/capabilities.ts +15 -0
  38. package/src/providers/index.ts +31 -1
  39. package/src/providers/jira-adf.ts +275 -0
  40. package/src/providers/jira-cache.ts +625 -0
  41. package/src/providers/jira-client.ts +390 -0
  42. package/src/providers/jira.ts +778 -0
  43. package/src/providers/linear-cache.ts +249 -70
  44. package/src/providers/linear-client.ts +256 -13
  45. package/src/providers/linear.ts +337 -14
  46. package/src/providers/local.ts +68 -17
  47. package/src/providers/types.ts +18 -2
  48. package/src/server.ts +139 -11
  49. package/src/tunnel.ts +79 -0
  50. package/src/types.ts +18 -2
  51. package/src/webhooks.ts +36 -0
  52. package/ui/dist/assets/index-DBnoKL_k.css +1 -0
  53. package/ui/dist/assets/index-qNVJ6clH.js +40 -0
  54. package/ui/dist/index.html +2 -2
  55. package/src/__tests__/commands/task.test.ts +0 -144
  56. package/src/commands/task.ts +0 -117
  57. package/src/fixtures.ts +0 -128
  58. package/ui/dist/assets/index-B8f9NB4z.css +0 -1
  59. package/ui/dist/assets/index-zWp-rB7b.js +0 -40
@@ -0,0 +1,594 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { Database } from 'bun:sqlite'
3
+ import { ErrorCode, KanbanError } from '../errors.ts'
4
+ import { JiraClient } from '../providers/jira-client.ts'
5
+ import { JiraProvider, type JiraProviderConfig } from '../providers/jira.ts'
6
+ import {
7
+ getCachedColumns,
8
+ getCachedTasks,
9
+ decodeColumnStatusIds,
10
+ loadTeamInfo,
11
+ saveJiraSyncMeta,
12
+ saveTeamInfo,
13
+ initJiraCacheSchema,
14
+ } from '../providers/jira-cache.ts'
15
+
16
+ type FetchInit = RequestInit | undefined
17
+ type StubCall = { url: string; init?: FetchInit }
18
+ type StubHandler = (url: string, init?: FetchInit) => Response | Promise<Response>
19
+ type StubRoute = { match: (url: string) => boolean; handler: StubHandler }
20
+
21
+ function jiraFetchStub(routes: StubRoute[]): {
22
+ fn: typeof fetch
23
+ calls: StubCall[]
24
+ } {
25
+ const calls: StubCall[] = []
26
+ const fn = (async (input: string | URL | Request, init?: FetchInit) => {
27
+ const url =
28
+ typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
29
+ calls.push({ url, init })
30
+ for (const r of routes) {
31
+ if (r.match(url)) return r.handler(url, init)
32
+ }
33
+ return new Response('route not stubbed: ' + url, { status: 500 })
34
+ }) as unknown as typeof fetch
35
+ return { fn, calls }
36
+ }
37
+
38
+ function jsonResponse(body: unknown, status = 200): Response {
39
+ return new Response(JSON.stringify(body), {
40
+ status,
41
+ headers: { 'content-type': 'application/json' },
42
+ })
43
+ }
44
+
45
+ const baseConfig: JiraProviderConfig = {
46
+ baseUrl: 'https://example.atlassian.net',
47
+ email: 'user@example.com',
48
+ apiToken: 'token',
49
+ projectKey: 'ENG',
50
+ }
51
+
52
+ const projectFixture = { id: '10000', key: 'ENG', name: 'Engineering' }
53
+ const usersFixture = [
54
+ { accountId: 'a1', displayName: 'Alice', active: true },
55
+ { accountId: 'a2', displayName: 'Bob', active: true },
56
+ ]
57
+ const prioritiesFixture = [
58
+ { id: '1', name: 'Highest' },
59
+ { id: '2', name: 'High' },
60
+ ]
61
+ const issueTypesFixture = [{ id: '10000', name: 'Bug' }]
62
+
63
+ function makeIssue(opts: {
64
+ id: string
65
+ key: string
66
+ statusId: string
67
+ updated?: string
68
+ summary?: string
69
+ assignee?: { accountId: string; displayName: string } | null
70
+ }): Record<string, unknown> {
71
+ return {
72
+ id: opts.id,
73
+ key: opts.key,
74
+ fields: {
75
+ summary: opts.summary ?? opts.key,
76
+ description: null,
77
+ status: { id: opts.statusId, name: 'Status ' + opts.statusId },
78
+ issuetype: { id: '10000', name: 'Bug' },
79
+ priority: { id: '2', name: 'High' },
80
+ assignee: opts.assignee ?? null,
81
+ created: '2026-01-01T00:00:00Z',
82
+ updated: opts.updated ?? '2026-01-02T00:00:00Z',
83
+ project: { id: '10000', key: 'ENG' },
84
+ },
85
+ }
86
+ }
87
+
88
+ const boardConfigFixture = {
89
+ id: 3,
90
+ name: 'ENG Board',
91
+ columnConfig: {
92
+ columns: [{ name: 'Done', statuses: [{ id: '10001' }, { id: '10002' }] }],
93
+ },
94
+ }
95
+
96
+ const statusCategoriesFixture = [
97
+ {
98
+ id: 'cat-1',
99
+ name: 'To Do',
100
+ statuses: [
101
+ { id: '10001', name: 'To Do' },
102
+ { id: '10002', name: 'In Progress' },
103
+ { id: '10003', name: 'Done' },
104
+ ],
105
+ },
106
+ ]
107
+
108
+ function standardRoutes(opts: {
109
+ boardCfg?: unknown
110
+ statuses?: unknown
111
+ users?: unknown
112
+ priorities?: unknown
113
+ issueTypes?: unknown
114
+ changelogHandler?: StubHandler
115
+ searchHandler?: StubHandler
116
+ }): StubRoute[] {
117
+ return [
118
+ {
119
+ match: (u) => u.includes('/rest/api/3/project/ENG/statuses'),
120
+ handler: () => jsonResponse(opts.statuses ?? statusCategoriesFixture),
121
+ },
122
+ {
123
+ match: (u) => u.includes('/rest/api/3/project/ENG'),
124
+ handler: () => jsonResponse(projectFixture),
125
+ },
126
+ {
127
+ match: (u) => u.includes('/rest/agile/1.0/board/'),
128
+ handler: () => jsonResponse(opts.boardCfg ?? boardConfigFixture),
129
+ },
130
+ {
131
+ match: (u) => u.includes('/rest/api/3/user/assignable/search'),
132
+ handler: () => jsonResponse(opts.users ?? usersFixture),
133
+ },
134
+ {
135
+ match: (u) => u.includes('/rest/api/3/priority'),
136
+ handler: () => jsonResponse(opts.priorities ?? prioritiesFixture),
137
+ },
138
+ {
139
+ match: (u) => u.includes('/rest/api/3/issuetype/project'),
140
+ handler: () => jsonResponse(opts.issueTypes ?? issueTypesFixture),
141
+ },
142
+ {
143
+ match: (u) => /\/rest\/api\/3\/issue\/[^/]+\/changelog/.test(u),
144
+ handler:
145
+ opts.changelogHandler ??
146
+ (() =>
147
+ jsonResponse({
148
+ startAt: 0,
149
+ maxResults: 100,
150
+ total: 0,
151
+ isLast: true,
152
+ values: [],
153
+ })),
154
+ },
155
+ {
156
+ match: (u) => u.includes('/rest/api/3/search/jql'),
157
+ handler:
158
+ opts.searchHandler ??
159
+ (() =>
160
+ jsonResponse({
161
+ startAt: 0,
162
+ maxResults: 100,
163
+ total: 0,
164
+ issues: [],
165
+ })),
166
+ },
167
+ ]
168
+ }
169
+
170
+ let db: Database
171
+ let originalFetch: typeof fetch
172
+ let originalDateNow: () => number
173
+
174
+ beforeEach(() => {
175
+ db = new Database(':memory:')
176
+ originalFetch = globalThis.fetch
177
+ originalDateNow = Date.now
178
+ })
179
+
180
+ afterEach(() => {
181
+ globalThis.fetch = originalFetch
182
+ Date.now = originalDateNow
183
+ })
184
+
185
+ function makeProvider(routes: StubRoute[]): {
186
+ provider: JiraProvider
187
+ calls: StubCall[]
188
+ config: JiraProviderConfig
189
+ } {
190
+ const { fn, calls } = jiraFetchStub(routes)
191
+ globalThis.fetch = fn
192
+ const client = new JiraClient({
193
+ baseUrl: baseConfig.baseUrl,
194
+ email: baseConfig.email,
195
+ apiToken: baseConfig.apiToken,
196
+ })
197
+ const provider = new JiraProvider(db, baseConfig, client)
198
+ return { provider, calls, config: baseConfig }
199
+ }
200
+
201
+ function makeProviderWithBoard(
202
+ routes: StubRoute[],
203
+ boardId: number,
204
+ ): {
205
+ provider: JiraProvider
206
+ calls: StubCall[]
207
+ } {
208
+ const { fn, calls } = jiraFetchStub(routes)
209
+ globalThis.fetch = fn
210
+ const cfg = { ...baseConfig, boardId }
211
+ const client = new JiraClient({
212
+ baseUrl: cfg.baseUrl,
213
+ email: cfg.email,
214
+ apiToken: cfg.apiToken,
215
+ })
216
+ const provider = new JiraProvider(db, cfg, client)
217
+ return { provider, calls }
218
+ }
219
+
220
+ describe('JiraProvider read path', () => {
221
+ test('sync populates columns from board with multi-status mapping', async () => {
222
+ const { provider } = makeProviderWithBoard(standardRoutes({}), 3)
223
+ await provider.getBoard()
224
+ const cols = getCachedColumns(db)
225
+ expect(cols).toHaveLength(1)
226
+ expect(cols[0]!.source).toBe('board')
227
+ expect(cols[0]!.position).toBe(0)
228
+ expect(decodeColumnStatusIds(cols[0]!)).toEqual(['10001', '10002'])
229
+ })
230
+
231
+ test('sync populates columns from statuses when boardId is absent', async () => {
232
+ const { provider } = makeProvider(standardRoutes({}))
233
+ await provider.getBoard()
234
+ const cols = getCachedColumns(db)
235
+ expect(cols).toHaveLength(3)
236
+ for (const c of cols) {
237
+ expect(c.source).toBe('status')
238
+ expect(decodeColumnStatusIds(c)).toHaveLength(1)
239
+ }
240
+ })
241
+
242
+ test('sync delta JQL is exactly project = KEY AND updated >= "<ts>" ORDER BY updated ASC', async () => {
243
+ const capturedJql: string[] = []
244
+ // First sync: one issue returned so lastIssueUpdatedAt is set.
245
+ const searchHandler: StubHandler = (url) => {
246
+ const parsed = new URL(url)
247
+ const jql = parsed.searchParams.get('jql') ?? ''
248
+ capturedJql.push(jql)
249
+ const startAt = Number(parsed.searchParams.get('startAt') ?? '0')
250
+ if (startAt === 0 && capturedJql.length === 1) {
251
+ return jsonResponse({
252
+ startAt: 0,
253
+ maxResults: 100,
254
+ total: 1,
255
+ issues: [
256
+ makeIssue({
257
+ id: '99',
258
+ key: 'ENG-99',
259
+ statusId: '10001',
260
+ updated: '2026-01-05T00:00:00Z',
261
+ }),
262
+ ],
263
+ })
264
+ }
265
+ return jsonResponse({ startAt: 0, maxResults: 100, total: 0, issues: [] })
266
+ }
267
+ const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
268
+ await provider.getBoard()
269
+ expect(capturedJql[0]).toBe(
270
+ 'project = ENG AND updated >= "1970-01-01 00:00" ORDER BY updated ASC',
271
+ )
272
+
273
+ // Advance clock past throttle so second sync runs.
274
+ const origNow = originalDateNow
275
+ Date.now = () => origNow() + 31_000
276
+ await provider.getBoard()
277
+ expect(capturedJql[1]).toBe(
278
+ 'project = ENG AND updated >= "2026-01-05T00:00:00Z" ORDER BY updated ASC',
279
+ )
280
+ })
281
+
282
+ test('periodic full reconciliation prunes cached issues missing upstream', async () => {
283
+ const capturedJql: string[] = []
284
+ let searchCalls = 0
285
+ const searchHandler: StubHandler = (url) => {
286
+ const parsed = new URL(url)
287
+ const jql = parsed.searchParams.get('jql') ?? ''
288
+ capturedJql.push(jql)
289
+ searchCalls += 1
290
+ if (searchCalls === 1) {
291
+ return jsonResponse({
292
+ startAt: 0,
293
+ maxResults: 100,
294
+ total: 2,
295
+ issues: [
296
+ makeIssue({
297
+ id: '1',
298
+ key: 'ENG-1',
299
+ statusId: '10001',
300
+ updated: '2026-01-02T00:00:00Z',
301
+ }),
302
+ makeIssue({
303
+ id: '2',
304
+ key: 'ENG-2',
305
+ statusId: '10001',
306
+ updated: '2026-01-03T00:00:00Z',
307
+ }),
308
+ ],
309
+ })
310
+ }
311
+ if (searchCalls === 2) {
312
+ return jsonResponse({ startAt: 0, maxResults: 100, total: 0, issues: [] })
313
+ }
314
+ return jsonResponse({
315
+ startAt: 0,
316
+ maxResults: 100,
317
+ total: 1,
318
+ issues: [
319
+ makeIssue({
320
+ id: '1',
321
+ key: 'ENG-1',
322
+ statusId: '10001',
323
+ updated: '2026-01-04T00:00:00Z',
324
+ }),
325
+ ],
326
+ })
327
+ }
328
+ const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
329
+ const baseNow = originalDateNow()
330
+
331
+ Date.now = () => baseNow
332
+ await provider.getBoard()
333
+ expect(getCachedTasks(db).map((task) => task.externalRef)).toEqual(['ENG-2', 'ENG-1'])
334
+
335
+ Date.now = () => baseNow + 31_000
336
+ await provider.getBoard()
337
+ expect(getCachedTasks(db).map((task) => task.externalRef)).toEqual(['ENG-2', 'ENG-1'])
338
+
339
+ Date.now = () => baseNow + 5 * 60_000 + 31_000
340
+ await provider.getBoard()
341
+
342
+ expect(searchCalls).toBe(3)
343
+ expect(capturedJql).toEqual([
344
+ 'project = ENG AND updated >= "1970-01-01 00:00" ORDER BY updated ASC',
345
+ 'project = ENG AND updated >= "2026-01-03T00:00:00Z" ORDER BY updated ASC',
346
+ 'project = ENG AND updated >= "1970-01-01 00:00" ORDER BY updated ASC',
347
+ ])
348
+ expect(getCachedTasks(db).map((task) => task.externalRef)).toEqual(['ENG-1'])
349
+ })
350
+
351
+ test('listTasks filters by columnId with many-to-one mapping', async () => {
352
+ const { provider } = makeProvider(standardRoutes({}))
353
+ // Sync first (populates statuses-based columns)
354
+ await provider.getBoard()
355
+ // Overwrite with the test-scenario columns + issues directly.
356
+ const { replaceJiraColumns, upsertJiraIssues } = await import('../providers/jira-cache.ts')
357
+ replaceJiraColumns(db, [
358
+ {
359
+ id: 'board:3:Done',
360
+ name: 'Done',
361
+ position: 0,
362
+ statusIds: ['10001', '10002'],
363
+ source: 'board',
364
+ },
365
+ {
366
+ id: 'status:99999',
367
+ name: 'Other',
368
+ position: 1,
369
+ statusIds: ['99999'],
370
+ source: 'status',
371
+ },
372
+ ])
373
+ upsertJiraIssues(db, [
374
+ {
375
+ id: '1',
376
+ key: 'ENG-1',
377
+ summary: 'a',
378
+ descriptionText: '',
379
+ statusId: '10001',
380
+ projectKey: 'ENG',
381
+ createdAt: '2026-01-01T00:00:00Z',
382
+ updatedAt: '2026-01-01T00:00:00Z',
383
+ },
384
+ {
385
+ id: '2',
386
+ key: 'ENG-2',
387
+ summary: 'b',
388
+ descriptionText: '',
389
+ statusId: '10002',
390
+ projectKey: 'ENG',
391
+ createdAt: '2026-01-01T00:00:00Z',
392
+ updatedAt: '2026-01-02T00:00:00Z',
393
+ },
394
+ {
395
+ id: '3',
396
+ key: 'ENG-3',
397
+ summary: 'c',
398
+ descriptionText: '',
399
+ statusId: '99999',
400
+ projectKey: 'ENG',
401
+ createdAt: '2026-01-01T00:00:00Z',
402
+ updatedAt: '2026-01-03T00:00:00Z',
403
+ },
404
+ ])
405
+ // Throttle is set; listTasks will not re-fetch.
406
+ const byName = await provider.listTasks({ column: 'Done' })
407
+ expect(byName).toHaveLength(2)
408
+ const byId = await provider.listTasks({ column: 'status:99999' })
409
+ expect(byId).toHaveLength(1)
410
+ const all = await provider.listTasks({})
411
+ expect(all).toHaveLength(3)
412
+ })
413
+
414
+ test('getTask accepts both id and key', async () => {
415
+ const searchHandler: StubHandler = () =>
416
+ jsonResponse({
417
+ startAt: 0,
418
+ maxResults: 100,
419
+ total: 1,
420
+ issues: [makeIssue({ id: '10001', key: 'ENG-1', statusId: '10001' })],
421
+ })
422
+ const { provider } = makeProvider(standardRoutes({ searchHandler }))
423
+ await provider.getBoard()
424
+ const byId = await provider.getTask('10001')
425
+ expect(byId.externalRef).toBe('ENG-1')
426
+ const byKey = await provider.getTask('ENG-1')
427
+ expect(byKey.externalRef).toBe('ENG-1')
428
+ const byPrefixed = await provider.getTask('jira:10001')
429
+ expect(byPrefixed.externalRef).toBe('ENG-1')
430
+ })
431
+
432
+ test('getBootstrap returns provider=jira and the adapted BoardConfig', async () => {
433
+ const { provider } = makeProvider(standardRoutes({}))
434
+ const result = await provider.getBootstrap()
435
+ expect(result.provider).toBe('jira')
436
+ expect(result.capabilities.taskMove).toBe(true)
437
+ expect(result.capabilities.taskDelete).toBe(false)
438
+ expect(result.capabilities.comment).toBe(true)
439
+ expect(result.config.provider).toBe('jira')
440
+ expect(Array.isArray(result.config.members)).toBe(true)
441
+ expect(result.config.members.length).toBeGreaterThan(0)
442
+ expect(result.config.projects).toEqual(['ENG'])
443
+ expect('priorities' in result.config).toBe(false)
444
+ })
445
+
446
+ test('getContext returns capabilities.taskMove=true and capabilities.taskDelete=false', async () => {
447
+ const { provider } = makeProvider(standardRoutes({}))
448
+ const ctx = await provider.getContext()
449
+ expect(ctx.capabilities.taskMove).toBe(true)
450
+ expect(ctx.capabilities.taskCreate).toBe(true)
451
+ expect(ctx.capabilities.taskUpdate).toBe(true)
452
+ expect(ctx.capabilities.taskDelete).toBe(false)
453
+ expect(ctx.capabilities.comment).toBe(true)
454
+ expect(ctx.capabilities.activity).toBe(false)
455
+ expect(ctx.capabilities.metrics).toBe(false)
456
+ expect(ctx.capabilities.columnCrud).toBe(false)
457
+ expect(ctx.capabilities.bulk).toBe(false)
458
+ expect(ctx.capabilities.configEdit).toBe(false)
459
+ expect(ctx.team).not.toBeNull()
460
+ expect(ctx.team?.name).toBe('Engineering')
461
+ })
462
+
463
+ test('getConfig adapter: members/projects present, priorities/issueTypes absent at BoardConfig level', async () => {
464
+ const { provider } = makeProvider(standardRoutes({}))
465
+ const config = await provider.getConfig()
466
+ expect(config.projects[0]).toBe('ENG')
467
+ expect(config.members.length).toBeGreaterThan(0)
468
+ const keys = Object.keys(config)
469
+ expect(keys).not.toContain('priorities')
470
+ expect(keys).not.toContain('issueTypes')
471
+ expect(keys).not.toContain('users')
472
+ })
473
+
474
+ test('sync throttle: two consecutive sync calls within 30s produce zero remote fetches on the second', async () => {
475
+ const { provider, calls } = makeProvider(standardRoutes({}))
476
+ await provider.getBoard()
477
+ const first = calls.length
478
+ expect(first).toBeGreaterThan(0)
479
+ await provider.getBoard()
480
+ expect(calls.length).toBe(first)
481
+ // Advance Date.now past the throttle window.
482
+ const origNow = originalDateNow
483
+ Date.now = () => origNow() + 31_000
484
+ await provider.getBoard()
485
+ expect(calls.length).toBeGreaterThanOrEqual(first + 2)
486
+ })
487
+
488
+ test('recent webhook timestamps do not stretch polling past the normal 30 second cadence', async () => {
489
+ const { provider, calls } = makeProvider(standardRoutes({}))
490
+ const baseNow = originalDateNow()
491
+
492
+ Date.now = () => baseNow
493
+ await provider.getBoard()
494
+ const first = calls.length
495
+ const webhookAt = new Date(baseNow + 1_000).toISOString()
496
+ saveJiraSyncMeta(db, { lastWebhookAt: webhookAt })
497
+
498
+ Date.now = () => baseNow + 31_000
499
+ await provider.getBoard()
500
+
501
+ expect(calls.length).toBeGreaterThanOrEqual(first + 2)
502
+ })
503
+
504
+ test('listColumns returns Column[] shape, not JiraColumnRow[]', async () => {
505
+ const { provider } = makeProvider(standardRoutes({}))
506
+ const cols = await provider.listColumns()
507
+ expect(cols.length).toBeGreaterThanOrEqual(1)
508
+ for (const col of cols) {
509
+ expect('id' in col).toBe(true)
510
+ expect('name' in col).toBe(true)
511
+ expect('position' in col).toBe(true)
512
+ expect('color' in col).toBe(true)
513
+ expect('created_at' in col).toBe(true)
514
+ expect('updated_at' in col).toBe(true)
515
+ expect('status_ids' in col).toBe(false)
516
+ expect('source' in col).toBe(false)
517
+ }
518
+ })
519
+
520
+ test('listTasks with non-existent column name throws COLUMN_NOT_FOUND', async () => {
521
+ // Board mode so only 'board:3:Done' column exists.
522
+ const { provider } = makeProviderWithBoard(standardRoutes({}), 3)
523
+ await provider.getBoard()
524
+ try {
525
+ await provider.listTasks({ column: 'Bogus' })
526
+ throw new Error('should have thrown')
527
+ } catch (err) {
528
+ expect(err).toBeInstanceOf(KanbanError)
529
+ expect((err as KanbanError).code).toBe(ErrorCode.COLUMN_NOT_FOUND)
530
+ }
531
+ })
532
+
533
+ test('sync paginates listIssues across multiple pages', async () => {
534
+ const searchCalls: Array<{ startAt: number }> = []
535
+ const page1Issues = Array.from({ length: 100 }, (_, i) =>
536
+ makeIssue({
537
+ id: String(i + 1),
538
+ key: `ENG-${i + 1}`,
539
+ statusId: '10001',
540
+ updated: '2026-01-02T00:00:00Z',
541
+ }),
542
+ )
543
+ const page2Issues = Array.from({ length: 50 }, (_, i) =>
544
+ makeIssue({
545
+ id: String(i + 101),
546
+ key: `ENG-${i + 101}`,
547
+ statusId: '10001',
548
+ updated: '2026-01-02T00:00:00Z',
549
+ }),
550
+ )
551
+ const searchHandler: StubHandler = (url) => {
552
+ const parsed = new URL(url)
553
+ const startAt = Number(parsed.searchParams.get('startAt') ?? '0')
554
+ searchCalls.push({ startAt })
555
+ if (startAt === 0) {
556
+ return jsonResponse({
557
+ startAt: 0,
558
+ maxResults: 100,
559
+ total: 150,
560
+ issues: page1Issues,
561
+ })
562
+ }
563
+ if (startAt === 100) {
564
+ return jsonResponse({
565
+ startAt: 100,
566
+ maxResults: 100,
567
+ total: 150,
568
+ issues: page2Issues,
569
+ })
570
+ }
571
+ return jsonResponse({
572
+ startAt,
573
+ maxResults: 100,
574
+ total: 150,
575
+ issues: [],
576
+ })
577
+ }
578
+ const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
579
+ await provider.getBoard()
580
+ expect(searchCalls).toHaveLength(2)
581
+ expect(searchCalls[0]!.startAt).toBe(0)
582
+ expect(searchCalls[1]!.startAt).toBe(100)
583
+ expect(getCachedTasks(db)).toHaveLength(150)
584
+ })
585
+
586
+ test('saveTeamInfo / loadTeamInfo roundtrip', () => {
587
+ initJiraCacheSchema(db)
588
+ saveTeamInfo(db, { id: '10000', key: 'ENG', name: 'Engineering' })
589
+ const loaded = loadTeamInfo(db)
590
+ expect(loaded).toEqual({ id: '10000', key: 'ENG', name: 'Engineering' })
591
+ saveTeamInfo(db, null)
592
+ expect(loadTeamInfo(db)).toBeNull()
593
+ })
594
+ })