@andypai/agent-kanban 0.5.1 → 0.6.1

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 (52) hide show
  1. package/README.md +42 -19
  2. package/package.json +3 -2
  3. package/src/__tests__/activity.test.ts +12 -0
  4. package/src/__tests__/api.test.ts +4 -2
  5. package/src/__tests__/board-utils.test.ts +16 -3
  6. package/src/__tests__/column-roles.test.ts +34 -0
  7. package/src/__tests__/commands/board.test.ts +7 -0
  8. package/src/__tests__/conflict.test.ts +11 -1
  9. package/src/__tests__/db.test.ts +70 -0
  10. package/src/__tests__/index.test.ts +55 -0
  11. package/src/__tests__/jira-cache.test.ts +56 -0
  12. package/src/__tests__/jira-client.test.ts +98 -1
  13. package/src/__tests__/jira-jql.test.ts +51 -0
  14. package/src/__tests__/jira-provider-mutations.test.ts +84 -59
  15. package/src/__tests__/jira-provider-read.test.ts +227 -20
  16. package/src/__tests__/mcp-server.test.ts +2 -2
  17. package/src/__tests__/metrics.test.ts +37 -1
  18. package/src/__tests__/postgres-jira-provider.test.ts +155 -0
  19. package/src/__tests__/postgres-local-provider.test.ts +90 -1
  20. package/src/__tests__/server.test.ts +126 -0
  21. package/src/__tests__/use-cases.test.ts +77 -0
  22. package/src/__tests__/webhooks.test.ts +75 -22
  23. package/src/api.ts +58 -36
  24. package/src/column-roles.ts +52 -0
  25. package/src/commands/board.ts +4 -4
  26. package/src/db.ts +145 -114
  27. package/src/errors.ts +1 -0
  28. package/src/index.ts +84 -33
  29. package/src/mcp/core.ts +8 -7
  30. package/src/metrics.ts +48 -23
  31. package/src/provider-runtime.ts +9 -2
  32. package/src/providers/index.ts +4 -1
  33. package/src/providers/jira-cache.ts +23 -6
  34. package/src/providers/jira-client.ts +70 -4
  35. package/src/providers/jira-core.ts +921 -0
  36. package/src/providers/jira-jql.ts +48 -0
  37. package/src/providers/jira.ts +111 -677
  38. package/src/providers/linear-cache.ts +5 -3
  39. package/src/providers/linear-core.ts +688 -0
  40. package/src/providers/linear.ts +70 -554
  41. package/src/providers/postgres-jira-cache.ts +597 -0
  42. package/src/providers/postgres-jira.ts +15 -1188
  43. package/src/providers/postgres-linear-cache.ts +500 -0
  44. package/src/providers/postgres-linear.ts +22 -1088
  45. package/src/providers/postgres-local.ts +181 -74
  46. package/src/providers/types.ts +71 -2
  47. package/src/server.ts +118 -32
  48. package/src/use-cases.ts +139 -0
  49. package/src/webhooks.ts +26 -0
  50. package/ui/dist/assets/index-65LcV0R7.js +40 -0
  51. package/ui/dist/index.html +1 -1
  52. package/ui/dist/assets/index-CFhtfqCn.js +0 -40
@@ -194,6 +194,19 @@ function standardSyncRoutes(opts: SyncRoutesOpts): StubRoute[] {
194
194
  match: (u) => u.includes('/rest/api/3/issuetype/project'),
195
195
  handler: () => jsonResponse(opts.issueTypes ?? []),
196
196
  },
