@andypai/agent-kanban 0.6.1 → 0.6.3

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.
@@ -41,6 +41,7 @@ function linearIssue(
41
41
  nodes: Array<{ id: string }>
42
42
  pageInfo?: { hasNextPage: boolean; endCursor: string | null }
43
43
  }
44
+ team: { id: string } | null
44
45
  }> = {},
45
46
  ) {
46
47
  return {
@@ -88,17 +89,27 @@ describe('LinearProvider sync', () => {
88
89
  }
89
90
 
90
91
  if (body.query.includes('query Users')) {
91
- return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
92
- status: 200,
93
- headers: { 'content-type': 'application/json' },
94
- })
92
+ return new Response(
93
+ JSON.stringify({
94
+ data: { users: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
95
+ }),
96
+ {
97
+ status: 200,
98
+ headers: { 'content-type': 'application/json' },
99
+ },
100
+ )
95
101
  }
96
102
 
97
103
  if (body.query.includes('query Projects')) {
98
- return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
99
- status: 200,
100
- headers: { 'content-type': 'application/json' },
101
- })
104
+ return new Response(
105
+ JSON.stringify({
106
+ data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
107
+ }),
108
+ {
109
+ status: 200,
110
+ headers: { 'content-type': 'application/json' },
111
+ },
112
+ )
102
113
  }
103
114
 
