@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.
- package/README.md +89 -22
- package/package.json +4 -2
- package/src/__tests__/activity.test.ts +15 -9
- package/src/__tests__/api.test.ts +96 -0
- package/src/__tests__/board-utils.test.ts +100 -0
- package/src/__tests__/commands/board.test.ts +6 -13
- package/src/__tests__/conflict.test.ts +64 -0
- package/src/__tests__/index.test.ts +233 -56
- package/src/__tests__/jira-adf.test.ts +168 -0
- package/src/__tests__/jira-cache.test.ts +304 -0
- package/src/__tests__/jira-client.test.ts +169 -0
- package/src/__tests__/jira-provider-comment.test.ts +281 -0
- package/src/__tests__/jira-provider-mutations.test.ts +771 -0
- package/src/__tests__/jira-provider-read.test.ts +594 -0
- package/src/__tests__/jira-wiring.test.ts +187 -0
- package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
- package/src/__tests__/linear-provider-comment.test.ts +243 -0
- package/src/__tests__/linear-provider-sync.test.ts +493 -0
- package/src/__tests__/local-provider-comment.test.ts +60 -0
- package/src/__tests__/mcp-core.test.ts +164 -0
- package/src/__tests__/mcp-server.test.ts +252 -0
- package/src/__tests__/server.test.ts +298 -0
- package/src/__tests__/webhooks.test.ts +604 -0
- package/src/activity.ts +1 -11
- package/src/api.ts +154 -19
- package/src/commands/board.ts +1 -11
- package/src/commands/mcp.ts +87 -0
- package/src/db.ts +115 -3
- package/src/errors.ts +2 -0
- package/src/id.ts +1 -1
- package/src/index.ts +72 -18
- package/src/mcp/core.ts +193 -0
- package/src/mcp/errors.ts +109 -0
- package/src/mcp/index.ts +13 -0
- package/src/mcp/server.ts +512 -0
- package/src/mcp/types.ts +72 -0
- package/src/providers/capabilities.ts +15 -0
- package/src/providers/index.ts +31 -1
- package/src/providers/jira-adf.ts +275 -0
- package/src/providers/jira-cache.ts +625 -0
- package/src/providers/jira-client.ts +390 -0
- package/src/providers/jira.ts +778 -0
- package/src/providers/linear-cache.ts +249 -70
- package/src/providers/linear-client.ts +256 -13
- package/src/providers/linear.ts +337 -14
- package/src/providers/local.ts +68 -17
- package/src/providers/types.ts +18 -2
- package/src/server.ts +139 -11
- package/src/tunnel.ts +79 -0
- package/src/types.ts +18 -2
- package/src/webhooks.ts +36 -0
- package/ui/dist/assets/index-DBnoKL_k.css +1 -0
- package/ui/dist/assets/index-qNVJ6clH.js +40 -0
- package/ui/dist/index.html +2 -2
- package/src/__tests__/commands/task.test.ts +0 -144
- package/src/commands/task.ts +0 -117
- package/src/fixtures.ts +0 -128
- package/ui/dist/assets/index-B8f9NB4z.css +0 -1
- package/ui/dist/assets/index-zWp-rB7b.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
|
+
})
|