197
+ {
198
+ // GET /rest/api/3/issue/{key} backs hydrateIssueByKey's read-after-write.
199
+ // Serves the post-mutation issue from the same pool as /search so tests
200
+ // that push a created/updated issue into `opts.issues` see it here too.
201
+ match: (u, init) =>
202
+ /\/rest\/api\/3\/issue\/[A-Z]+-\d+$/.test(new URL(u).pathname) &&
203
+ (init?.method ?? 'GET') === 'GET',
204
+ handler: (u) => {
205
+ const key = new URL(u).pathname.split('/').pop()!
206
+ const found = (opts.issues ?? []).find((iss) => (iss as { key?: string }).key === key)
207
+ return found ? jsonResponse(found) : jsonResponse({ errorMessages: ['not found'] }, 404)
208
+ },
209
+ },
197
210
  {
198
211
  match: (u) => u.includes('/rest/api/3/search/jql'),
199
212
  handler: () =>
@@ -298,16 +311,16 @@ function fullSyncRoutes(extraIssues: Record<string, unknown>[] = []): StubRoute[
298
311
  describe('JiraProvider mutations', () => {
299
312
  test('createTask happy path: plainTextToAdf invoked, priority mapped, assignee and issueType resolved', async () => {
300
313
  fullSeed(db)
301
- // Shared state so the POST /issue handler appends the created issue, which
302
- // the subsequent sync(true)'s /search handler then returns so getCachedTask
303
- // finds it after the post-mutation sync.
304
- const createdIssues: Record<string, unknown>[] = []
314
+ // Shared issue pool: the POST /issue handler appends the created issue, which
315
+ // hydrateIssueByKey then reads back through the GET /issue/{key} route so the
316
+ // returned task reflects the create.
305
317
  const createdIssue = makeJiraIssueFixture({
306
318
  id: '600',
307
319
  key: 'ENG-10',
308
320
  statusId: '20000',
309
321
  summary: 'Fix',
310
322
  })
323
+ const issues: Record<string, unknown>[] = [makeJiraIssueFixture(seedIssues[0]!)]
311
324
  const syncRoutes: StubRoute[] = standardSyncRoutes({
312
325
  projectKey: 'ENG',
313
326
  columns: [
@@ -317,28 +330,12 @@ describe('JiraProvider mutations', () => {
317
330
  users: seedUsers,
318
331
  priorities: seedPriorities,
319
332
  issueTypes: seedIssueTypes,
320
- issues: [makeJiraIssueFixture(seedIssues[0]!)],
333
+ issues,
321
334
  })
322
- // Replace the /search route to include createdIssues dynamically.
323
- const searchRouteIndex = syncRoutes.findIndex((r) =>
324
- r.match('https://example.atlassian.net/rest/api/3/search/jql'),
325
- )
326
- syncRoutes[searchRouteIndex] = {
327
- match: (u) => u.includes('/rest/api/3/search/jql'),
328
- handler: () => {
329
- const all = [makeJiraIssueFixture(seedIssues[0]!), ...createdIssues]
330
- return jsonResponse({
331
- startAt: 0,
332
- maxResults: 100,
333
- total: all.length,
334
- issues: all,
335
- })
336
- },
337
- }
338
335
  const mutationRoute: StubRoute = {
339
336
  match: (u, init) => u.endsWith('/rest/api/3/issue') && (init?.method ?? 'GET') === 'POST',
340
337
  handler: () => {
341
- createdIssues.push(createdIssue)
338
+ issues.push(createdIssue)
342
339
  return jsonResponse({
343
340
  id: '600',
344
341
  key: 'ENG-10',
@@ -483,6 +480,62 @@ describe('JiraProvider mutations', () => {
483
480
  expect(body.transition.id).toBe('21')
484
481
  })
485
482
 
483
+ test('moveTask ingests the moved issue changelog via hydration (activity recorded immediately)', async () => {
484
+ // seedCache sets a recent lastSyncAt, so moveTask's pre-move sync() is
485
+ // throttled and fetches no changelog. The only changelog call must therefore
486
+ // come from hydrateIssueByKey's read-after-write — proving the transition
487
+ // lands in jira_activity immediately rather than waiting for a later sync.
488
+ seedCache(db, {
489
+ priorities: seedPriorities,
490
+ users: seedUsers,
491
+ issueTypes: seedIssueTypes,
492
+ columns: seedColumns,
493
+ issues: [{ id: '501', key: 'ENG-1', statusId: '20000' }],
494
+ projectKey: 'ENG',
495
+ })
496
+ const syncRoutes = fullSyncRoutes()
497
+ let changelogCalls = 0
498
+ const changelogRoute: StubRoute = {
499
+ match: (u) => /\/rest\/api\/3\/issue\/501\/changelog/.test(new URL(u).pathname),
500
+ handler: () => {
501
+ changelogCalls += 1
502
+ return jsonResponse({
503
+ values: [
504
+ {
505
+ id: 'h1',
506
+ created: '2026-01-06T00:00:00Z',
507
+ items: [{ field: 'status', from: '20000', to: '10001' }],
508
+ },
509
+ ],
510
+ })
511
+ },
512
+ }
513
+ const transitionsRoute: StubRoute = {
514
+ match: (u, init) =>
515
+ u.endsWith('/rest/api/3/issue/ENG-1/transitions') && (init?.method ?? 'GET') === 'GET',
516
+ handler: () =>
517
+ jsonResponse({
518
+ transitions: [{ id: '21', name: 'Done', to: { id: '10001', name: 'Done' } }],
519
+ }),
520
+ }
521
+ const postTransitionRoute: StubRoute = {
522
+ match: (u, init) =>
523
+ u.endsWith('/rest/api/3/issue/ENG-1/transitions') && (init?.method ?? 'GET') === 'POST',
524
+ handler: () => emptyResponse(204),
525
+ }
526
+ const { provider } = makeProvider(db, [
527
+ changelogRoute,
528
+ transitionsRoute,
529
+ postTransitionRoute,
530
+ ...syncRoutes,
531
+ ])
532
+ await provider.moveTask('ENG-1', 'Done')
533
+
534
+ expect(changelogCalls).toBe(1)
535
+ const activity = await provider.getActivity(50, 'jira:501')
536
+ expect(activity.length).toBeGreaterThan(0)
537
+ })
538
+
486
539
  test('moveTask no-match failure: error message names target and lists available transitions', async () => {
487
540
  seedCache(db, {
488
541
  priorities: seedPriorities,
@@ -492,7 +545,9 @@ describe('JiraProvider mutations', () => {
492
545
  issues: [{ id: '501', key: 'ENG-1', statusId: '20000' }],
493
546
  projectKey: 'ENG',
494
547
  })
495
- // No standard sync routes the mutation throws before sync(true).
548
+ // No standard sync/hydrate routes needed: the move resolves against the
549
+ // seeded cache and throws at transition resolution (no transition reaches the
550
+ // target status) before any read-after-write hydration runs.
496
551
  const transitionsRoute: StubRoute = {
497
552
  match: (u, init) =>
498
553
  u.endsWith('/rest/api/3/issue/ENG-1/transitions') && (init?.method ?? 'GET') === 'GET',
@@ -652,13 +707,13 @@ describe('JiraProvider mutations', () => {
652
707
 
653
708
  test('createTask project field omitted is accepted', async () => {
654
709
  fullSeed(db)
655
- const createdIssues: Record<string, unknown>[] = []
656
710
  const createdIssue = makeJiraIssueFixture({
657
711
  id: '600',
658
712
  key: 'ENG-10',
659
713
  statusId: '20000',
660
714
  summary: 'x',
661
715
  })
716
+ const issues: Record<string, unknown>[] = [makeJiraIssueFixture(seedIssues[0]!)]
662
717
  const syncRoutes = standardSyncRoutes({
663
718
  projectKey: 'ENG',
664
719
  columns: [
@@ -668,27 +723,12 @@ describe('JiraProvider mutations', () => {
668
723
  users: seedUsers,
669
724
  priorities: seedPriorities,
670
725
  issueTypes: seedIssueTypes,
671
- issues: [makeJiraIssueFixture(seedIssues[0]!)],
726
+ issues,
672
727
  })
673
- const searchIdx = syncRoutes.findIndex((r) =>
674
- r.match('https://example.atlassian.net/rest/api/3/search/jql'),
675
- )
676
- syncRoutes[searchIdx] = {
677
- match: (u) => u.includes('/rest/api/3/search/jql'),
678
- handler: () => {
679
- const all = [makeJiraIssueFixture(seedIssues[0]!), ...createdIssues]
680
- return jsonResponse({
681
- startAt: 0,
682
- maxResults: 100,
683
- total: all.length,
684
- issues: all,
685
- })
686
- },
687
- }
688
728
  const mutationRoute: StubRoute = {
689
729
  match: (u, init) => u.endsWith('/rest/api/3/issue') && (init?.method ?? 'GET') === 'POST',
690
730
  handler: () => {
691
- createdIssues.push(createdIssue)
731
+ issues.push(createdIssue)
692
732
  return jsonResponse({ id: '600', key: 'ENG-10', self: 'x' })
693
733
  },
694
734
  }
@@ -706,13 +746,13 @@ describe('JiraProvider mutations', () => {
706
746
 
707
747
  test('createTask project field matching configured projectKey is accepted', async () => {
708
748
  fullSeed(db)
709
- const createdIssues: Record<string, unknown>[] = []
710
749
  const createdIssue = makeJiraIssueFixture({
711
750
  id: '601',
712
751
  key: 'ENG-11',
713
752
  statusId: '20000',
714
753
  summary: 'x',
715
754
  })
755
+ const issues: Record<string, unknown>[] = [makeJiraIssueFixture(seedIssues[0]!)]
716
756
  const syncRoutes = standardSyncRoutes({
717
757
  projectKey: 'ENG',
718
758
  columns: [
@@ -722,27 +762,12 @@ describe('JiraProvider mutations', () => {
722
762
  users: seedUsers,
723
763
  priorities: seedPriorities,
724
764
  issueTypes: seedIssueTypes,
725
- issues: [makeJiraIssueFixture(seedIssues[0]!)],
765
+ issues,
726
766
  })
727
- const searchIdx = syncRoutes.findIndex((r) =>
728
- r.match('https://example.atlassian.net/rest/api/3/search/jql'),
729
- )
730
- syncRoutes[searchIdx] = {
731
- match: (u) => u.includes('/rest/api/3/search/jql'),
732
- handler: () => {
733
- const all = [makeJiraIssueFixture(seedIssues[0]!), ...createdIssues]
734
- return jsonResponse({
735
- startAt: 0,
736
- maxResults: 100,
737
- total: all.length,
738
- issues: all,
739
- })
740
- },
741
- }
742
767
  const mutationRoute: StubRoute = {
743
768
  match: (u, init) => u.endsWith('/rest/api/3/issue') && (init?.method ?? 'GET') === 'POST',
744
769
  handler: () => {
745
- createdIssues.push(createdIssue)
770
+ issues.push(createdIssue)
746
771
  return jsonResponse({ id: '601', key: 'ENG-11', self: 'x' })
747
772
  },
748
773
  }
@@ -331,6 +331,85 @@ describe('JiraProvider read path', () => {
331
331
  )
332
332
  })
333
333
 
334
+ test('sync follows the nextPageToken cursor across pages when total is omitted', async () => {
335
+ // /rest/api/3/search/jql omits `total` and paginates by an opaque
336
+ // nextPageToken. The previous offset/total loop stopped after page 1 once
337
+ // `total` came back undefined, so every issue beyond the first 100 (by
338
+ // updated ASC) — including newly-created tickets — was silently dropped.
339
+ const requestedTokens: Array<string | null> = []
340
+ const searchHandler: StubHandler = (url) => {
341
+ const token = new URL(url).searchParams.get('nextPageToken')
342
+ requestedTokens.push(token)
343
+ if (token === null) {
344
+ return jsonResponse({
345
+ nextPageToken: 'page-2',
346
+ isLast: false,
347
+ issues: [makeIssue({ id: '1', key: 'ENG-1', statusId: '10001' })],
348
+ })
349
+ }
350
+ if (token === 'page-2') {
351
+ return jsonResponse({
352
+ isLast: true,
353
+ issues: [makeIssue({ id: '2', key: 'ENG-2', statusId: '10002' })],
354
+ })
355
+ }
356
+ return jsonResponse({ isLast: true, issues: [] })
357
+ }
358
+ const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
359
+ await provider.getBoard()
360
+
361
+ // Both pages land in the cache, not just page 1.
362
+ expect(
363
+ getCachedTasks(db)
364
+ .map((task) => task.externalRef)
365
+ .sort(),
366
+ ).toEqual(['ENG-1', 'ENG-2'])
367
+ // Page 1 sends no cursor; page 2 follows the returned token; then it stops.
368
+ expect(requestedTokens).toEqual([null, 'page-2'])
369
+ })
370
+
371
+ test('sync does not drop a page when an intermediate page is empty but carries a token', async () => {
372
+ // A non-last page may legitimately return zero issues alongside a cursor;
373
+ // the loop must follow the token rather than treat the empty page as the end.
374
+ const searchHandler: StubHandler = (url) => {
375
+ const token = new URL(url).searchParams.get('nextPageToken')
376
+ if (token === null) {
377
+ return jsonResponse({ nextPageToken: 'page-2', isLast: false, issues: [] })
378
+ }
379
+ return jsonResponse({
380
+ isLast: true,
381
+ issues: [makeIssue({ id: '2', key: 'ENG-2', statusId: '10002' })],
382
+ })
383
+ }
384
+ const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
385
+ await provider.getBoard()
386
+ expect(getCachedTasks(db).map((task) => task.externalRef)).toEqual(['ENG-2'])
387
+ })
388
+
389
+ test('sync terminates instead of spinning when the server repeats a cursor token', async () => {
390
+ // A misbehaving server that keeps handing back the same non-last token would
391
+ // otherwise loop forever and hang the poll cycle; the seen-token guard stops
392
+ // after the cursor fails to advance, without dropping the page it did return.
393
+ let call = 0
394
+ const searchHandler: StubHandler = () => {
395
+ call += 1
396
+ return jsonResponse({
397
+ nextPageToken: 'stuck',
398
+ isLast: false,
399
+ issues: [makeIssue({ id: String(call), key: `ENG-${call}`, statusId: '10001' })],
400
+ })
401
+ }
402
+ const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
403
+ await provider.getBoard()
404
+ // Two fetches: the first records token 'stuck', the second sees it repeat and breaks.
405
+ expect(call).toBe(2)
406
+ expect(
407
+ getCachedTasks(db)
408
+ .map((task) => task.externalRef)
409
+ .sort(),
410
+ ).toEqual(['ENG-1', 'ENG-2'])
411
+ })
412
+
334
413
  test('periodic full reconciliation prunes cached issues missing upstream', async () => {
335
414
  const capturedJql: string[] = []
336
415
  let searchCalls = 0
@@ -400,6 +479,91 @@ describe('JiraProvider read path', () => {
400
479
  expect(getCachedTasks(db).map((task) => task.externalRef)).toEqual(['ENG-1'])
401
480
  })
402
481
 
482
+ test('full reconcile with a stalled cursor does not prune issues from the unfetched pages', async () => {
483
+ // If the cursor stalls (a repeated non-last token) mid-scan, seenIssueIds is
484
+ // only partial. Pruning against it would delete issues that exist upstream on
485
+ // pages we never fetched, so an incomplete scan must not prune.
486
+ let stalled = false
487
+ const searchHandler: StubHandler = () => {
488
+ if (!stalled) {
489
+ // First full reconcile: a clean single terminal page with both issues.
490
+ return jsonResponse({
491
+ isLast: true,
492
+ issues: [
493
+ makeIssue({ id: '1', key: 'ENG-1', statusId: '10001' }),
494
+ makeIssue({ id: '2', key: 'ENG-2', statusId: '10001' }),
495
+ ],
496
+ })
497
+ }
498
+ // Later full reconcile: the cursor stalls after returning only ENG-1, so
499
+ // ENG-2's page is never reached.
500
+ return jsonResponse({
501
+ nextPageToken: 'stuck',
502
+ isLast: false,
503
+ issues: [makeIssue({ id: '1', key: 'ENG-1', statusId: '10001' })],
504
+ })
505
+ }
506
+ const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
507
+ const baseNow = originalDateNow()
508
+
509
+ Date.now = () => baseNow
510
+ await provider.getBoard()
511
+ expect(
512
+ getCachedTasks(db)
513
+ .map((task) => task.externalRef)
514
+ .sort(),
515
+ ).toEqual(['ENG-1', 'ENG-2'])
516
+
517
+ // Next full reconcile (past the interval) hits the stalled cursor.
518
+ stalled = true
519
+ Date.now = () => baseNow + 5 * 60_000 + 31_000
520
+ await provider.getBoard()
521
+
522
+ // ENG-2 must survive: the incomplete scan is not authoritative for pruning.
523
+ expect(
524
+ getCachedTasks(db)
525
+ .map((task) => task.externalRef)
526
+ .sort(),
527
+ ).toEqual(['ENG-1', 'ENG-2'])
528
+ })
529
+
530
+ test('full reconcile does not prune when the server reports isLast=false but omits a cursor', async () => {
531
+ // A server can contradict itself: claim more pages (isLast=false) while giving
532
+ // no nextPageToken. That must be treated as an incomplete scan, not a complete
533
+ // one, so a partial result never prunes issues that still exist upstream.
534
+ let degraded = false
535
+ const searchHandler: StubHandler = () => {
536
+ if (!degraded) {
537
+ return jsonResponse({
538
+ isLast: true,
539
+ issues: [
540
+ makeIssue({ id: '1', key: 'ENG-1', statusId: '10001' }),
541
+ makeIssue({ id: '2', key: 'ENG-2', statusId: '10001' }),
542
+ ],
543
+ })
544
+ }
545
+ return jsonResponse({
546
+ isLast: false,
547
+ issues: [makeIssue({ id: '1', key: 'ENG-1', statusId: '10001' })],
548
+ })
549
+ }
550
+ const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
551
+ const baseNow = originalDateNow()
552
+
553
+ Date.now = () => baseNow
554
+ await provider.getBoard()
555
+
556
+ degraded = true
557
+ Date.now = () => baseNow + 5 * 60_000 + 31_000
558
+ await provider.getBoard()
559
+
560
+ expect(
561
+ getCachedTasks(db)
562
+ .map((task) => task.externalRef)
563
+ .sort(),
564
+ ).toEqual(['ENG-1', 'ENG-2'])
565
+ })
566
+
403
567
  test('listTasks filters by columnId with many-to-one mapping', async () => {
404
568
  const { provider } = makeProvider(standardRoutes({}))
405
569
  // Sync first (populates statuses-based columns)
@@ -628,7 +792,7 @@ describe('JiraProvider read path', () => {
628
792
  })
629
793
 
630
794
  test('sync paginates listIssues across multiple pages', async () => {
631
- const searchCalls: Array<{ startAt: number }> = []
795
+ const searchCalls: Array<{ token: string | null }> = []
632
796
  const page1Issues = Array.from({ length: 100 }, (_, i) =>
633
797
  makeIssue({
634
798
  id: String(i + 1),
@@ -645,38 +809,31 @@ describe('JiraProvider read path', () => {
645
809
  updated: '2026-01-02T00:00:00Z',
646
810
  }),
647
811
  )
812
+ // /rest/api/3/search/jql paginates by an opaque nextPageToken (no `total`);
813
+ // the loop follows the cursor until `isLast`.
648
814
  const searchHandler: StubHandler = (url) => {
649
- const parsed = new URL(url)
650
- const startAt = Number(parsed.searchParams.get('startAt') ?? '0')
651
- searchCalls.push({ startAt })
652
- if (startAt === 0) {
815
+ const token = new URL(url).searchParams.get('nextPageToken')
816
+ searchCalls.push({ token })
817
+ if (token === null) {
653
818
  return jsonResponse({
654
- startAt: 0,
655
- maxResults: 100,
656
- total: 150,
819
+ nextPageToken: 'page-2',
820
+ isLast: false,
657
821
  issues: page1Issues,
658
822
  })
659
823
  }
660
- if (startAt === 100) {
824
+ if (token === 'page-2') {
661
825
  return jsonResponse({
662
- startAt: 100,
663
- maxResults: 100,
664
- total: 150,
826
+ isLast: true,
665
827
  issues: page2Issues,
666
828
  })
667
829
  }
668
- return jsonResponse({
669
- startAt,
670
- maxResults: 100,
671
- total: 150,
672
- issues: [],
673
- })
830
+ return jsonResponse({ isLast: true, issues: [] })
674
831
  }
675
832
  const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
676
833
  await provider.getBoard()
677
834
  expect(searchCalls).toHaveLength(2)
678
- expect(searchCalls[0]!.startAt).toBe(0)
679
- expect(searchCalls[1]!.startAt).toBe(100)
835
+ expect(searchCalls[0]!.token).toBeNull()
836
+ expect(searchCalls[1]!.token).toBe('page-2')
680
837
  expect(getCachedTasks(db)).toHaveLength(150)
681
838
  })
682
839
 
@@ -688,4 +845,54 @@ describe('JiraProvider read path', () => {
688
845
  saveTeamInfo(db, null)
689
846
  expect(loadTeamInfo(db)).toBeNull()
690
847
  })
848
+
849
+ // Regression: in server (background-managed) mode, request-path reads must serve
850
+ // the warm cache instead of blocking on a Jira sync — otherwise a stale-throttle
851
+ // read triggers a foreground network sync that can exceed the HTTP idle timeout
852
+ // (observed as /api/board ERR_EMPTY_RESPONSE).
853
+ const seedSyncedMeta = () =>
854
+ saveJiraSyncMeta(db, {
855
+ projectKey: 'ENG',
856
+ lastSyncAt: '2026-01-01T00:00:00.000Z',
857
+ lastFullSyncAt: '2026-01-01T00:00:00.000Z',
858
+ lastIssueUpdatedAt: '2026-01-01T00:00:00.000Z',
859
+ })
860
+ const wellPastThrottle = () => {
861
+ Date.now = () => Date.parse('2026-01-01T01:00:00.000Z') // +1h, throttle long expired
862
+ }
863
+
864
+ test('background-managed reads do NOT foreground-sync once the cache is warm', async () => {
865
+ const { provider, calls } = makeProvider(standardRoutes({}))
866
+ provider.setBackgroundManaged(true)
867
+ seedSyncedMeta()
868
+ wellPastThrottle()
869
+
870
+ await provider.listTasks()
871
+
872
+ expect(calls.some((c) => c.url.includes('/rest/api/3/search/jql'))).toBe(false)
873
+ })
874
+
875
+ test('non-managed reads still foreground-sync when the throttle has expired', async () => {
876
+ const { provider, calls } = makeProvider(standardRoutes({}))
877
+ // backgroundManaged left false (CLI / default)
878
+ seedSyncedMeta()
879
+ wellPastThrottle()
880
+
881
+ await provider.listTasks()
882
+
883
+ expect(calls.some((c) => c.url.includes('/rest/api/3/search/jql'))).toBe(true)
884
+ })
885
+
886
+ test('background warmer (syncCache) still syncs when background-managed', async () => {
887
+ const { provider, calls } = makeProvider(standardRoutes({}))
888
+ provider.setBackgroundManaged(true)
889
+ seedSyncedMeta()
890
+ wellPastThrottle()
891
+
892
+ // syncCache bypasses the managed read-path gate (viaWarmer), so the warmer
893
+ // keeps refreshing the cache even though plain reads would be suppressed.
894
+ await provider.syncCache()
895
+
896
+ expect(calls.some((c) => c.url.includes('/rest/api/3/search/jql'))).toBe(true)
897
+ })
691
898
  })
@@ -85,7 +85,7 @@ function startTrackerServer(
85
85
  url,
86
86
  async close() {
87
87
  await tracker.close()
88
- httpServer.stop(true)
88
+ void httpServer.stop(true)
89
89
  },
90
90
  }
91
91
  }
@@ -246,7 +246,7 @@ describe('createTrackerMcpServer', () => {
246
246
  message: 'Tracker MCP server is closed',
247
247
  })
248
248
  } finally {
249
- runtime.httpServer.stop(true)
249
+ void runtime.httpServer.stop(true)
250
250
  }
251
251
  })
252
252
  })
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test, beforeEach } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
- import { initSchema, seedDefaultColumns, addTask } from '../db'
3
+ import { initSchema, seedDefaultColumns, addTask, addColumn, moveTask } from '../db'
4
4
  import { getBoardMetrics } from '../metrics'
5
5
 
6
6
  let db: Database
@@ -62,3 +62,39 @@ describe('getBoardMetrics', () => {
62
62
  expect(metrics.tasksByPriority).toHaveLength(0)
63
63
  })
64
64
  })
65
+
66
+ describe('getBoardMetrics with custom column names', () => {
67
+ function customBoard(columnNames: string[]): Database {
68
+ const db = new Database(':memory:')
69
+ db.run('PRAGMA foreign_keys = ON')
70
+ initSchema(db)
71
+ for (const name of columnNames) addColumn(db, name)
72
+ return db
73
+ }
74
+
75
+ test('classifies done/in-progress by role despite custom case and spacing', () => {
76
+ const db = customBoard(['Todo', 'In Progress', 'Human Review', 'Merging', 'Done'])
77
+ addTask(db, 'a', { column: 'Todo' })
78
+ const b = addTask(db, 'b', { column: 'Todo' })
79
+ const c = addTask(db, 'c', { column: 'Todo' })
80
+ moveTask(db, b.id, 'In Progress')
81
+ moveTask(db, c.id, 'Done')
82
+
83
+ const metrics = getBoardMetrics(db)
84
+ // 'In Progress' (space + caps) used to never match the literal 'in-progress'.
85
+ expect(metrics.inProgressCount).toBe(1)
86
+ expect(metrics.completedTasks).toBe(1)
87
+ expect(metrics.completionPercent).toBe(33)
88
+ })
89
+
90
+ test('falls back to the terminal column for completed when no done-named column', () => {
91
+ const db = customBoard(['Todo', 'Doing', 'Shipping'])
92
+ const t = addTask(db, 'a', { column: 'Todo' })
93
+ moveTask(db, t.id, 'Shipping')
94
+
95
+ const metrics = getBoardMetrics(db)
96
+ expect(metrics.completedTasks).toBe(1)
97
+ // 'Doing' is a recognized in-progress synonym.
98
+ expect(metrics.inProgressCount).toBe(0)
99
+ })
100
+ })