@andypai/agent-kanban 0.2.0 → 0.3.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 (72) hide show
  1. package/README.md +120 -24
  2. package/package.json +4 -2
  3. package/src/__tests__/activity.test.ts +16 -10
  4. package/src/__tests__/api.test.ts +99 -3
  5. package/src/__tests__/board-utils.test.ts +100 -0
  6. package/src/__tests__/commands/board.test.ts +7 -14
  7. package/src/__tests__/commands/bulk.test.ts +3 -3
  8. package/src/__tests__/commands/column.test.ts +4 -4
  9. package/src/__tests__/conflict.test.ts +64 -0
  10. package/src/__tests__/db.test.ts +2 -2
  11. package/src/__tests__/id.test.ts +1 -1
  12. package/src/__tests__/index.test.ts +233 -56
  13. package/src/__tests__/jira-adf.test.ts +180 -0
  14. package/src/__tests__/jira-cache.test.ts +304 -0
  15. package/src/__tests__/jira-client.test.ts +169 -0
  16. package/src/__tests__/jira-provider-comment.test.ts +281 -0
  17. package/src/__tests__/jira-provider-mutations.test.ts +771 -0
  18. package/src/__tests__/jira-provider-read.test.ts +594 -0
  19. package/src/__tests__/jira-wiring.test.ts +187 -0
  20. package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
  21. package/src/__tests__/linear-provider-comment.test.ts +243 -0
  22. package/src/__tests__/linear-provider-sync.test.ts +488 -0
  23. package/src/__tests__/local-provider-comment.test.ts +60 -0
  24. package/src/__tests__/mcp-core.test.ts +164 -0
  25. package/src/__tests__/mcp-server.test.ts +252 -0
  26. package/src/__tests__/metrics.test.ts +2 -2
  27. package/src/__tests__/output.test.ts +1 -1
  28. package/src/__tests__/provider-capabilities.test.ts +40 -0
  29. package/src/__tests__/server.test.ts +291 -0
  30. package/src/__tests__/webhooks.test.ts +604 -0
  31. package/src/activity.ts +2 -12
  32. package/src/api.ts +156 -21
  33. package/src/commands/board.ts +4 -14
  34. package/src/commands/bulk.ts +4 -4
  35. package/src/commands/column.ts +4 -4
  36. package/src/commands/mcp.ts +87 -0
  37. package/src/config.ts +1 -1
  38. package/src/db.ts +118 -6
  39. package/src/errors.ts +2 -0
  40. package/src/id.ts +1 -1
  41. package/src/index.ts +83 -35
  42. package/src/mcp/core.ts +193 -0
  43. package/src/mcp/errors.ts +109 -0
  44. package/src/mcp/index.ts +13 -0
  45. package/src/mcp/server.ts +512 -0
  46. package/src/mcp/types.ts +72 -0
  47. package/src/metrics.ts +1 -1
  48. package/src/output.ts +1 -1
  49. package/src/providers/capabilities.ts +22 -17
  50. package/src/providers/errors.ts +1 -1
  51. package/src/providers/index.ts +36 -6
  52. package/src/providers/jira-adf.ts +275 -0
  53. package/src/providers/jira-cache.ts +625 -0
  54. package/src/providers/jira-client.ts +390 -0
  55. package/src/providers/jira.ts +773 -0
  56. package/src/providers/linear-cache.ts +250 -71
  57. package/src/providers/linear-client.ts +255 -15
  58. package/src/providers/linear.ts +338 -20
  59. package/src/providers/local.ts +74 -23
  60. package/src/providers/types.ts +19 -3
  61. package/src/server.ts +141 -13
  62. package/src/tunnel.ts +79 -0
  63. package/src/types.ts +19 -2
  64. package/src/webhooks.ts +36 -0
  65. package/ui/dist/assets/index-DBnoKL_k.css +1 -0
  66. package/ui/dist/assets/index-qNVJ6clH.js +40 -0
  67. package/ui/dist/index.html +2 -2
  68. package/src/__tests__/commands/task.test.ts +0 -144
  69. package/src/commands/task.ts +0 -117
  70. package/src/fixtures.ts +0 -128
  71. package/ui/dist/assets/index-B8f9NB4z.css +0 -1
  72. package/ui/dist/assets/index-zWp-rB7b.js +0 -40