104
115
  if (body.query.includes('query Issues')) {
@@ -209,6 +220,324 @@ describe('LinearProvider sync', () => {
209
220
  expect(created.labels).toEqual(['garage-smoke', 'garage-owner-local'])
210
221
  })
211
222
 
223
+ test('paginates users and projects across multiple pages', async () => {
224
+ let userPages = 0
225
+ let projectPages = 0
226
+ const seenUserAfter: Array<string | null> = []
227
+ const seenProjectAfter: Array<string | null> = []
228
+
229
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
230
+ const body = JSON.parse(String(init?.body)) as {
231
+ query: string
232
+ variables: Record<string, unknown>
233
+ }
234
+
235
+ if (body.query.includes('query TeamSnapshot')) {
236
+ return new Response(
237
+ JSON.stringify({
238
+ data: {
239
+ team: {
240
+ id: 'team-1',
241
+ key: 'R2P',
242
+ name: 'R2pi',
243
+ states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
244
+ },
245
+ },
246
+ }),
247
+ { status: 200, headers: { 'content-type': 'application/json' } },
248
+ )
249
+ }
250
+
251
+ if (body.query.includes('query Users')) {
252
+ userPages += 1
253
+ const after = (body.variables.after as string | null) ?? null
254
+ seenUserAfter.push(after)
255
+ const page = after
256
+ ? {
257
+ nodes: [{ id: 'u2', displayName: 'User Two', active: true }],
258
+ hasNextPage: false,
259
+ endCursor: null,
260
+ }
261
+ : {
262
+ nodes: [{ id: 'u1', displayName: 'User One', active: true }],
263
+ hasNextPage: true,
264
+ endCursor: 'cursor-u1',
265
+ }
266
+ return new Response(
267
+ JSON.stringify({
268
+ data: {
269
+ users: {
270
+ nodes: page.nodes,
271
+ pageInfo: { hasNextPage: page.hasNextPage, endCursor: page.endCursor },
272
+ },
273
+ },
274
+ }),
275
+ { status: 200, headers: { 'content-type': 'application/json' } },
276
+ )
277
+ }
278
+
279
+ if (body.query.includes('query Projects')) {
280
+ projectPages += 1
281
+ const after = (body.variables.after as string | null) ?? null
282
+ seenProjectAfter.push(after)
283
+ const page = after
284
+ ? { nodes: [{ id: 'p2', name: 'Project Two' }], hasNextPage: false, endCursor: null }
285
+ : {
286
+ nodes: [{ id: 'p1', name: 'Project One' }],
287
+ hasNextPage: true,
288
+ endCursor: 'cursor-p1',
289
+ }
290
+ return new Response(
291
+ JSON.stringify({
292
+ data: {
293
+ projects: {
294
+ nodes: page.nodes,
295
+ pageInfo: { hasNextPage: page.hasNextPage, endCursor: page.endCursor },
296
+ },
297
+ },
298
+ }),
299
+ { status: 200, headers: { 'content-type': 'application/json' } },
300
+ )
301
+ }
302
+
303
+ if (body.query.includes('query Issues')) {
304
+ return new Response(
305
+ JSON.stringify({
306
+ data: { issues: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
307
+ }),
308
+ { status: 200, headers: { 'content-type': 'application/json' } },
309
+ )
310
+ }
311
+
312
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
313
+ }) as unknown as typeof fetch
314
+
315
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
316
+ await provider.getBoard()
317
+
318
+ expect(userPages).toBe(2)
319
+ expect(seenUserAfter).toEqual([null, 'cursor-u1'])
320
+ expect(projectPages).toBe(2)
321
+ expect(seenProjectAfter).toEqual([null, 'cursor-p1'])
322
+ })
323
+
324
+ test('createTask rejects an unknown assignee instead of silently dropping it', async () => {
325
+ replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
326
+ saveSyncMeta(db, {
327
+ team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
328
+ lastSyncAt: new Date().toISOString(),
329
+ lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
330
+ })
331
+
332
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
333
+ const body = JSON.parse(String(init?.body)) as { query: string }
334
+ // The assignee resolver should throw before any CreateIssue mutation runs.
335
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
336
+ }) as unknown as typeof fetch
337
+
338
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
339
+ await expect(
340
+ provider.createTask({ title: 'Hello', assignee: 'Ghost User' }),
341
+ ).rejects.toMatchObject({ code: 'PROVIDER_UPSTREAM_ERROR' })
342
+ })
343
+
344
+ test('updateTask rejects an unknown assignee instead of clearing the field', async () => {
345
+ replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
346
+ upsertIssues(db, [
347
+ {
348
+ id: 'issue-1',
349
+ identifier: 'R2P-1',
350
+ title: 'Linear task',
351
+ assigneeId: 'user-1',
352
+ assigneeName: 'Real Person',
353
+ stateId: 'state-1',
354
+ stateName: 'Todo',
355
+ statePosition: 0,
356
+ commentCount: 0,
357
+ createdAt: '2026-01-01T00:00:00Z',
358
+ updatedAt: '2026-01-01T00:00:00Z',
359
+ },
360
+ ])
361
+ saveSyncMeta(db, {
362
+ team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
363
+ lastSyncAt: new Date().toISOString(),
364
+ lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
365
+ })
366
+
367
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
368
+ const body = JSON.parse(String(init?.body)) as { query: string }
369
+ // The resolver should throw before any UpdateIssue mutation runs.
370
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
371
+ }) as unknown as typeof fetch
372
+
373
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
374
+ await expect(provider.updateTask('R2P-1', { assignee: 'Ghost User' })).rejects.toMatchObject({
375
+ code: 'PROVIDER_UPSTREAM_ERROR',
376
+ })
377
+ })
378
+
379
+ test('updateTask clears the assignee when given an empty string', async () => {
380
+ replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
381
+ upsertIssues(db, [
382
+ {
383
+ id: 'issue-1',
384
+ identifier: 'R2P-1',
385
+ title: 'Linear task',
386
+ assigneeId: 'user-1',
387
+ assigneeName: 'Real Person',
388
+ stateId: 'state-1',
389
+ stateName: 'Todo',
390
+ statePosition: 0,
391
+ commentCount: 0,
392
+ createdAt: '2026-01-01T00:00:00Z',
393
+ updatedAt: '2026-01-01T00:00:00Z',
394
+ },
395
+ ])
396
+ saveSyncMeta(db, {
397
+ team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
398
+ lastSyncAt: new Date().toISOString(),
399
+ lastFullSyncAt: new Date().toISOString(),
400
+ lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
401
+ })
402
+
403
+ const updateInputs: Array<Record<string, unknown>> = []
404
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
405
+ const body = JSON.parse(String(init?.body)) as {
406
+ query: string
407
+ variables: { input?: Record<string, unknown> }
408
+ }
409
+
410
+ if (body.query.includes('mutation UpdateIssue')) {
411
+ updateInputs.push(body.variables.input ?? {})
412
+ return new Response(JSON.stringify({ data: { issueUpdate: { success: true } } }), {
413
+ status: 200,
414
+ headers: { 'content-type': 'application/json' },
415
+ })
416
+ }
417
+
418
+ if (body.query.includes('query TeamSnapshot')) {
419
+ return new Response(
420
+ JSON.stringify({
421
+ data: {
422
+ team: {
423
+ id: 'team-1',
424
+ key: 'R2P',
425
+ name: 'R2pi',
426
+ states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
427
+ },
428
+ },
429
+ }),
430
+ { status: 200, headers: { 'content-type': 'application/json' } },
431
+ )
432
+ }
433
+ if (body.query.includes('query Users')) {
434
+ return new Response(
435
+ JSON.stringify({
436
+ data: { users: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
437
+ }),
438
+ {
439
+ status: 200,
440
+ headers: { 'content-type': 'application/json' },
441
+ },
442
+ )
443
+ }
444
+ if (body.query.includes('query Projects')) {
445
+ return new Response(
446
+ JSON.stringify({
447
+ data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
448
+ }),
449
+ {
450
+ status: 200,
451
+ headers: { 'content-type': 'application/json' },
452
+ },
453
+ )
454
+ }
455
+ if (body.query.includes('query Issues')) {
456
+ return new Response(
457
+ JSON.stringify({
458
+ data: {
459
+ issues: {
460
+ nodes: [linearIssue()],
461
+ pageInfo: { hasNextPage: false, endCursor: null },
462
+ },
463
+ },
464
+ }),
465
+ { status: 200, headers: { 'content-type': 'application/json' } },
466
+ )
467
+ }
468
+ if (body.query.includes('query IssueById')) {
469
+ return new Response(JSON.stringify({ data: { issue: linearIssue({ assignee: null }) } }), {
470
+ status: 200,
471
+ headers: { 'content-type': 'application/json' },
472
+ })
473
+ }
474
+ if (body.query.includes('query IssueHistory')) {
475
+ return new Response(
476
+ JSON.stringify({
477
+ data: {
478
+ issue: { history: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
479
+ },
480
+ }),
481
+ { status: 200, headers: { 'content-type': 'application/json' } },
482
+ )
483
+ }
484
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
485
+ }) as unknown as typeof fetch
486
+
487
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
488
+ await provider.updateTask('R2P-1', { assignee: '' })
489
+
490
+ expect(updateInputs).toHaveLength(1)
491
+ expect(updateInputs[0]).toHaveProperty('assigneeId', null)
492
+ })
493
+
494
+ test('updateTask drops the cached row when the hydrated issue left the team', async () => {
495
+ replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
496
+ upsertIssues(db, [
497
+ {
498
+ id: 'issue-1',
499
+ identifier: 'R2P-1',
500
+ title: 'Linear task',
501
+ stateId: 'state-1',
502
+ stateName: 'Todo',
503
+ statePosition: 0,
504
+ commentCount: 0,
505
+ createdAt: '2026-01-01T00:00:00Z',
506
+ updatedAt: '2026-01-01T00:00:00Z',
507
+ },
508
+ ])
509
+ saveSyncMeta(db, {
510
+ team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
511
+ lastSyncAt: new Date().toISOString(),
512
+ lastFullSyncAt: new Date().toISOString(),
513
+ lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
514
+ })
515
+
516
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
517
+ const body = JSON.parse(String(init?.body)) as { query: string }
518
+ if (body.query.includes('mutation UpdateIssue')) {
519
+ return new Response(JSON.stringify({ data: { issueUpdate: { success: true } } }), {
520
+ status: 200,
521
+ headers: { 'content-type': 'application/json' },
522
+ })
523
+ }
524
+ if (body.query.includes('query IssueById')) {
525
+ // Upstream reports the issue now belongs to a different team.
526
+ return new Response(
527
+ JSON.stringify({ data: { issue: linearIssue({ team: { id: 'team-2' } }) } }),
528
+ { status: 200, headers: { 'content-type': 'application/json' } },
529
+ )
530
+ }
531
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
532
+ }) as unknown as typeof fetch
533
+
534
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
535
+ await expect(provider.updateTask('R2P-1', { title: 'Renamed' })).rejects.toMatchObject({
536
+ code: 'TASK_NOT_FOUND',
537
+ })
538
+ expect(getCachedTasks(db)).toHaveLength(0)
539
+ })
540
+
212
541
  test('periodic full sync prunes cached issues missing from upstream', async () => {
213
542
  replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
214
543
  upsertIssues(db, [
@@ -265,17 +594,27 @@ describe('LinearProvider sync', () => {
265
594
  }
266
595
 
267
596
  if (body.query.includes('query Users')) {
268
- return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
269
- status: 200,
270
- headers: { 'content-type': 'application/json' },
271
- })
597
+ return new Response(
598
+ JSON.stringify({
599
+ data: { users: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
600
+ }),
601
+ {
602
+ status: 200,
603
+ headers: { 'content-type': 'application/json' },
604
+ },
605
+ )
272
606
  }
273
607
 
274
608
  if (body.query.includes('query Projects')) {
275
- return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
276
- status: 200,
277
- headers: { 'content-type': 'application/json' },
278
- })
609
+ return new Response(
610
+ JSON.stringify({
611
+ data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
612
+ }),
613
+ {
614
+ status: 200,
615
+ headers: { 'content-type': 'application/json' },
616
+ },
617
+ )
279
618
  }
280
619
 
281
620
  if (body.query.includes('query Issues')) {
@@ -327,6 +666,114 @@ describe('LinearProvider sync', () => {
327
666
  expect(loadSyncMeta(db).lastFullSyncAt).not.toBeNull()
328
667
  })
329
668
 
669
+ test('counts comments beyond the inline first page instead of capping at the page length', async () => {
670
+ saveSyncMeta(db, {
671
+ team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
672
+ lastSyncAt: '2026-01-01T00:00:00Z',
673
+ lastFullSyncAt: '2026-01-01T00:00:00Z',
674
+ lastIssueUpdatedAt: '2026-01-01T00:00:00Z',
675
+ })
676
+
677
+ let commentCountQueries = 0
678
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
679
+ const body = JSON.parse(String(init?.body)) as {
680
+ query: string
681
+ variables: Record<string, unknown>
682
+ }
683
+
684
+ if (body.query.includes('query TeamSnapshot')) {
685
+ return new Response(
686
+ JSON.stringify({
687
+ data: {
688
+ team: {
689
+ id: 'team-1',
690
+ key: 'R2P',
691
+ name: 'R2pi',
692
+ states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
693
+ },
694
+ },
695
+ }),
696
+ { status: 200, headers: { 'content-type': 'application/json' } },
697
+ )
698
+ }
699
+ if (body.query.includes('query Users')) {
700
+ return new Response(
701
+ JSON.stringify({
702
+ data: { users: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
703
+ }),
704
+ { status: 200, headers: { 'content-type': 'application/json' } },
705
+ )
706
+ }
707
+ if (body.query.includes('query Projects')) {
708
+ return new Response(
709
+ JSON.stringify({
710
+ data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
711
+ }),
712
+ { status: 200, headers: { 'content-type': 'application/json' } },
713
+ )
714
+ }
715
+ if (body.query.includes('query IssueCommentCount')) {
716
+ commentCountQueries += 1
717
+ // Second comment page: three more comments, no further pages.
718
+ return new Response(
719
+ JSON.stringify({
720
+ data: {
721
+ issue: {
722
+ comments: {
723
+ nodes: [{ id: 'c3' }, { id: 'c4' }, { id: 'c5' }],
724
+ pageInfo: { hasNextPage: false, endCursor: null },
725
+ },
726
+ },
727
+ },
728
+ }),
729
+ { status: 200, headers: { 'content-type': 'application/json' } },
730
+ )
731
+ }
732
+ if (body.query.includes('query Issues')) {
733
+ return new Response(
734
+ JSON.stringify({
735
+ data: {
736
+ issues: {
737
+ nodes: [
738
+ linearIssue({
739
+ comments: {
740
+ nodes: [{ id: 'c1' }, { id: 'c2' }],
741
+ pageInfo: { hasNextPage: true, endCursor: 'comment-cursor-1' },
742
+ },
743
+ }),
744
+ ],
745
+ pageInfo: { hasNextPage: false, endCursor: null },
746
+ },
747
+ },
748
+ }),
749
+ { status: 200, headers: { 'content-type': 'application/json' } },
750
+ )
751
+ }
752
+ if (body.query.includes('query IssueHistory')) {
753
+ return new Response(
754
+ JSON.stringify({
755
+ data: {
756
+ issue: { history: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
757
+ },
758
+ }),
759
+ { status: 200, headers: { 'content-type': 'application/json' } },
760
+ )
761
+ }
762
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
763
+ }) as unknown as typeof fetch
764
+
765
+ const originalDateNow = Date.now
766
+ Date.now = () => Date.parse('2026-01-01T00:06:00Z')
767
+ try {
768
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
769
+ const task = await provider.getTask('R2P-1')
770
+ expect(task.comment_count).toBe(5)
771
+ expect(commentCountQueries).toBe(1)
772
+ } finally {
773
+ Date.now = originalDateNow
774
+ }
775
+ })
776
+
330
777
  test('polling keeps upstream comment counts instead of resetting them to zero', async () => {
331
778
  upsertIssues(db, [
332
779
  {
@@ -370,17 +817,27 @@ describe('LinearProvider sync', () => {
370
817
  }
371
818
 
372
819
  if (body.query.includes('query Users')) {
373
- return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
374
- status: 200,
375
- headers: { 'content-type': 'application/json' },
376
- })
820
+ return new Response(
821
+ JSON.stringify({
822
+ data: { users: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
823
+ }),
824
+ {
825
+ status: 200,
826
+ headers: { 'content-type': 'application/json' },
827
+ },
828
+ )
377
829
  }
378
830
 
379
831
  if (body.query.includes('query Projects')) {
380
- return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
381
- status: 200,
382
- headers: { 'content-type': 'application/json' },
383
- })
832
+ return new Response(
833
+ JSON.stringify({
834
+ data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
835
+ }),
836
+ {
837
+ status: 200,
838
+ headers: { 'content-type': 'application/json' },
839
+ },
840
+ )
384
841
  }
385
842
 
386
843
  if (body.query.includes('query Issues')) {
@@ -468,17 +925,27 @@ describe('LinearProvider sync', () => {
468
925
  }
469
926
 
470
927
  if (body.query.includes('query Users')) {
471
- return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
472
- status: 200,
473
- headers: { 'content-type': 'application/json' },
474
- })
928
+ return new Response(
929
+ JSON.stringify({
930
+ data: { users: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
931
+ }),
932
+ {
933
+ status: 200,
934
+ headers: { 'content-type': 'application/json' },
935
+ },
936
+ )
475
937
  }
476
938
 
477
939
  if (body.query.includes('query Projects')) {
478
- return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
479
- status: 200,
480
- headers: { 'content-type': 'application/json' },
481
- })
940
+ return new Response(
941
+ JSON.stringify({
942
+ data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
943
+ }),
944
+ {
945
+ status: 200,
946
+ headers: { 'content-type': 'application/json' },
947
+ },
948
+ )
482
949
  }
483
950
 
484
951
  if (body.query.includes('query Issues')) {
@@ -546,17 +1013,27 @@ describe('LinearProvider sync', () => {
546
1013
  }
547
1014
 
548
1015
  if (body.query.includes('query Users')) {
549
- return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
550
- status: 200,
551
- headers: { 'content-type': 'application/json' },
552
- })
1016
+ return new Response(
1017
+ JSON.stringify({
1018
+ data: { users: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
1019
+ }),
1020
+ {
1021
+ status: 200,
1022
+ headers: { 'content-type': 'application/json' },
1023
+ },
1024
+ )
553
1025
  }
554
1026
 
555
1027
  if (body.query.includes('query Projects')) {
556
- return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
557
- status: 200,
558
- headers: { 'content-type': 'application/json' },
559
- })
1028
+ return new Response(
1029
+ JSON.stringify({
1030
+ data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
1031
+ }),
1032
+ {
1033
+ status: 200,
1034
+ headers: { 'content-type': 'application/json' },
1035
+ },
1036
+ )
560
1037
  }
561
1038
 
562
1039
  if (body.query.includes('query Issues')) {
@@ -115,6 +115,60 @@ describe('createTrackerCore', () => {
115
115
  expect(seenCommentBody === 'visible comment').toBe(true)
116
116
  })
117
117
 
118
+ test('filters board tasks via policy and reports the visible task count', async () => {
119
+ const visible = addTask(db, 'visible task')
120
+ addTask(db, 'hidden task')
121
+ const hookResults: Array<{ tool: string; result?: Record<string, unknown> }> = []
122
+
123
+ const core = createTrackerCore<TestScope>({
124
+ provider,
125
+ policy: {
126
+ canReadTicket() {},
127
+ canPostComment() {},
128
+ canUpdateComment() {},
129
+ canMoveTicket() {},
130
+ filterTask(_scope, task) {
131
+ return task.title.startsWith('visible')
132
+ },
133
+ },
134
+ hooks: {
135
+ onToolResult(event) {
136
+ hookResults.push({ tool: event.tool, result: event.result })
137
+ },
138
+ },
139
+ })
140
+
141
+ const board = await core.handlers.getBoard({ scope: { actor: 'agent' } })
142
+ const tasks = board.columns.flatMap((column) => column.tasks)
143
+
144
+ expect(tasks.map((task) => task.id)).toEqual([visible.id])
145
+ expect(hookResults).toEqual([{ tool: 'getBoard', result: { taskCount: 1 } }])
146
+ })
147
+
148
+ test('denies board reads when canReadBoard throws', async () => {
149
+ addTask(db, 'Core task')
150
+ const core = createTrackerCore<TestScope>({
151
+ provider,
152
+ policy: {
153
+ canReadTicket() {},
154
+ canPostComment() {},
155
+ canUpdateComment() {},
156
+ canMoveTicket() {},
157
+ canReadBoard() {
158
+ throw new TrackerMcpError({
159
+ code: 'policy_denied',
160
+ publicMessage: 'forbidden_board',
161
+ })
162
+ },
163
+ },
164
+ })
165
+
166
+ await expect(core.handlers.getBoard({ scope: { actor: 'agent' } })).rejects.toMatchObject({
167
+ code: 'policy_denied',
168
+ publicMessage: 'forbidden_board',
169
+ })
170
+ })
171
+
118
172
  test('normalizes policy denials into TrackerMcpError and reports them through hooks', async () => {
119
173
  const task = addTask(db, 'Core task')
120
174
  const toolErrors: Array<{ tool: string; code: string; message?: string }> = []