@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,493 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { Database } from 'bun:sqlite'
3
+ import { LinearProvider } from '../providers/linear.ts'
4
+ import {
5
+ getCachedTasks,
6
+ initLinearCacheSchema,
7
+ loadSyncMeta,
8
+ replaceStates,
9
+ saveSyncMeta,
10
+ upsertIssues,
11
+ } from '../providers/linear-cache.ts'
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
+ totalCount?: number
42
+ nodes: Array<{ id: string }>
43
+ pageInfo?: { hasNextPage: boolean; endCursor: string | null }
44
+ }
45
+ }> = {},
46
+ ) {
47
+ return {
48
+ id: 'issue-1',
49
+ identifier: 'R2P-1',
50
+ title: 'Linear task',
51
+ description: '',
52
+ priority: 2,
53
+ url: 'https://linear.app/x/issue/R2P-1',
54
+ createdAt: '2026-01-01T00:00:00Z',
55
+ updatedAt: '2026-01-02T00:00:00Z',
56
+ assignee: null,
57
+ project: null,
58
+ state: { id: 'state-1', name: 'Todo', position: 0 },
59
+ labels: { nodes: [] },
60
+ comments: { totalCount: 0, nodes: [], pageInfo: { hasNextPage: false, endCursor: null } },
61
+ ...overrides,
62
+ }
63
+ }
64
+
65
+ describe('LinearProvider sync', () => {
66
+ test('resolves a configured team key before querying issues', async () => {
67
+ const seenIssueTeamIds: string[] = []
68
+
69
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
70
+ const body = JSON.parse(String(init?.body)) as {
71
+ query: string
72
+ variables: Record<string, unknown>
73
+ }
74
+
75
+ if (body.query.includes('query TeamSnapshot')) {
76
+ return new Response(
77
+ JSON.stringify({
78
+ data: {
79
+ team: {
80
+ id: '3ca24047-e954-44e8-b266-c7182410befb',
81
+ key: 'R2P',
82
+ name: 'R2pi',
83
+ states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
84
+ },
85
+ },
86
+ }),
87
+ { status: 200, headers: { 'content-type': 'application/json' } },
88
+ )
89
+ }
90
+
91
+ if (body.query.includes('query Users')) {
92
+ return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
93
+ status: 200,
94
+ headers: { 'content-type': 'application/json' },
95
+ })
96
+ }
97
+
98
+ if (body.query.includes('query Projects')) {
99
+ return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
100
+ status: 200,
101
+ headers: { 'content-type': 'application/json' },
102
+ })
103
+ }
104
+
105
+ if (body.query.includes('query Issues')) {
106
+ seenIssueTeamIds.push(String(body.variables.teamId))
107
+ return new Response(
108
+ JSON.stringify({
109
+ data: {
110
+ issues: {
111
+ nodes: [],
112
+ pageInfo: { hasNextPage: false, endCursor: null },
113
+ },
114
+ },
115
+ }),
116
+ { status: 200, headers: { 'content-type': 'application/json' } },
117
+ )
118
+ }
119
+
120
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
121
+ }) as unknown as typeof fetch
122
+
123
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
124
+ await provider.getBoard()
125
+
126
+ expect(seenIssueTeamIds).toEqual(['3ca24047-e954-44e8-b266-c7182410befb'])
127
+ })
128
+
129
+ test('createTask uses the resolved team UUID from cached sync meta', async () => {
130
+ replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
131
+ saveSyncMeta(db, {
132
+ team: { id: '3ca24047-e954-44e8-b266-c7182410befb', key: 'R2P', name: 'R2pi' },
133
+ lastSyncAt: new Date().toISOString(),
134
+ lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
135
+ })
136
+
137
+ let createIssueTeamId: string | null = null
138
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
139
+ const body = JSON.parse(String(init?.body)) as {
140
+ query: string
141
+ variables: { input?: { teamId?: string } }
142
+ }
143
+
144
+ if (body.query.includes('mutation CreateIssue')) {
145
+ createIssueTeamId = body.variables.input?.teamId ?? null
146
+ return new Response(
147
+ JSON.stringify({
148
+ data: {
149
+ issueCreate: {
150
+ success: true,
151
+ issue: {
152
+ id: 'issue-1',
153
+ identifier: 'R2P-1',
154
+ title: 'Hello',
155
+ description: '',
156
+ priority: 3,
157
+ url: 'https://linear.app/x/issue/R2P-1',
158
+ createdAt: '2026-01-01T00:00:00Z',
159
+ updatedAt: '2026-01-01T00:00:00Z',
160
+ assignee: null,
161
+ project: null,
162
+ state: { id: 'state-1', name: 'Todo', position: 0 },
163
+ labels: { nodes: [] },
164
+ comments: {
165
+ totalCount: 0,
166
+ nodes: [],
167
+ pageInfo: { hasNextPage: false, endCursor: null },
168
+ },
169
+ },
170
+ },
171
+ },
172
+ }),
173
+ { status: 200, headers: { 'content-type': 'application/json' } },
174
+ )
175
+ }
176
+
177
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
178
+ }) as unknown as typeof fetch
179
+
180
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
181
+ const created = await provider.createTask({ title: 'Hello' })
182
+
183
+ expect(String(createIssueTeamId)).toBe('3ca24047-e954-44e8-b266-c7182410befb')
184
+ expect(created.externalRef).toBe('R2P-1')
185
+ })
186
+
187
+ test('periodic full sync prunes cached issues missing from upstream', async () => {
188
+ replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
189
+ upsertIssues(db, [
190
+ {
191
+ id: 'issue-1',
192
+ identifier: 'R2P-1',
193
+ title: 'Keep me',
194
+ stateId: 'state-1',
195
+ stateName: 'Todo',
196
+ statePosition: 0,
197
+ commentCount: 1,
198
+ createdAt: '2026-01-01T00:00:00Z',
199
+ updatedAt: '2026-01-01T00:00:00Z',
200
+ },
201
+ {
202
+ id: 'issue-stale',
203
+ identifier: 'R2P-9',
204
+ title: 'Delete me',
205
+ stateId: 'state-1',
206
+ stateName: 'Todo',
207
+ statePosition: 0,
208
+ commentCount: 3,
209
+ createdAt: '2026-01-01T00:00:00Z',
210
+ updatedAt: '2026-01-01T00:00:00Z',
211
+ },
212
+ ])
213
+ saveSyncMeta(db, {
214
+ team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
215
+ lastSyncAt: '2026-01-01T00:00:00Z',
216
+ lastFullSyncAt: '2026-01-01T00:00:00Z',
217
+ lastIssueUpdatedAt: '2026-01-01T00:00:00Z',
218
+ })
219
+
220
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
221
+ const body = JSON.parse(String(init?.body)) as {
222
+ query: string
223
+ variables: Record<string, unknown>
224
+ }
225
+
226
+ if (body.query.includes('query TeamSnapshot')) {
227
+ return new Response(
228
+ JSON.stringify({
229
+ data: {
230
+ team: {
231
+ id: 'team-1',
232
+ key: 'R2P',
233
+ name: 'R2pi',
234
+ states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
235
+ },
236
+ },
237
+ }),
238
+ { status: 200, headers: { 'content-type': 'application/json' } },
239
+ )
240
+ }
241
+
242
+ if (body.query.includes('query Users')) {
243
+ return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
244
+ status: 200,
245
+ headers: { 'content-type': 'application/json' },
246
+ })
247
+ }
248
+
249
+ if (body.query.includes('query Projects')) {
250
+ return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
251
+ status: 200,
252
+ headers: { 'content-type': 'application/json' },
253
+ })
254
+ }
255
+
256
+ if (body.query.includes('query Issues')) {
257
+ return new Response(
258
+ JSON.stringify({
259
+ data: {
260
+ issues: {
261
+ nodes: [
262
+ linearIssue({ comments: { totalCount: 2, nodes: [{ id: 'c1' }, { id: 'c2' }] } }),
263
+ ],
264
+ pageInfo: { hasNextPage: false, endCursor: null },
265
+ },
266
+ },
267
+ }),
268
+ { status: 200, headers: { 'content-type': 'application/json' } },
269
+ )
270
+ }
271
+
272
+ if (body.query.includes('query IssueHistory')) {
273
+ return new Response(
274
+ JSON.stringify({
275
+ data: {
276
+ issue: {
277
+ history: {
278
+ nodes: [],
279
+ pageInfo: { hasNextPage: false, endCursor: null },
280
+ },
281
+ },
282
+ },
283
+ }),
284
+ { status: 200, headers: { 'content-type': 'application/json' } },
285
+ )
286
+ }
287
+
288
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
289
+ }) as unknown as typeof fetch
290
+
291
+ const originalDateNow = Date.now
292
+ Date.now = () => Date.parse('2026-01-01T00:06:00Z')
293
+
294
+ try {
295
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
296
+ await provider.getBoard()
297
+ } finally {
298
+ Date.now = originalDateNow
299
+ }
300
+
301
+ const tasks = getCachedTasks(db)
302
+ expect(tasks.map((task) => task.externalRef)).toEqual(['R2P-1'])
303
+ expect(tasks[0]?.comment_count).toBe(2)
304
+ expect(loadSyncMeta(db).lastFullSyncAt).not.toBeNull()
305
+ })
306
+
307
+ test('polling keeps upstream comment counts instead of resetting them to zero', async () => {
308
+ upsertIssues(db, [
309
+ {
310
+ id: 'issue-1',
311
+ identifier: 'R2P-1',
312
+ title: 'Linear task',
313
+ stateId: 'state-1',
314
+ stateName: 'Todo',
315
+ statePosition: 0,
316
+ commentCount: 4,
317
+ createdAt: '2026-01-01T00:00:00Z',
318
+ updatedAt: '2026-01-01T00:00:00Z',
319
+ },
320
+ ])
321
+ saveSyncMeta(db, {
322
+ team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
323
+ lastSyncAt: '2026-01-01T00:00:00Z',
324
+ lastFullSyncAt: '2026-01-01T00:00:00Z',
325
+ lastIssueUpdatedAt: '2026-01-01T00:00:00Z',
326
+ })
327
+
328
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
329
+ const body = JSON.parse(String(init?.body)) as {
330
+ query: string
331
+ }
332
+
333
+ if (body.query.includes('query TeamSnapshot')) {
334
+ return new Response(
335
+ JSON.stringify({
336
+ data: {
337
+ team: {
338
+ id: 'team-1',
339
+ key: 'R2P',
340
+ name: 'R2pi',
341
+ states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
342
+ },
343
+ },
344
+ }),
345
+ { status: 200, headers: { 'content-type': 'application/json' } },
346
+ )
347
+ }
348
+
349
+ if (body.query.includes('query Users')) {
350
+ return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
351
+ status: 200,
352
+ headers: { 'content-type': 'application/json' },
353
+ })
354
+ }
355
+
356
+ if (body.query.includes('query Projects')) {
357
+ return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
358
+ status: 200,
359
+ headers: { 'content-type': 'application/json' },
360
+ })
361
+ }
362
+
363
+ if (body.query.includes('query Issues')) {
364
+ return new Response(
365
+ JSON.stringify({
366
+ data: {
367
+ issues: {
368
+ nodes: [
369
+ linearIssue({
370
+ comments: {
371
+ totalCount: 7,
372
+ nodes: Array.from({ length: 7 }, (_, index) => ({ id: `c${index}` })),
373
+ },
374
+ }),
375
+ ],
376
+ pageInfo: { hasNextPage: false, endCursor: null },
377
+ },
378
+ },
379
+ }),
380
+ { status: 200, headers: { 'content-type': 'application/json' } },
381
+ )
382
+ }
383
+
384
+ if (body.query.includes('query IssueHistory')) {
385
+ return new Response(
386
+ JSON.stringify({
387
+ data: {
388
+ issue: {
389
+ history: {
390
+ nodes: [],
391
+ pageInfo: { hasNextPage: false, endCursor: null },
392
+ },
393
+ },
394
+ },
395
+ }),
396
+ { status: 200, headers: { 'content-type': 'application/json' } },
397
+ )
398
+ }
399
+
400
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
401
+ }) as unknown as typeof fetch
402
+
403
+ const originalDateNow = Date.now
404
+ Date.now = () => Date.parse('2026-01-01T00:06:00Z')
405
+
406
+ try {
407
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
408
+ const task = await provider.getTask('R2P-1')
409
+ expect(task.comment_count).toBe(7)
410
+ } finally {
411
+ Date.now = originalDateNow
412
+ }
413
+ })
414
+
415
+ test('recent webhook traffic does not stretch polling beyond the normal interval', async () => {
416
+ let issueQueries = 0
417
+
418
+ saveSyncMeta(db, {
419
+ team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
420
+ lastSyncAt: '2026-01-01T00:00:00.000Z',
421
+ lastFullSyncAt: '2026-01-01T00:00:00.000Z',
422
+ lastIssueUpdatedAt: '2026-01-01T00:00:00.000Z',
423
+ lastWebhookAt: '2026-01-01T00:00:30.000Z',
424
+ })
425
+
426
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
427
+ const body = JSON.parse(String(init?.body)) as {
428
+ query: string
429
+ variables: Record<string, unknown>
430
+ }
431
+
432
+ if (body.query.includes('query TeamSnapshot')) {
433
+ return new Response(
434
+ JSON.stringify({
435
+ data: {
436
+ team: {
437
+ id: 'team-1',
438
+ key: 'R2P',
439
+ name: 'R2pi',
440
+ states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
441
+ },
442
+ },
443
+ }),
444
+ { status: 200, headers: { 'content-type': 'application/json' } },
445
+ )
446
+ }
447
+
448
+ if (body.query.includes('query Users')) {
449
+ return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
450
+ status: 200,
451
+ headers: { 'content-type': 'application/json' },
452
+ })
453
+ }
454
+
455
+ if (body.query.includes('query Projects')) {
456
+ return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
457
+ status: 200,
458
+ headers: { 'content-type': 'application/json' },
459
+ })
460
+ }
461
+
462
+ if (body.query.includes('query Issues')) {
463
+ issueQueries += 1
464
+ expect(body.variables.updatedAfter).toBe('2026-01-01T00:00:00.000Z')
465
+ return new Response(
466
+ JSON.stringify({
467
+ data: {
468
+ issues: {
469
+ nodes: [],
470
+ pageInfo: { hasNextPage: false, endCursor: null },
471
+ },
472
+ },
473
+ }),
474
+ { status: 200, headers: { 'content-type': 'application/json' } },
475
+ )
476
+ }
477
+
478
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
479
+ }) as unknown as typeof fetch
480
+
481
+ const originalDateNow = Date.now
482
+ Date.now = () => Date.parse('2026-01-01T00:00:31.000Z')
483
+
484
+ try {
485
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
486
+ await provider.getBoard()
487
+ } finally {
488
+ Date.now = originalDateNow
489
+ }
490
+
491
+ expect(issueQueries).toBe(1)
492
+ })
493
+ })
@@ -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.ts'
4
+ import { LocalProvider } from '../providers/local.ts'
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
+ })