@andypai/agent-kanban 0.1.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 +8 -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-DEnUD0fq.css +0 -1
  59. package/ui/dist/assets/index-DMRjw1nI.js +0 -40
@@ -0,0 +1,771 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { Database } from 'bun:sqlite'
3
+ import { ErrorCode, KanbanError } from '../errors.ts'
4
+ import { JiraClient } from '../providers/jira-client.ts'
5
+ import { JiraProvider, type JiraProviderConfig } from '../providers/jira.ts'
6
+ import {
7
+ initJiraCacheSchema,
8
+ replaceJiraColumns,
9
+ replaceJiraIssueTypes,
10
+ replaceJiraPriorities,
11
+ saveJiraSyncMeta,
12
+ saveTeamInfo,
13
+ upsertJiraIssues,
14
+ upsertJiraUsers,
15
+ } from '../providers/jira-cache.ts'
16
+
17
+ type FetchInit = RequestInit | undefined
18
+ type StubCall = { url: string; method: string; body: string | null }
19
+ type StubHandler = (url: string, init?: FetchInit) => Response | Promise<Response>
20
+ type StubRoute = { match: (url: string, init?: FetchInit) => boolean; handler: StubHandler }
21
+
22
+ function jsonResponse(body: unknown, status = 200): Response {
23
+ return new Response(JSON.stringify(body), {
24
+ status,
25
+ headers: { 'content-type': 'application/json' },
26
+ })
27
+ }
28
+
29
+ function emptyResponse(status = 204): Response {
30
+ return new Response(null, { status })
31
+ }
32
+
33
+ function jiraFetchStub(routes: StubRoute[]): {
34
+ fn: typeof fetch
35
+ calls: StubCall[]
36
+ } {
37
+ const calls: StubCall[] = []
38
+ const fn = (async (input: string | URL | Request, init?: FetchInit) => {
39
+ const url =
40
+ typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
41
+ const method = (init?.method ?? 'GET').toUpperCase()
42
+ const body = typeof init?.body === 'string' ? init.body : null
43
+ calls.push({ url, method, body })
44
+ for (const r of routes) {
45
+ if (r.match(url, init)) return r.handler(url, init)
46
+ }
47
+ return new Response('route not stubbed: ' + method + ' ' + url, {
48
+ status: 500,
49
+ })
50
+ }) as unknown as typeof fetch
51
+ return { fn, calls }
52
+ }
53
+
54
+ const baseConfig: JiraProviderConfig = {
55
+ baseUrl: 'https://example.atlassian.net',
56
+ email: 'user@example.com',
57
+ apiToken: 'token',
58
+ projectKey: 'ENG',
59
+ }
60
+
61
+ interface SeedIssue {
62
+ id: string
63
+ key: string
64
+ summary?: string
65
+ statusId: string
66
+ projectKey?: string
67
+ createdAt?: string
68
+ updatedAt?: string
69
+ }
70
+
71
+ interface SeedOpts {
72
+ priorities?: Array<{ id: string; name: string }>
73
+ users?: Array<{ accountId: string; displayName: string; active?: boolean }>
74
+ issueTypes?: Array<{ id: string; name: string }>
75
+ columns?: Array<{
76
+ id: string
77
+ name: string
78
+ position: number
79
+ statusIds: string[]
80
+ source: 'board' | 'status'
81
+ }>
82
+ issues?: SeedIssue[]
83
+ projectKey?: string
84
+ boardId?: number | null
85
+ team?: { id: string; key: string; name: string }
86
+ }
87
+
88
+ function seedCache(db: Database, opts: SeedOpts): void {
89
+ initJiraCacheSchema(db)
90
+ if (opts.priorities) replaceJiraPriorities(db, opts.priorities)
91
+ if (opts.users) upsertJiraUsers(db, opts.users)
92
+ if (opts.issueTypes) replaceJiraIssueTypes(db, opts.issueTypes)
93
+ if (opts.columns) replaceJiraColumns(db, opts.columns)
94
+ if (opts.issues) {
95
+ upsertJiraIssues(
96
+ db,
97
+ opts.issues.map((iss) => ({
98
+ id: iss.id,
99
+ key: iss.key,
100
+ summary: iss.summary ?? iss.key,
101
+ descriptionText: '',
102
+ statusId: iss.statusId,
103
+ priorityName: 'High',
104
+ issueTypeName: 'Task',
105
+ assigneeAccountId: null,
106
+ assigneeName: '',
107
+ projectKey: iss.projectKey ?? opts.projectKey ?? 'ENG',
108
+ url: null,
109
+ createdAt: iss.createdAt ?? '2026-01-01T00:00:00Z',
110
+ updatedAt: iss.updatedAt ?? '2026-01-02T00:00:00Z',
111
+ })),
112
+ )
113
+ }
114
+ saveJiraSyncMeta(db, {
115
+ projectKey: opts.projectKey ?? 'ENG',
116
+ boardId: opts.boardId ?? null,
117
+ lastSyncAt: new Date().toISOString(),
118
+ lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
119
+ })
120
+ if (opts.team) saveTeamInfo(db, opts.team)
121
+ else saveTeamInfo(db, { id: '10000', key: opts.projectKey ?? 'ENG', name: 'Engineering' })
122
+ }
123
+
124
+ interface SyncRoutesOpts {
125
+ projectKey: string
126
+ columns?: Array<{ name: string; statusIds: string[] }>
127
+ users?: Array<{ accountId: string; displayName: string; active?: boolean }>
128
+ priorities?: Array<{ id: string; name: string }>
129
+ issueTypes?: Array<{ id: string; name: string }>
130
+ issues?: Array<Record<string, unknown>>
131
+ boardId?: number
132
+ }
133
+
134
+ function makeJiraIssueFixture(iss: SeedIssue): Record<string, unknown> {
135
+ return {
136
+ id: iss.id,
137
+ key: iss.key,
138
+ fields: {
139
+ summary: iss.summary ?? iss.key,
140
+ description: null,
141
+ status: { id: iss.statusId, name: 'Status ' + iss.statusId },
142
+ issuetype: { id: '10001', name: 'Task' },
143
+ priority: { id: '2', name: 'High' },
144
+ assignee: null,
145
+ created: iss.createdAt ?? '2026-01-01T00:00:00Z',
146
+ updated: iss.updatedAt ?? '2026-01-02T00:00:00Z',
147
+ project: { id: '10000', key: iss.projectKey ?? 'ENG' },
148
+ },
149
+ }
150
+ }
151
+
152
+ function standardSyncRoutes(opts: SyncRoutesOpts): StubRoute[] {
153
+ const statusCategories = [
154
+ {
155
+ id: 'cat-1',
156
+ name: 'All',
157
+ statuses: (opts.columns ?? []).flatMap((c) =>
158
+ c.statusIds.map((sid) => ({ id: sid, name: c.name })),
159
+ ),
160
+ },
161
+ ]
162
+ const boardCfg = {
163
+ id: opts.boardId ?? 3,
164
+ name: 'Board',
165
+ columnConfig: {
166
+ columns: (opts.columns ?? []).map((c) => ({
167
+ name: c.name,
168
+ statuses: c.statusIds.map((sid) => ({ id: sid })),
169
+ })),
170
+ },
171
+ }
172
+ return [
173
+ {
174
+ match: (u) => u.includes(`/rest/api/3/project/${opts.projectKey}/statuses`),
175
+ handler: () => jsonResponse(statusCategories),
176
+ },
177
+ {
178
+ match: (u) => u.includes(`/rest/api/3/project/${opts.projectKey}`),
179
+ handler: () => jsonResponse({ id: '10000', key: opts.projectKey, name: 'Engineering' }),
180
+ },
181
+ {
182
+ match: (u) => u.includes('/rest/agile/1.0/board/'),
183
+ handler: () => jsonResponse(boardCfg),
184
+ },
185
+ {
186
+ match: (u) => u.includes('/rest/api/3/user/assignable/search'),
187
+ handler: () => jsonResponse(opts.users ?? []),
188
+ },
189
+ {
190
+ match: (u) => u.includes('/rest/api/3/priority'),
191
+ handler: () => jsonResponse(opts.priorities ?? []),
192
+ },
193
+ {
194
+ match: (u) => u.includes('/rest/api/3/issuetype/project'),
195
+ handler: () => jsonResponse(opts.issueTypes ?? []),
196
+ },
197
+ {
198
+ match: (u) => u.includes('/rest/api/3/search/jql'),
199
+ handler: () =>
200
+ jsonResponse({
201
+ startAt: 0,
202
+ maxResults: 100,
203
+ total: (opts.issues ?? []).length,
204
+ issues: opts.issues ?? [],
205
+ }),
206
+ },
207
+ ]
208
+ }
209
+
210
+ function makeProvider(
211
+ db: Database,
212
+ routes: StubRoute[],
213
+ config: JiraProviderConfig = baseConfig,
214
+ ): { provider: JiraProvider; calls: StubCall[] } {
215
+ const { fn, calls } = jiraFetchStub(routes)
216
+ globalThis.fetch = fn
217
+ const client = new JiraClient({
218
+ baseUrl: config.baseUrl,
219
+ email: config.email,
220
+ apiToken: config.apiToken,
221
+ })
222
+ const provider = new JiraProvider(db, config, client)
223
+ return { provider, calls }
224
+ }
225
+
226
+ let db: Database
227
+ let originalFetch: typeof fetch
228
+
229
+ beforeEach(() => {
230
+ db = new Database(':memory:')
231
+ originalFetch = globalThis.fetch
232
+ })
233
+
234
+ afterEach(() => {
235
+ globalThis.fetch = originalFetch
236
+ })
237
+
238
+ // Standard full-cache seed values reused across happy-path tests.
239
+ const seedPriorities = [
240
+ { id: '1', name: 'Highest' },
241
+ { id: '2', name: 'High' },
242
+ { id: '3', name: 'Medium' },
243
+ { id: '4', name: 'Low' },
244
+ ]
245
+ const seedUsers = [
246
+ { accountId: 'a-1', displayName: 'Alice', active: true },
247
+ { accountId: 'a-2', displayName: 'Bob', active: true },
248
+ ]
249
+ const seedIssueTypes = [
250
+ { id: '10001', name: 'Task' },
251
+ { id: '10002', name: 'Bug' },
252
+ ]
253
+ const seedColumns: SeedOpts['columns'] = [
254
+ {
255
+ id: 'board:3:To Do',
256
+ name: 'To Do',
257
+ position: 0,
258
+ statusIds: ['20000'],
259
+ source: 'board',
260
+ },
261
+ {
262
+ id: 'board:3:Done',
263
+ name: 'Done',
264
+ position: 1,
265
+ statusIds: ['10001'],
266
+ source: 'board',
267
+ },
268
+ ]
269
+ const seedIssues: SeedIssue[] = [
270
+ { id: '501', key: 'ENG-1', statusId: '20000', summary: 'Existing' },
271
+ ]
272
+
273
+ function fullSeed(db: Database): void {
274
+ seedCache(db, {
275
+ priorities: seedPriorities,
276
+ users: seedUsers,
277
+ issueTypes: seedIssueTypes,
278
+ columns: seedColumns,
279
+ issues: seedIssues,
280
+ projectKey: 'ENG',
281
+ })
282
+ }
283
+
284
+ function fullSyncRoutes(extraIssues: Record<string, unknown>[] = []): StubRoute[] {
285
+ return standardSyncRoutes({
286
+ projectKey: 'ENG',
287
+ columns: [
288
+ { name: 'To Do', statusIds: ['20000'] },
289
+ { name: 'Done', statusIds: ['10001'] },
290
+ ],
291
+ users: seedUsers,
292
+ priorities: seedPriorities,
293
+ issueTypes: seedIssueTypes,
294
+ issues: [makeJiraIssueFixture(seedIssues[0]!), ...extraIssues],
295
+ })
296
+ }
297
+
298
+ describe('JiraProvider mutations', () => {
299
+ test('createTask happy path: plainTextToAdf invoked, priority mapped, assignee and issueType resolved', async () => {
300
+ 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>[] = []
305
+ const createdIssue = makeJiraIssueFixture({
306
+ id: '600',
307
+ key: 'ENG-10',
308
+ statusId: '20000',
309
+ summary: 'Fix',
310
+ })
311
+ const syncRoutes: StubRoute[] = standardSyncRoutes({
312
+ projectKey: 'ENG',
313
+ columns: [
314
+ { name: 'To Do', statusIds: ['20000'] },
315
+ { name: 'Done', statusIds: ['10001'] },
316
+ ],
317
+ users: seedUsers,
318
+ priorities: seedPriorities,
319
+ issueTypes: seedIssueTypes,
320
+ issues: [makeJiraIssueFixture(seedIssues[0]!)],
321
+ })
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
+ const mutationRoute: StubRoute = {
339
+ match: (u, init) => u.endsWith('/rest/api/3/issue') && (init?.method ?? 'GET') === 'POST',
340
+ handler: () => {
341
+ createdIssues.push(createdIssue)
342
+ return jsonResponse({
343
+ id: '600',
344
+ key: 'ENG-10',
345
+ self: 'https://example.atlassian.net/rest/api/3/issue/600',
346
+ })
347
+ },
348
+ }
349
+ const { provider, calls } = makeProvider(db, [mutationRoute, ...syncRoutes])
350
+ const task = await provider.createTask({
351
+ title: 'Fix',
352
+ description: 'hello\n- item',
353
+ priority: 'high',
354
+ assignee: 'Alice',
355
+ })
356
+
357
+ expect(task.externalRef).toBe('ENG-10')
358
+ const postCall = calls.find((c) => c.method === 'POST' && c.url.endsWith('/rest/api/3/issue'))
359
+ expect(postCall).toBeDefined()
360
+ const body = JSON.parse(postCall!.body ?? '{}') as {
361
+ fields: Record<string, unknown>
362
+ }
363
+ expect(body.fields.summary).toBe('Fix')
364
+ expect((body.fields.issuetype as { id: string }).id).toBe('10001')
365
+ expect((body.fields.priority as { name: string }).name).toBe('High')
366
+ expect((body.fields.assignee as { accountId: string }).accountId).toBe('a-1')
367
+ expect((body.fields.project as { key: string }).key).toBe('ENG')
368
+ const desc = body.fields.description as {
369
+ version: number
370
+ type: string
371
+ content: Array<{ type: string; content?: unknown[] }>
372
+ }
373
+ expect(desc.version).toBe(1)
374
+ expect(desc.type).toBe('doc')
375
+ expect(desc.content.length).toBeGreaterThan(0)
376
+ expect(desc.content[0]!.type).toBe('paragraph')
377
+ expect(desc.content[1]!.type).toBe('bulletList')
378
+ })
379
+
380
+ test('updateTask happy path: summary + description + priority rewritten', async () => {
381
+ fullSeed(db)
382
+ const syncRoutes = fullSyncRoutes()
383
+ const mutationRoute: StubRoute = {
384
+ match: (u, init) =>
385
+ u.endsWith('/rest/api/3/issue/ENG-1') && (init?.method ?? 'GET') === 'PUT',
386
+ handler: () => emptyResponse(204),
387
+ }
388
+ const { provider, calls } = makeProvider(db, [mutationRoute, ...syncRoutes])
389
+ await provider.updateTask('ENG-1', {
390
+ title: 'New',
391
+ description: 'new body',
392
+ priority: 'urgent',
393
+ })
394
+ const putCall = calls.find(
395
+ (c) => c.method === 'PUT' && c.url.endsWith('/rest/api/3/issue/ENG-1'),
396
+ )
397
+ expect(putCall).toBeDefined()
398
+ const body = JSON.parse(putCall!.body ?? '{}') as {
399
+ fields: Record<string, unknown>
400
+ }
401
+ expect(Object.keys(body.fields).sort()).toEqual(['description', 'priority', 'summary'].sort())
402
+ expect(body.fields.summary).toBe('New')
403
+ expect((body.fields.priority as { name: string }).name).toBe('Highest')
404
+ const desc = body.fields.description as {
405
+ version: number
406
+ type: string
407
+ content: unknown[]
408
+ }
409
+ expect(desc.version).toBe(1)
410
+ expect(desc.type).toBe('doc')
411
+ expect(desc.content.length).toBeGreaterThan(0)
412
+ })
413
+
414
+ test('updateTask clearing assignee with empty string sets fields.assignee to null', async () => {
415
+ fullSeed(db)
416
+ const syncRoutes = fullSyncRoutes()
417
+ const mutationRoute: StubRoute = {
418
+ match: (u, init) =>
419
+ u.endsWith('/rest/api/3/issue/ENG-1') && (init?.method ?? 'GET') === 'PUT',
420
+ handler: () => emptyResponse(204),
421
+ }
422
+ const { provider, calls } = makeProvider(db, [mutationRoute, ...syncRoutes])
423
+ await provider.updateTask('ENG-1', { assignee: '' })
424
+ const putCall = calls.find(
425
+ (c) => c.method === 'PUT' && c.url.endsWith('/rest/api/3/issue/ENG-1'),
426
+ )
427
+ expect(putCall).toBeDefined()
428
+ const body = JSON.parse(putCall!.body ?? '{}') as {
429
+ fields: Record<string, unknown>
430
+ }
431
+ expect('assignee' in body.fields).toBe(true)
432
+ expect(body.fields.assignee).toBeNull()
433
+ })
434
+
435
+ test('moveTask happy path: matching transition is used with GET before POST', async () => {
436
+ seedCache(db, {
437
+ priorities: seedPriorities,
438
+ users: seedUsers,
439
+ issueTypes: seedIssueTypes,
440
+ columns: seedColumns,
441
+ issues: [{ id: '501', key: 'ENG-1', statusId: '20000' }],
442
+ projectKey: 'ENG',
443
+ })
444
+ const syncRoutes = fullSyncRoutes()
445
+ const transitionsRoute: StubRoute = {
446
+ match: (u, init) =>
447
+ u.endsWith('/rest/api/3/issue/ENG-1/transitions') && (init?.method ?? 'GET') === 'GET',
448
+ handler: () =>
449
+ jsonResponse({
450
+ transitions: [
451
+ { id: '21', name: 'Done', to: { id: '10001', name: 'Done' } },
452
+ { id: '22', name: 'Reject', to: { id: '30000', name: 'Rejected' } },
453
+ ],
454
+ }),
455
+ }
456
+ const postTransitionRoute: StubRoute = {
457
+ match: (u, init) =>
458
+ u.endsWith('/rest/api/3/issue/ENG-1/transitions') && (init?.method ?? 'GET') === 'POST',
459
+ handler: () => emptyResponse(204),
460
+ }
461
+ const { provider, calls } = makeProvider(db, [
462
+ transitionsRoute,
463
+ postTransitionRoute,
464
+ ...syncRoutes,
465
+ ])
466
+ await provider.moveTask('ENG-1', 'Done')
467
+
468
+ const getIdx = calls.findIndex(
469
+ (c) => c.method === 'GET' && c.url.endsWith('/rest/api/3/issue/ENG-1/transitions'),
470
+ )
471
+ const postIdx = calls.findIndex(
472
+ (c) => c.method === 'POST' && c.url.endsWith('/rest/api/3/issue/ENG-1/transitions'),
473
+ )
474
+ expect(getIdx).toBeGreaterThanOrEqual(0)
475
+ expect(postIdx).toBeGreaterThanOrEqual(0)
476
+ expect(getIdx).toBeLessThan(postIdx)
477
+ const postCall = calls[postIdx]!
478
+ const body = JSON.parse(postCall.body ?? '{}') as {
479
+ transition: { id: string }
480
+ }
481
+ expect(body.transition.id).toBe('21')
482
+ })
483
+
484
+ test('moveTask no-match failure: error message names target and lists available transitions', async () => {
485
+ seedCache(db, {
486
+ priorities: seedPriorities,
487
+ users: seedUsers,
488
+ issueTypes: seedIssueTypes,
489
+ columns: seedColumns,
490
+ issues: [{ id: '501', key: 'ENG-1', statusId: '20000' }],
491
+ projectKey: 'ENG',
492
+ })
493
+ // No standard sync routes — the mutation throws before sync(true).
494
+ const transitionsRoute: StubRoute = {
495
+ match: (u, init) =>
496
+ u.endsWith('/rest/api/3/issue/ENG-1/transitions') && (init?.method ?? 'GET') === 'GET',
497
+ handler: () =>
498
+ jsonResponse({
499
+ transitions: [{ id: '22', name: 'Reject', to: { id: '30000', name: 'Rejected' } }],
500
+ }),
501
+ }
502
+ const { provider } = makeProvider(db, [transitionsRoute])
503
+ try {
504
+ await provider.moveTask('ENG-1', 'Done')
505
+ throw new Error('should have thrown')
506
+ } catch (err) {
507
+ expect(err).toBeInstanceOf(KanbanError)
508
+ const e = err as KanbanError
509
+ expect(e.code).toBe(ErrorCode.PROVIDER_UPSTREAM_ERROR)
510
+ expect(e.message).toContain('ENG-1')
511
+ expect(e.message).toContain('Done')
512
+ expect(e.message).toContain('10001')
513
+ expect(e.message).toContain('Reject')
514
+ }
515
+ })
516
+
517
+ test('moveTask required-field failure: error surfaces Jira errors keys', async () => {
518
+ seedCache(db, {
519
+ priorities: seedPriorities,
520
+ users: seedUsers,
521
+ issueTypes: seedIssueTypes,
522
+ columns: seedColumns,
523
+ issues: [{ id: '501', key: 'ENG-1', statusId: '20000' }],
524
+ projectKey: 'ENG',
525
+ })
526
+ const transitionsRoute: StubRoute = {
527
+ match: (u, init) =>
528
+ u.endsWith('/rest/api/3/issue/ENG-1/transitions') && (init?.method ?? 'GET') === 'GET',
529
+ handler: () =>
530
+ jsonResponse({
531
+ transitions: [{ id: '21', name: 'Done', to: { id: '10001', name: 'Done' } }],
532
+ }),
533
+ }
534
+ const postTransitionRoute: StubRoute = {
535
+ match: (u, init) =>
536
+ u.endsWith('/rest/api/3/issue/ENG-1/transitions') && (init?.method ?? 'GET') === 'POST',
537
+ handler: () =>
538
+ jsonResponse(
539
+ {
540
+ errorMessages: ['Required field missing'],
541
+ errors: { resolution: 'is required' },
542
+ },
543
+ 400,
544
+ ),
545
+ }
546
+ const { provider } = makeProvider(db, [transitionsRoute, postTransitionRoute])
547
+ try {
548
+ await provider.moveTask('ENG-1', 'Done')
549
+ throw new Error('should have thrown')
550
+ } catch (err) {
551
+ expect(err).toBeInstanceOf(KanbanError)
552
+ const e = err as KanbanError
553
+ expect(e.code).toBe(ErrorCode.PROVIDER_UPSTREAM_ERROR)
554
+ expect(e.message).toContain('resolution')
555
+ expect(e.message).toContain('is required')
556
+ }
557
+ })
558
+
559
+ test('createTask failure: unknown assignee name', async () => {
560
+ seedCache(db, {
561
+ priorities: seedPriorities,
562
+ users: seedUsers,
563
+ issueTypes: seedIssueTypes,
564
+ columns: seedColumns,
565
+ issues: [],
566
+ projectKey: 'ENG',
567
+ })
568
+ // Strict stub: reject any unexpected call.
569
+ const { provider, calls } = makeProvider(db, [])
570
+ try {
571
+ await provider.createTask({ title: 'x', assignee: 'Bob2' })
572
+ throw new Error('should have thrown')
573
+ } catch (err) {
574
+ expect(err).toBeInstanceOf(KanbanError)
575
+ const e = err as KanbanError
576
+ expect(e.code).toBe(ErrorCode.PROVIDER_UPSTREAM_ERROR)
577
+ expect(e.message).toContain('Bob2')
578
+ }
579
+ expect(calls.some((c) => c.method === 'POST' && c.url.endsWith('/rest/api/3/issue'))).toBe(
580
+ false,
581
+ )
582
+ })
583
+
584
+ test('createTask failure: canonical priority maps to Jira name missing from cache', async () => {
585
+ seedCache(db, {
586
+ priorities: [
587
+ { id: '1', name: 'Highest' },
588
+ { id: '3', name: 'Medium' },
589
+ { id: '4', name: 'Low' },
590
+ ],
591
+ users: seedUsers,
592
+ issueTypes: seedIssueTypes,
593
+ columns: seedColumns,
594
+ issues: [],
595
+ projectKey: 'ENG',
596
+ })
597
+ const { provider } = makeProvider(db, [])
598
+ try {
599
+ await provider.createTask({ title: 'x', priority: 'high' })
600
+ throw new Error('should have thrown')
601
+ } catch (err) {
602
+ expect(err).toBeInstanceOf(KanbanError)
603
+ const e = err as KanbanError
604
+ expect(e.code).toBe(ErrorCode.PROVIDER_UPSTREAM_ERROR)
605
+ expect(e.message).toContain('high')
606
+ expect(e.message).toContain('High')
607
+ expect(e.message).toContain('Highest')
608
+ expect(e.message).toContain('Medium')
609
+ expect(e.message).toContain('Low')
610
+ }
611
+ })
612
+
613
+ test('createTask failure: default issue type missing from cache', async () => {
614
+ seedCache(db, {
615
+ priorities: seedPriorities,
616
+ users: seedUsers,
617
+ issueTypes: [
618
+ { id: '1', name: 'Bug' },
619
+ { id: '2', name: 'Story' },
620
+ ],
621
+ columns: seedColumns,
622
+ issues: [],
623
+ projectKey: 'ENG',
624
+ })
625
+ const { provider } = makeProvider(db, [])
626
+ try {
627
+ await provider.createTask({ title: 'x' })
628
+ throw new Error('should have thrown')
629
+ } catch (err) {
630
+ expect(err).toBeInstanceOf(KanbanError)
631
+ const e = err as KanbanError
632
+ expect(e.code).toBe(ErrorCode.PROVIDER_UPSTREAM_ERROR)
633
+ expect(e.message).toContain('Task')
634
+ expect(e.message).toContain('Bug')
635
+ expect(e.message).toContain('Story')
636
+ }
637
+ })
638
+
639
+ test('createTask project field mismatch rejected as UNSUPPORTED_OPERATION', async () => {
640
+ fullSeed(db)
641
+ const { provider } = makeProvider(db, [])
642
+ try {
643
+ await provider.createTask({ title: 'x', project: 'OTHER' })
644
+ throw new Error('should have thrown')
645
+ } catch (err) {
646
+ expect(err).toBeInstanceOf(KanbanError)
647
+ expect((err as KanbanError).code).toBe(ErrorCode.UNSUPPORTED_OPERATION)
648
+ }
649
+ })
650
+
651
+ test('createTask project field omitted is accepted', async () => {
652
+ fullSeed(db)
653
+ const createdIssues: Record<string, unknown>[] = []
654
+ const createdIssue = makeJiraIssueFixture({
655
+ id: '600',
656
+ key: 'ENG-10',
657
+ statusId: '20000',
658
+ summary: 'x',
659
+ })
660
+ const syncRoutes = standardSyncRoutes({
661
+ projectKey: 'ENG',
662
+ columns: [
663
+ { name: 'To Do', statusIds: ['20000'] },
664
+ { name: 'Done', statusIds: ['10001'] },
665
+ ],
666
+ users: seedUsers,
667
+ priorities: seedPriorities,
668
+ issueTypes: seedIssueTypes,
669
+ issues: [makeJiraIssueFixture(seedIssues[0]!)],
670
+ })
671
+ const searchIdx = syncRoutes.findIndex((r) =>
672
+ r.match('https://example.atlassian.net/rest/api/3/search/jql'),
673
+ )
674
+ syncRoutes[searchIdx] = {
675
+ match: (u) => u.includes('/rest/api/3/search/jql'),
676
+ handler: () => {
677
+ const all = [makeJiraIssueFixture(seedIssues[0]!), ...createdIssues]
678
+ return jsonResponse({
679
+ startAt: 0,
680
+ maxResults: 100,
681
+ total: all.length,
682
+ issues: all,
683
+ })
684
+ },
685
+ }
686
+ const mutationRoute: StubRoute = {
687
+ match: (u, init) => u.endsWith('/rest/api/3/issue') && (init?.method ?? 'GET') === 'POST',
688
+ handler: () => {
689
+ createdIssues.push(createdIssue)
690
+ return jsonResponse({ id: '600', key: 'ENG-10', self: 'x' })
691
+ },
692
+ }
693
+ const { provider, calls } = makeProvider(db, [mutationRoute, ...syncRoutes])
694
+ await provider.createTask({ title: 'x' })
695
+ const postCalls = calls.filter(
696
+ (c) => c.method === 'POST' && c.url.endsWith('/rest/api/3/issue'),
697
+ )
698
+ expect(postCalls).toHaveLength(1)
699
+ const body = JSON.parse(postCalls[0]!.body ?? '{}') as {
700
+ fields: { project: { key: string } }
701
+ }
702
+ expect(body.fields.project.key).toBe('ENG')
703
+ })
704
+
705
+ test('createTask project field matching configured projectKey is accepted', async () => {
706
+ fullSeed(db)
707
+ const createdIssues: Record<string, unknown>[] = []
708
+ const createdIssue = makeJiraIssueFixture({
709
+ id: '601',
710
+ key: 'ENG-11',
711
+ statusId: '20000',
712
+ summary: 'x',
713
+ })
714
+ const syncRoutes = standardSyncRoutes({
715
+ projectKey: 'ENG',
716
+ columns: [
717
+ { name: 'To Do', statusIds: ['20000'] },
718
+ { name: 'Done', statusIds: ['10001'] },
719
+ ],
720
+ users: seedUsers,
721
+ priorities: seedPriorities,
722
+ issueTypes: seedIssueTypes,
723
+ issues: [makeJiraIssueFixture(seedIssues[0]!)],
724
+ })
725
+ const searchIdx = syncRoutes.findIndex((r) =>
726
+ r.match('https://example.atlassian.net/rest/api/3/search/jql'),
727
+ )
728
+ syncRoutes[searchIdx] = {
729
+ match: (u) => u.includes('/rest/api/3/search/jql'),
730
+ handler: () => {
731
+ const all = [makeJiraIssueFixture(seedIssues[0]!), ...createdIssues]
732
+ return jsonResponse({
733
+ startAt: 0,
734
+ maxResults: 100,
735
+ total: all.length,
736
+ issues: all,
737
+ })
738
+ },
739
+ }
740
+ const mutationRoute: StubRoute = {
741
+ match: (u, init) => u.endsWith('/rest/api/3/issue') && (init?.method ?? 'GET') === 'POST',
742
+ handler: () => {
743
+ createdIssues.push(createdIssue)
744
+ return jsonResponse({ id: '601', key: 'ENG-11', self: 'x' })
745
+ },
746
+ }
747
+ const { provider, calls } = makeProvider(db, [mutationRoute, ...syncRoutes])
748
+ await provider.createTask({ title: 'x', project: 'ENG' })
749
+ const postCalls = calls.filter(
750
+ (c) => c.method === 'POST' && c.url.endsWith('/rest/api/3/issue'),
751
+ )
752
+ expect(postCalls).toHaveLength(1)
753
+ const body = JSON.parse(postCalls[0]!.body ?? '{}') as {
754
+ fields: { project: { key: string } }
755
+ }
756
+ expect(body.fields.project.key).toBe('ENG')
757
+ })
758
+
759
+ test('updateTask metadata field rejected as UNSUPPORTED_OPERATION', async () => {
760
+ fullSeed(db)
761
+ const { provider, calls } = makeProvider(db, [])
762
+ try {
763
+ await provider.updateTask('ENG-1', { metadata: '{}' })
764
+ throw new Error('should have thrown')
765
+ } catch (err) {
766
+ expect(err).toBeInstanceOf(KanbanError)
767
+ expect((err as KanbanError).code).toBe(ErrorCode.UNSUPPORTED_OPERATION)
768
+ }
769
+ expect(calls.some((c) => c.method === 'PUT')).toBe(false)
770
+ })
771
+ })