@@ -0,0 +1,488 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { Database } from 'bun:sqlite'
3
+ import { LinearProvider } from '../providers/linear'
4
+ import {
5
+ getCachedTasks,
6
+ initLinearCacheSchema,
7
+ loadSyncMeta,
8
+ replaceStates,
9
+ saveSyncMeta,
10
+ upsertIssues,
11
+ } from '../providers/linear-cache'
12
+
13
+ let db: Database
14
+ let originalFetch: typeof fetch
15
+
16
+ beforeEach(() => {
17
+ db = new Database(':memory:')
18
+ initLinearCacheSchema(db)
19
+ originalFetch = globalThis.fetch
20
+ })
21
+
22
+ afterEach(() => {
23
+ globalThis.fetch = originalFetch
24
+ })
25
+
26
+ function linearIssue(
27
+ overrides: Partial<{
28
+ id: string
29
+ identifier: string
30
+ title: string
31
+ description: string
32
+ priority: number
33
+ url: string
34
+ createdAt: string
35
+ updatedAt: string
36
+ assignee: { id: string; name?: string | null; displayName?: string | null } | null
37
+ project: { id: string; name: string; url?: string | null; state?: string | null } | null
38
+ state: { id: string; name: string; position: number }
39
+ labels: { nodes: Array<{ id: string; name: string }> }
40
+ comments: {
41
+ nodes: Array<{ id: string }>
42
+ pageInfo?: { hasNextPage: boolean; endCursor: string | null }
43
+ }
44
+ }> = {},
45
+ ) {
46
+ return {
47
+ id: 'issue-1',
48
+ identifier: 'R2P-1',
49
+ title: 'Linear task',
50
+ description: '',
51
+ priority: 2,
52
+ url: 'https://linear.app/x/issue/R2P-1',
53
+ createdAt: '2026-01-01T00:00:00Z',
54
+ updatedAt: '2026-01-02T00:00:00Z',
55
+ assignee: null,
56
+ project: null,
57
+ state: { id: 'state-1', name: 'Todo', position: 0 },
58
+ labels: { nodes: [] },
59
+ comments: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } },
60
+ ...overrides,
61
+ }
62
+ }
63
+
64
+ describe('LinearProvider sync', () => {
65
+ test('resolves a configured team key before querying issues', async () => {
66
+ const seenIssueTeamIds: string[] = []
67
+
68
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
69
+ const body = JSON.parse(String(init?.body)) as {
70
+ query: string
71
+ variables: Record<string, unknown>
72
+ }
73
+
74
+ if (body.query.includes('query TeamSnapshot')) {
75
+ return new Response(
76
+ JSON.stringify({
77
+ data: {
78
+ team: {
79
+ id: '3ca24047-e954-44e8-b266-c7182410befb',
80
+ key: 'R2P',
81
+ name: 'R2pi',
82
+ states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
83
+ },
84
+ },
85
+ }),
86
+ { status: 200, headers: { 'content-type': 'application/json' } },
87
+ )
88
+ }
89
+
90
+ 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
+ })
95
+ }
96
+
97
+ 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
+ })
102
+ }
103
+
104
+ if (body.query.includes('query Issues')) {
105
+ seenIssueTeamIds.push(String(body.variables.teamId))
106
+ return new Response(
107
+ JSON.stringify({
108
+ data: {
109
+ issues: {
110
+ nodes: [],
111
+ pageInfo: { hasNextPage: false, endCursor: null },
112
+ },
113
+ },
114
+ }),
115
+ { status: 200, headers: { 'content-type': 'application/json' } },
116
+ )
117
+ }
118
+
119
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
120
+ }) as unknown as typeof fetch
121
+
122
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
123
+ await provider.getBoard()
124
+
125
+ expect(seenIssueTeamIds).toEqual(['3ca24047-e954-44e8-b266-c7182410befb'])
126
+ })
127
+
128
+ test('createTask uses the resolved team UUID from cached sync meta', async () => {
129
+ replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
130
+ saveSyncMeta(db, {
131
+ team: { id: '3ca24047-e954-44e8-b266-c7182410befb', key: 'R2P', name: 'R2pi' },
132
+ lastSyncAt: new Date().toISOString(),
133
+ lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
134
+ })
135
+
136
+ let createIssueTeamId: string | null = null
137
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
138
+ const body = JSON.parse(String(init?.body)) as {
139
+ query: string
140
+ variables: { input?: { teamId?: string } }
141
+ }
142
+
143
+ if (body.query.includes('mutation CreateIssue')) {
144
+ createIssueTeamId = body.variables.input?.teamId ?? null
145
+ return new Response(
146
+ JSON.stringify({
147
+ data: {
148
+ issueCreate: {
149
+ success: true,
150
+ issue: {
151
+ id: 'issue-1',
152
+ identifier: 'R2P-1',
153
+ title: 'Hello',
154
+ description: '',
155
+ priority: 3,
156
+ url: 'https://linear.app/x/issue/R2P-1',
157
+ createdAt: '2026-01-01T00:00:00Z',
158
+ updatedAt: '2026-01-01T00:00:00Z',
159
+ assignee: null,
160
+ project: null,
161
+ state: { id: 'state-1', name: 'Todo', position: 0 },
162
+ labels: { nodes: [] },
163
+ comments: {
164
+ nodes: [],
165
+ pageInfo: { hasNextPage: false, endCursor: null },
166
+ },
167
+ },
168
+ },
169
+ },
170
+ }),
171
+ { status: 200, headers: { 'content-type': 'application/json' } },
172
+ )
173
+ }
174
+
175
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
176
+ }) as unknown as typeof fetch
177
+
178
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
179
+ const created = await provider.createTask({ title: 'Hello' })
180
+
181
+ expect(String(createIssueTeamId)).toBe('3ca24047-e954-44e8-b266-c7182410befb')
182
+ expect(created.externalRef).toBe('R2P-1')
183
+ })
184
+
185
+ test('periodic full sync prunes cached issues missing from upstream', async () => {
186
+ replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
187
+ upsertIssues(db, [
188
+ {
189
+ id: 'issue-1',
190
+ identifier: 'R2P-1',
191
+ title: 'Keep me',
192
+ stateId: 'state-1',
193
+ stateName: 'Todo',
194
+ statePosition: 0,
195
+ commentCount: 1,
196
+ createdAt: '2026-01-01T00:00:00Z',
197
+ updatedAt: '2026-01-01T00:00:00Z',
198
+ },
199
+ {
200
+ id: 'issue-stale',
201
+ identifier: 'R2P-9',
202
+ title: 'Delete me',
203
+ stateId: 'state-1',
204
+ stateName: 'Todo',
205
+ statePosition: 0,
206
+ commentCount: 3,
207
+ createdAt: '2026-01-01T00:00:00Z',
208
+ updatedAt: '2026-01-01T00:00:00Z',
209
+ },
210
+ ])
211
+ saveSyncMeta(db, {
212
+ team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
213
+ lastSyncAt: '2026-01-01T00:00:00Z',
214
+ lastFullSyncAt: '2026-01-01T00:00:00Z',
215
+ lastIssueUpdatedAt: '2026-01-01T00:00:00Z',
216
+ })
217
+
218
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
219
+ const body = JSON.parse(String(init?.body)) as {
220
+ query: string
221
+ variables: Record<string, unknown>
222
+ }
223
+
224
+ if (body.query.includes('query TeamSnapshot')) {
225
+ return new Response(
226
+ JSON.stringify({
227
+ data: {
228
+ team: {
229
+ id: 'team-1',
230
+ key: 'R2P',
231
+ name: 'R2pi',
232
+ states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
233
+ },
234
+ },
235
+ }),
236
+ { status: 200, headers: { 'content-type': 'application/json' } },
237
+ )
238
+ }
239
+
240
+ if (body.query.includes('query Users')) {
241
+ return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
242
+ status: 200,
243
+ headers: { 'content-type': 'application/json' },
244
+ })
245
+ }
246
+
247
+ if (body.query.includes('query Projects')) {
248
+ return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
249
+ status: 200,
250
+ headers: { 'content-type': 'application/json' },
251
+ })
252
+ }
253
+
254
+ if (body.query.includes('query Issues')) {
255
+ return new Response(
256
+ JSON.stringify({
257
+ data: {
258
+ issues: {
259
+ nodes: [linearIssue({ comments: { nodes: [{ id: 'c1' }, { id: 'c2' }] } })],
260
+ pageInfo: { hasNextPage: false, endCursor: null },
261
+ },
262
+ },
263
+ }),
264
+ { status: 200, headers: { 'content-type': 'application/json' } },
265
+ )
266
+ }
267
+
268
+ if (body.query.includes('query IssueHistory')) {
269
+ return new Response(
270
+ JSON.stringify({
271
+ data: {
272
+ issue: {
273
+ history: {
274
+ nodes: [],
275
+ pageInfo: { hasNextPage: false, endCursor: null },
276
+ },
277
+ },
278
+ },
279
+ }),
280
+ { status: 200, headers: { 'content-type': 'application/json' } },
281
+ )
282
+ }
283
+
284
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
285
+ }) as unknown as typeof fetch
286
+
287
+ const originalDateNow = Date.now
288
+ Date.now = () => Date.parse('2026-01-01T00:06:00Z')
289
+
290
+ try {
291
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
292
+ await provider.getBoard()
293
+ } finally {
294
+ Date.now = originalDateNow
295
+ }
296
+
297
+ const tasks = getCachedTasks(db)
298
+ expect(tasks.map((task) => task.externalRef)).toEqual(['R2P-1'])
299
+ expect(tasks[0]?.comment_count).toBe(2)
300
+ expect(loadSyncMeta(db).lastFullSyncAt).not.toBeNull()
301
+ })
302
+
303
+ test('polling keeps upstream comment counts instead of resetting them to zero', async () => {
304
+ upsertIssues(db, [
305
+ {
306
+ id: 'issue-1',
307
+ identifier: 'R2P-1',
308
+ title: 'Linear task',
309
+ stateId: 'state-1',
310
+ stateName: 'Todo',
311
+ statePosition: 0,
312
+ commentCount: 4,
313
+ createdAt: '2026-01-01T00:00:00Z',
314
+ updatedAt: '2026-01-01T00:00:00Z',
315
+ },
316
+ ])
317
+ saveSyncMeta(db, {
318
+ team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
319
+ lastSyncAt: '2026-01-01T00:00:00Z',
320
+ lastFullSyncAt: '2026-01-01T00:00:00Z',
321
+ lastIssueUpdatedAt: '2026-01-01T00:00:00Z',
322
+ })
323
+
324
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
325
+ const body = JSON.parse(String(init?.body)) as {
326
+ query: string
327
+ }
328
+
329
+ if (body.query.includes('query TeamSnapshot')) {
330
+ return new Response(
331
+ JSON.stringify({
332
+ data: {
333
+ team: {
334
+ id: 'team-1',
335
+ key: 'R2P',
336
+ name: 'R2pi',
337
+ states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
338
+ },
339
+ },
340
+ }),
341
+ { status: 200, headers: { 'content-type': 'application/json' } },
342
+ )
343
+ }
344
+
345
+ if (body.query.includes('query Users')) {
346
+ return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
347
+ status: 200,
348
+ headers: { 'content-type': 'application/json' },
349
+ })
350
+ }
351
+
352
+ if (body.query.includes('query Projects')) {
353
+ return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
354
+ status: 200,
355
+ headers: { 'content-type': 'application/json' },
356
+ })
357
+ }
358
+
359
+ if (body.query.includes('query Issues')) {
360
+ return new Response(
361
+ JSON.stringify({
362
+ data: {
363
+ issues: {
364
+ nodes: [
365
+ linearIssue({
366
+ comments: {
367
+ nodes: Array.from({ length: 7 }, (_, index) => ({ id: `c${index}` })),
368
+ },
369
+ }),
370
+ ],
371
+ pageInfo: { hasNextPage: false, endCursor: null },
372
+ },
373
+ },
374
+ }),
375
+ { status: 200, headers: { 'content-type': 'application/json' } },
376
+ )
377
+ }
378
+
379
+ if (body.query.includes('query IssueHistory')) {
380
+ return new Response(
381
+ JSON.stringify({
382
+ data: {
383
+ issue: {
384
+ history: {
385
+ nodes: [],
386
+ pageInfo: { hasNextPage: false, endCursor: null },
387
+ },
388
+ },
389
+ },
390
+ }),
391
+ { status: 200, headers: { 'content-type': 'application/json' } },
392
+ )
393
+ }
394
+
395
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
396
+ }) as unknown as typeof fetch
397
+
398
+ const originalDateNow = Date.now
399
+ Date.now = () => Date.parse('2026-01-01T00:06:00Z')
400
+
401
+ try {
402
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
403
+ const task = await provider.getTask('R2P-1')
404
+ expect(task.comment_count).toBe(7)
405
+ } finally {
406
+ Date.now = originalDateNow
407
+ }
408
+ })
409
+
410
+ test('recent webhook traffic does not stretch polling beyond the normal interval', async () => {
411
+ let issueQueries = 0
412
+
413
+ saveSyncMeta(db, {
414
+ team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
415
+ lastSyncAt: '2026-01-01T00:00:00.000Z',
416
+ lastFullSyncAt: '2026-01-01T00:00:00.000Z',
417
+ lastIssueUpdatedAt: '2026-01-01T00:00:00.000Z',
418
+ lastWebhookAt: '2026-01-01T00:00:30.000Z',
419
+ })
420
+
421
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
422
+ const body = JSON.parse(String(init?.body)) as {
423
+ query: string
424
+ variables: Record<string, unknown>
425
+ }
426
+
427
+ if (body.query.includes('query TeamSnapshot')) {
428
+ return new Response(
429
+ JSON.stringify({
430
+ data: {
431
+ team: {
432
+ id: 'team-1',
433
+ key: 'R2P',
434
+ name: 'R2pi',
435
+ states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
436
+ },
437
+ },
438
+ }),
439
+ { status: 200, headers: { 'content-type': 'application/json' } },
440
+ )
441
+ }
442
+
443
+ if (body.query.includes('query Users')) {
444
+ return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
445
+ status: 200,
446
+ headers: { 'content-type': 'application/json' },
447
+ })
448
+ }
449
+
450
+ if (body.query.includes('query Projects')) {
451
+ return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
452
+ status: 200,
453
+ headers: { 'content-type': 'application/json' },
454
+ })
455
+ }
456
+
457
+ if (body.query.includes('query Issues')) {
458
+ issueQueries += 1
459
+ expect(body.variables.updatedAfter).toBe('2026-01-01T00:00:00.000Z')
460
+ return new Response(
461
+ JSON.stringify({
462
+ data: {
463
+ issues: {
464
+ nodes: [],
465
+ pageInfo: { hasNextPage: false, endCursor: null },
466
+ },
467
+ },
468
+ }),
469
+ { status: 200, headers: { 'content-type': 'application/json' } },
470
+ )
471
+ }
472
+
473
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
474
+ }) as unknown as typeof fetch
475
+
476
+ const originalDateNow = Date.now
477
+ Date.now = () => Date.parse('2026-01-01T00:00:31.000Z')
478
+
479
+ try {
480
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
481
+ await provider.getBoard()
482
+ } finally {
483
+ Date.now = originalDateNow
484
+ }
485
+
486
+ expect(issueQueries).toBe(1)
487
+ })
488
+ })
@@ -0,0 +1,60 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import { Database } from 'bun:sqlite'
3
+ import { initSchema, seedDefaultColumns, addTask } from '../db'
4
+ import { LocalProvider } from '../providers/local'
5
+
6
+ let db: Database
7
+ let provider: LocalProvider
8
+
9
+ beforeEach(() => {
10
+ db = new Database(':memory:')
11
+ db.run('PRAGMA foreign_keys = ON')
12
+ initSchema(db)
13
+ seedDefaultColumns(db)
14
+ provider = new LocalProvider(db, ':memory:')
15
+ })
16
+
17
+ describe('LocalProvider.comment', () => {
18
+ test('creates a stored comment, updates task counts, and advertises comment capability', async () => {
19
+ const task = addTask(db, 'Comment me')
20
+
21
+ const comment = await provider.comment(task.id, 'hello from local')
22
+
23
+ expect(comment.task_id).toBe(task.id)
24
+ expect(comment.body).toBe('hello from local')
25
+ expect((await provider.getTask(task.id)).comment_count).toBe(1)
26
+ const activity = await provider.getActivity(10, task.id)
27
+ expect(activity[0]?.action).toBe('updated')
28
+ expect(activity[0]?.field_changed).toBe('comment')
29
+ expect(activity[0]?.new_value).toBe('hello from local')
30
+
31
+ const context = await provider.getContext()
32
+ expect(context.capabilities.comment).toBe(true)
33
+ })
34
+
35
+ test('lists stored comments in creation order', async () => {
36
+ const task = addTask(db, 'Comment me')
37
+
38
+ const first = await provider.comment(task.id, 'first comment')
39
+ const second = await provider.comment(task.id, 'second comment')
40
+ const comments = await provider.listComments(task.id)
41
+
42
+ expect(comments.map((comment) => comment.id)).toEqual([first.id, second.id])
43
+ expect(comments.map((comment) => comment.body)).toEqual(['first comment', 'second comment'])
44
+ })
45
+
46
+ test('updates a stored comment body', async () => {
47
+ const task = addTask(db, 'Comment me')
48
+ const comment = await provider.comment(task.id, 'hello from local')
49
+
50
+ const updated = await provider.updateComment(task.id, comment.id, 'edited local comment')
51
+
52
+ expect(updated.id).toBe(comment.id)
53
+ expect(updated.body).toBe('edited local comment')
54
+ const activity = await provider.getActivity(10, task.id)
55
+ expect(activity[0]?.action).toBe('updated')
56
+ expect(activity[0]?.field_changed).toBe('comment')
57
+ expect(activity[0]?.old_value).toBe('hello from local')
58
+ expect(activity[0]?.new_value).toBe('edited local comment')
59
+ })
60
+ })
@@ -0,0 +1,164 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import { Database } from 'bun:sqlite'
3
+ import { addTask, initSchema, seedDefaultColumns } from '../db'
4
+ import { createTrackerCore } from '../mcp/core'
5
+ import { TrackerMcpError } from '../mcp/errors'
6
+ import { LocalProvider } from '../providers/local'
7
+
8
+ interface TestScope {
9
+ actor: string
10
+ }
11
+
12
+ let db: Database
13
+ let provider: LocalProvider
14
+
15
+ beforeEach(() => {
16
+ db = new Database(':memory:')
17
+ db.run('PRAGMA foreign_keys = ON')
18
+ initSchema(db)
19
+ seedDefaultColumns(db)
20
+ provider = new LocalProvider(db, ':memory:')
21
+ })
22
+
23
+ describe('createTrackerCore', () => {
24
+ test('runs allowed handlers and reports hook metadata', async () => {
25
+ const task = addTask(db, 'Core task')
26
+ const hookEvents: Array<{ tool: string; result?: Record<string, unknown> }> = []
27
+ const core = createTrackerCore<TestScope>({
28
+ provider,
29
+ policy: {
30
+ canReadTicket() {},
31
+ canPostComment() {},
32
+ canUpdateComment() {},
33
+ canMoveTicket() {},
34
+ },
35
+ hooks: {
36
+ onToolResult(event) {
37
+ hookEvents.push({ tool: event.tool, result: event.result })
38
+ },
39
+ },
40
+ })
41
+
42
+ const created = await core.handlers.postComment({
43
+ scope: { actor: 'agent' },
44
+ ticketId: task.id,
45
+ body: 'hello from core',
46
+ })
47
+ const updated = await core.handlers.updateComment({
48
+ scope: { actor: 'agent' },
49
+ ticketId: task.id,
50
+ commentId: created.id,
51
+ body: 'edited by core',
52
+ })
53
+ const comments = await core.handlers.listComments({
54
+ scope: { actor: 'agent' },
55
+ ticketId: task.id,
56
+ })
57
+ await core.handlers.moveTicket({
58
+ scope: { actor: 'agent' },
59
+ ticketId: task.id,
60
+ column: 'in-progress',
61
+ })
62
+
63
+ const inProgressColumn = (await provider.listColumns()).find(
64
+ (column) => column.name.toLowerCase() === 'in-progress',
65
+ )
66
+
67
+ expect(updated.body).toBe('edited by core')
68
+ expect(comments).toHaveLength(1)
69
+ expect(inProgressColumn).toBeDefined()
70
+ expect((await provider.getTask(task.id)).column_id).toBe(inProgressColumn!.id)
71
+ expect(hookEvents).toEqual([
72
+ { tool: 'postComment', result: { commentId: created.id } },
73
+ { tool: 'updateComment', result: { commentId: created.id } },
74
+ { tool: 'listComments', result: { commentCount: 1 } },
75
+ { tool: 'moveTicket', result: { movedTo: 'in-progress' } },
76
+ ])
77
+ })
78
+
79
+ test('passes the existing comment to update policy and filters listed comments via policy', async () => {
80
+ const task = addTask(db, 'Core task')
81
+ const first = await provider.comment(task.id, 'visible comment')
82
+ await provider.comment(task.id, 'hidden comment')
83
+ let seenCommentId: string | null = null
84
+ let seenCommentBody: string | null = null
85
+
86
+ const core = createTrackerCore<TestScope>({
87
+ provider,
88
+ policy: {
89
+ canReadTicket() {},
90
+ canPostComment() {},
91
+ canUpdateComment(_scope, _ticketId, comment) {
92
+ seenCommentId = comment.id
93
+ seenCommentBody = comment.body
94
+ },
95
+ canMoveTicket() {},
96
+ filterComment(_scope, comment) {
97
+ return comment.body.startsWith('visible')
98
+ },
99
+ },
100
+ })
101
+
102
+ const comments = await core.handlers.listComments({
103
+ scope: { actor: 'agent' },
104
+ ticketId: task.id,
105
+ })
106
+ await core.handlers.updateComment({
107
+ scope: { actor: 'agent' },
108
+ ticketId: task.id,
109
+ commentId: first.id,
110
+ body: 'edited comment',
111
+ })
112
+
113
+ expect(comments.map((comment) => comment.body)).toEqual(['visible comment'])
114
+ expect(seenCommentId === first.id).toBe(true)
115
+ expect(seenCommentBody === 'visible comment').toBe(true)
116
+ })
117
+
118
+ test('normalizes policy denials into TrackerMcpError and reports them through hooks', async () => {
119
+ const task = addTask(db, 'Core task')
120
+ const toolErrors: Array<{ tool: string; code: string; message?: string }> = []
121
+ const core = createTrackerCore<TestScope>({
122
+ provider,
123
+ policy: {
124
+ canReadTicket() {},
125
+ canPostComment() {},
126
+ canUpdateComment() {},
127
+ canMoveTicket() {
128
+ throw new TrackerMcpError({
129
+ code: 'policy_denied',
130
+ publicMessage: 'forbidden_column',
131
+ })
132
+ },
133
+ },
134
+ hooks: {
135
+ onToolError(event) {
136
+ toolErrors.push({
137
+ tool: event.tool,
138
+ code: event.errorCode,
139
+ message: event.error.publicMessage,
140
+ })
141
+ },
142
+ },
143
+ })
144
+
145
+ await expect(
146
+ core.handlers.moveTicket({
147
+ scope: { actor: 'agent' },
148
+ ticketId: task.id,
149
+ column: 'done',
150
+ }),
151
+ ).rejects.toMatchObject({
152
+ code: 'policy_denied',
153
+ publicMessage: 'forbidden_column',
154
+ })
155
+
156
+ expect(toolErrors).toEqual([
157
+ {
158
+ tool: 'moveTicket',
159
+ code: 'policy_denied',
160
+ message: 'forbidden_column',
161
+ },
162
+ ])
163
+ })
164
+ })