@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.
- package/README.md +120 -24
- package/package.json +4 -2
- package/src/__tests__/activity.test.ts +16 -10
- package/src/__tests__/api.test.ts +99 -3
- package/src/__tests__/board-utils.test.ts +100 -0
- package/src/__tests__/commands/board.test.ts +7 -14
- package/src/__tests__/commands/bulk.test.ts +3 -3
- package/src/__tests__/commands/column.test.ts +4 -4
- package/src/__tests__/conflict.test.ts +64 -0
- package/src/__tests__/db.test.ts +2 -2
- package/src/__tests__/id.test.ts +1 -1
- package/src/__tests__/index.test.ts +233 -56
- package/src/__tests__/jira-adf.test.ts +180 -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 +488 -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__/metrics.test.ts +2 -2
- package/src/__tests__/output.test.ts +1 -1
- package/src/__tests__/provider-capabilities.test.ts +40 -0
- package/src/__tests__/server.test.ts +291 -0
- package/src/__tests__/webhooks.test.ts +604 -0
- package/src/activity.ts +2 -12
- package/src/api.ts +156 -21
- package/src/commands/board.ts +4 -14
- package/src/commands/bulk.ts +4 -4
- package/src/commands/column.ts +4 -4
- package/src/commands/mcp.ts +87 -0
- package/src/config.ts +1 -1
- package/src/db.ts +118 -6
- package/src/errors.ts +2 -0
- package/src/id.ts +1 -1
- package/src/index.ts +83 -35
- 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/metrics.ts +1 -1
- package/src/output.ts +1 -1
- package/src/providers/capabilities.ts +22 -17
- package/src/providers/errors.ts +1 -1
- package/src/providers/index.ts +36 -6
- 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 +773 -0
- package/src/providers/linear-cache.ts +250 -71
- package/src/providers/linear-client.ts +255 -15
- package/src/providers/linear.ts +338 -20
- package/src/providers/local.ts +74 -23
- package/src/providers/types.ts +19 -3
- package/src/server.ts +141 -13
- package/src/tunnel.ts +79 -0
- package/src/types.ts +19 -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,304 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Database } from 'bun:sqlite'
|
|
3
|
+
import {
|
|
4
|
+
decodeColumnStatusIds,
|
|
5
|
+
getCachedBoard,
|
|
6
|
+
getCachedColumns,
|
|
7
|
+
getCachedConfig,
|
|
8
|
+
getCachedTask,
|
|
9
|
+
getCachedTasks,
|
|
10
|
+
initJiraCacheSchema,
|
|
11
|
+
loadJiraSyncMeta,
|
|
12
|
+
replaceJiraColumns,
|
|
13
|
+
replaceJiraIssueTypes,
|
|
14
|
+
replaceJiraPriorities,
|
|
15
|
+
saveJiraSyncMeta,
|
|
16
|
+
upsertJiraIssues,
|
|
17
|
+
upsertJiraUsers,
|
|
18
|
+
} from '../providers/jira-cache'
|
|
19
|
+
|
|
20
|
+
let db: Database
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
db = new Database(':memory:')
|
|
24
|
+
initJiraCacheSchema(db)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('jira-cache', () => {
|
|
28
|
+
test('initJiraCacheSchema is idempotent', () => {
|
|
29
|
+
saveJiraSyncMeta(db, { projectKey: 'SENTINEL' })
|
|
30
|
+
expect(() => initJiraCacheSchema(db)).not.toThrow()
|
|
31
|
+
expect(loadJiraSyncMeta(db).projectKey).toBe('SENTINEL')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('sync meta round-trips each key', () => {
|
|
35
|
+
saveJiraSyncMeta(db, {
|
|
36
|
+
projectKey: 'ENG',
|
|
37
|
+
boardId: 42,
|
|
38
|
+
lastSyncAt: '2026-01-01T00:00:00Z',
|
|
39
|
+
lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
|
|
40
|
+
})
|
|
41
|
+
const loaded = loadJiraSyncMeta(db)
|
|
42
|
+
expect(loaded.projectKey).toBe('ENG')
|
|
43
|
+
expect(loaded.boardId).toBe(42)
|
|
44
|
+
expect(loaded.lastSyncAt).toBe('2026-01-01T00:00:00Z')
|
|
45
|
+
expect(loaded.lastIssueUpdatedAt).toBe('2026-01-02T00:00:00Z')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('replaceJiraColumns preserves status_ids ordering and source', () => {
|
|
49
|
+
replaceJiraColumns(db, [
|
|
50
|
+
{
|
|
51
|
+
id: 'board:1:To Do',
|
|
52
|
+
name: 'To Do',
|
|
53
|
+
position: 0,
|
|
54
|
+
statusIds: ['10001', '10002', '10003'],
|
|
55
|
+
source: 'board',
|
|
56
|
+
},
|
|
57
|
+
])
|
|
58
|
+
const columns = getCachedColumns(db)
|
|
59
|
+
expect(columns).toHaveLength(1)
|
|
60
|
+
const col = columns[0]!
|
|
61
|
+
expect(col.source).toBe('board')
|
|
62
|
+
expect(decodeColumnStatusIds(col)).toEqual(['10001', '10002', '10003'])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('decodeColumnStatusIds handles 3-element array without ordering loss', () => {
|
|
66
|
+
expect(decodeColumnStatusIds({ status_ids: JSON.stringify(['c', 'a', 'b']) })).toEqual([
|
|
67
|
+
'c',
|
|
68
|
+
'a',
|
|
69
|
+
'b',
|
|
70
|
+
])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('upsertJiraUsers round-trip and active filter', () => {
|
|
74
|
+
upsertJiraUsers(db, [
|
|
75
|
+
{ accountId: 'a1', displayName: 'Alice', active: true },
|
|
76
|
+
{ accountId: 'a2', displayName: 'Bob', active: true },
|
|
77
|
+
{ accountId: 'a3', displayName: 'Zara', active: false },
|
|
78
|
+
])
|
|
79
|
+
const { users } = getCachedConfig(db)
|
|
80
|
+
expect(users).toHaveLength(2)
|
|
81
|
+
expect(users.map((u) => u.displayName).sort()).toEqual(['Alice', 'Bob'])
|
|
82
|
+
expect(users.every((u) => typeof u.accountId === 'string')).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('upsertJiraIssues round-trip with full Task shape', () => {
|
|
86
|
+
upsertJiraIssues(db, [
|
|
87
|
+
{
|
|
88
|
+
id: '10001',
|
|
89
|
+
key: 'ENG-1',
|
|
90
|
+
summary: 'Fix login',
|
|
91
|
+
descriptionText: 'hello world',
|
|
92
|
+
statusId: '30',
|
|
93
|
+
priorityName: 'High',
|
|
94
|
+
issueTypeName: 'Bug',
|
|
95
|
+
assigneeAccountId: 'a1',
|
|
96
|
+
assigneeName: 'Alice',
|
|
97
|
+
projectKey: 'ENG',
|
|
98
|
+
url: 'https://jira/browse/ENG-1',
|
|
99
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
100
|
+
updatedAt: '2026-01-02T00:00:00Z',
|
|
101
|
+
},
|
|
102
|
+
])
|
|
103
|
+
const viaKey = getCachedTask(db, 'ENG-1')
|
|
104
|
+
expect(viaKey).not.toBeNull()
|
|
105
|
+
expect(viaKey).toMatchObject({
|
|
106
|
+
id: 'jira:10001',
|
|
107
|
+
providerId: '10001',
|
|
108
|
+
externalRef: 'ENG-1',
|
|
109
|
+
title: 'Fix login',
|
|
110
|
+
description: 'hello world',
|
|
111
|
+
column_id: '30',
|
|
112
|
+
position: 0,
|
|
113
|
+
priority: 'high',
|
|
114
|
+
assignee: 'Alice',
|
|
115
|
+
project: 'ENG',
|
|
116
|
+
url: 'https://jira/browse/ENG-1',
|
|
117
|
+
metadata: '{}',
|
|
118
|
+
})
|
|
119
|
+
expect(getCachedTask(db, 'jira:10001')).not.toBeNull()
|
|
120
|
+
expect(getCachedTask(db, '10001')).not.toBeNull()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('getCachedBoard groups issues by many-to-one column-to-status mapping', () => {
|
|
124
|
+
replaceJiraColumns(db, [
|
|
125
|
+
{
|
|
126
|
+
id: 'board:1:In Progress',
|
|
127
|
+
name: 'In Progress',
|
|
128
|
+
position: 0,
|
|
129
|
+
statusIds: ['10001', '10002'],
|
|
130
|
+
source: 'board',
|
|
131
|
+
},
|
|
132
|
+
])
|
|
133
|
+
upsertJiraIssues(db, [
|
|
134
|
+
{
|
|
135
|
+
id: '1',
|
|
136
|
+
key: 'ENG-1',
|
|
137
|
+
summary: 'a',
|
|
138
|
+
descriptionText: '',
|
|
139
|
+
statusId: '10001',
|
|
140
|
+
projectKey: 'ENG',
|
|
141
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
142
|
+
updatedAt: '2026-01-03T00:00:00Z',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: '2',
|
|
146
|
+
key: 'ENG-2',
|
|
147
|
+
summary: 'b',
|
|
148
|
+
descriptionText: '',
|
|
149
|
+
statusId: '10002',
|
|
150
|
+
projectKey: 'ENG',
|
|
151
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
152
|
+
updatedAt: '2026-01-02T00:00:00Z',
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: '3',
|
|
156
|
+
key: 'ENG-3',
|
|
157
|
+
summary: 'c',
|
|
158
|
+
descriptionText: '',
|
|
159
|
+
statusId: '99999',
|
|
160
|
+
projectKey: 'ENG',
|
|
161
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
162
|
+
updatedAt: '2026-01-01T00:00:00Z',
|
|
163
|
+
},
|
|
164
|
+
])
|
|
165
|
+
const board = getCachedBoard(db)
|
|
166
|
+
expect(board.columns).toHaveLength(1)
|
|
167
|
+
const col = board.columns[0]!
|
|
168
|
+
expect(col.tasks).toHaveLength(2)
|
|
169
|
+
const refs = new Set(col.tasks.map((t) => t.externalRef))
|
|
170
|
+
expect(refs).toEqual(new Set(['ENG-1', 'ENG-2']))
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('getCachedTasks({ columnId }) filters to the column status_ids', () => {
|
|
174
|
+
const columnId = 'board:1:In Progress'
|
|
175
|
+
replaceJiraColumns(db, [
|
|
176
|
+
{
|
|
177
|
+
id: columnId,
|
|
178
|
+
name: 'In Progress',
|
|
179
|
+
position: 0,
|
|
180
|
+
statusIds: ['10001', '10002'],
|
|
181
|
+
source: 'board',
|
|
182
|
+
},
|
|
183
|
+
])
|
|
184
|
+
upsertJiraIssues(db, [
|
|
185
|
+
{
|
|
186
|
+
id: '1',
|
|
187
|
+
key: 'ENG-1',
|
|
188
|
+
summary: 'a',
|
|
189
|
+
descriptionText: '',
|
|
190
|
+
statusId: '10001',
|
|
191
|
+
projectKey: 'ENG',
|
|
192
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
193
|
+
updatedAt: '2026-01-03T00:00:00Z',
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: '2',
|
|
197
|
+
key: 'ENG-2',
|
|
198
|
+
summary: 'b',
|
|
199
|
+
descriptionText: '',
|
|
200
|
+
statusId: '10002',
|
|
201
|
+
projectKey: 'ENG',
|
|
202
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
203
|
+
updatedAt: '2026-01-02T00:00:00Z',
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: '3',
|
|
207
|
+
key: 'ENG-3',
|
|
208
|
+
summary: 'c',
|
|
209
|
+
descriptionText: '',
|
|
210
|
+
statusId: '99999',
|
|
211
|
+
projectKey: 'ENG',
|
|
212
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
213
|
+
updatedAt: '2026-01-01T00:00:00Z',
|
|
214
|
+
},
|
|
215
|
+
])
|
|
216
|
+
expect(getCachedTasks(db, { columnId })).toHaveLength(2)
|
|
217
|
+
expect(getCachedTasks(db)).toHaveLength(3)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('priority name mapping is case-insensitive with urgent/high/medium/low fallback', () => {
|
|
221
|
+
const samples: Array<{ key: string; priority: string; expected: string }> = [
|
|
222
|
+
{ key: 'ENG-1', priority: 'Highest', expected: 'urgent' },
|
|
223
|
+
{ key: 'ENG-2', priority: 'high', expected: 'high' },
|
|
224
|
+
{ key: 'ENG-3', priority: 'MEDIUM', expected: 'medium' },
|
|
225
|
+
{ key: 'ENG-4', priority: 'weird', expected: 'low' },
|
|
226
|
+
{ key: 'ENG-5', priority: '', expected: 'low' },
|
|
227
|
+
]
|
|
228
|
+
upsertJiraIssues(
|
|
229
|
+
db,
|
|
230
|
+
samples.map((s, i) => ({
|
|
231
|
+
id: String(1000 + i),
|
|
232
|
+
key: s.key,
|
|
233
|
+
summary: s.key,
|
|
234
|
+
descriptionText: '',
|
|
235
|
+
statusId: '10',
|
|
236
|
+
priorityName: s.priority,
|
|
237
|
+
projectKey: 'ENG',
|
|
238
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
239
|
+
updatedAt: `2026-01-0${i + 1}T00:00:00Z`,
|
|
240
|
+
})),
|
|
241
|
+
)
|
|
242
|
+
for (const s of samples) {
|
|
243
|
+
const task = getCachedTask(db, s.key)
|
|
244
|
+
expect(task).not.toBeNull()
|
|
245
|
+
expect(task?.priority as string).toBe(s.expected)
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('saveJiraSyncMeta partial does not clobber prior values; omit=preserve, null=clear', () => {
|
|
250
|
+
saveJiraSyncMeta(db, { projectKey: 'ENG', boardId: 42 })
|
|
251
|
+
saveJiraSyncMeta(db, { lastSyncAt: '2026-01-01T00:00:00Z' })
|
|
252
|
+
let loaded = loadJiraSyncMeta(db)
|
|
253
|
+
expect(loaded.projectKey).toBe('ENG')
|
|
254
|
+
expect(loaded.boardId).toBe(42)
|
|
255
|
+
expect(loaded.lastSyncAt).toBe('2026-01-01T00:00:00Z')
|
|
256
|
+
|
|
257
|
+
saveJiraSyncMeta(db, { projectKey: null })
|
|
258
|
+
loaded = loadJiraSyncMeta(db)
|
|
259
|
+
expect(loaded.projectKey).toBeNull()
|
|
260
|
+
expect(loaded.boardId).toBe(42)
|
|
261
|
+
expect(loaded.lastSyncAt).toBe('2026-01-01T00:00:00Z')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('getCachedConfig returns internal shape, not BoardConfig', () => {
|
|
265
|
+
upsertJiraUsers(db, [{ accountId: 'a1', displayName: 'Alice' }])
|
|
266
|
+
replaceJiraPriorities(db, [{ id: '1', name: 'High' }])
|
|
267
|
+
replaceJiraIssueTypes(db, [{ id: '10000', name: 'Bug' }])
|
|
268
|
+
saveJiraSyncMeta(db, { projectKey: 'ENG' })
|
|
269
|
+
const config = getCachedConfig(db)
|
|
270
|
+
expect(config.projectKey).toBe('ENG')
|
|
271
|
+
expect(Array.isArray(config.users)).toBe(true)
|
|
272
|
+
expect(config.users.length).toBeGreaterThan(0)
|
|
273
|
+
expect(config.users[0]).toEqual({ accountId: 'a1', displayName: 'Alice' })
|
|
274
|
+
expect(config.priorities.length).toBeGreaterThan(0)
|
|
275
|
+
expect(config.issueTypes.length).toBeGreaterThan(0)
|
|
276
|
+
const keys = Object.keys(config)
|
|
277
|
+
expect(keys).not.toContain('members')
|
|
278
|
+
expect(keys).not.toContain('discoveredAssignees')
|
|
279
|
+
expect(keys).not.toContain('discoveredProjects')
|
|
280
|
+
expect(keys).not.toContain('provider')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('empty status_ids array short-circuits with no SQL error', () => {
|
|
284
|
+
const columnId = 'board:1:Empty'
|
|
285
|
+
replaceJiraColumns(db, [
|
|
286
|
+
{ id: columnId, name: 'Empty', position: 0, statusIds: [], source: 'board' },
|
|
287
|
+
])
|
|
288
|
+
upsertJiraIssues(db, [
|
|
289
|
+
{
|
|
290
|
+
id: '1',
|
|
291
|
+
key: 'ENG-1',
|
|
292
|
+
summary: 'a',
|
|
293
|
+
descriptionText: '',
|
|
294
|
+
statusId: '10001',
|
|
295
|
+
projectKey: 'ENG',
|
|
296
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
297
|
+
updatedAt: '2026-01-03T00:00:00Z',
|
|
298
|
+
},
|
|
299
|
+
])
|
|
300
|
+
const board = getCachedBoard(db)
|
|
301
|
+
expect(board.columns[0]!.tasks).toEqual([])
|
|
302
|
+
expect(getCachedTasks(db, { columnId })).toEqual([])
|
|
303
|
+
})
|
|
304
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import { ErrorCode, KanbanError } from '../errors'
|
|
4
|
+
import { JiraClient } from '../providers/jira-client'
|
|
5
|
+
|
|
6
|
+
const origFetch = globalThis.fetch
|
|
7
|
+
let lastRequest: { url: string; init?: RequestInit } | null = null
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
globalThis.fetch = origFetch
|
|
11
|
+
lastRequest = null
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
function stub(response: Response): void {
|
|
15
|
+
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
|
|
16
|
+
lastRequest = { url: String(url), init }
|
|
17
|
+
return response
|
|
18
|
+
}) as unknown as typeof fetch
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
22
|
+
return new Response(JSON.stringify(body), {
|
|
23
|
+
status,
|
|
24
|
+
headers: { 'content-type': 'application/json' },
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeClient(): JiraClient {
|
|
29
|
+
return new JiraClient({
|
|
30
|
+
baseUrl: 'https://example.atlassian.net/',
|
|
31
|
+
email: 'user@example.com',
|
|
32
|
+
apiToken: 'tok123',
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('JiraClient', () => {
|
|
37
|
+
test('auth header format', async () => {
|
|
38
|
+
stub(jsonResponse({ id: '10000', key: 'ABC', name: 'Alpha' }))
|
|
39
|
+
const client = makeClient()
|
|
40
|
+
await client.getProject('ABC')
|
|
41
|
+
expect(lastRequest).not.toBeNull()
|
|
42
|
+
const headers = (lastRequest!.init!.headers ?? {}) as Record<string, string>
|
|
43
|
+
const expected = `Basic ${Buffer.from('user@example.com:tok123').toString('base64')}`
|
|
44
|
+
expect(headers.Authorization).toBe(expected)
|
|
45
|
+
expect(headers.Accept).toBe('application/json')
|
|
46
|
+
expect(headers['Content-Type']).toBe('application/json')
|
|
47
|
+
expect(lastRequest!.url).toBe('https://example.atlassian.net/rest/api/3/project/ABC')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('happy path getIssue', async () => {
|
|
51
|
+
const body = {
|
|
52
|
+
id: '10001',
|
|
53
|
+
key: 'ABC-1',
|
|
54
|
+
fields: {
|
|
55
|
+
summary: 'hello',
|
|
56
|
+
status: { id: '1', name: 'To Do' },
|
|
57
|
+
issuetype: { id: '10000', name: 'Task' },
|
|
58
|
+
created: '2026-01-01T00:00:00.000Z',
|
|
59
|
+
updated: '2026-01-02T00:00:00.000Z',
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
stub(jsonResponse(body))
|
|
63
|
+
const client = makeClient()
|
|
64
|
+
const issue = await client.getIssue('ABC-1')
|
|
65
|
+
expect(issue.key).toBe('ABC-1')
|
|
66
|
+
expect(issue.fields.summary).toBe('hello')
|
|
67
|
+
expect(issue.fields.status.name).toBe('To Do')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('401 maps to PROVIDER_AUTH_FAILED', async () => {
|
|
71
|
+
stub(new Response('', { status: 401 }))
|
|
72
|
+
const client = makeClient()
|
|
73
|
+
let err: unknown
|
|
74
|
+
try {
|
|
75
|
+
await client.getIssue('ABC-1')
|
|
76
|
+
} catch (e) {
|
|
77
|
+
err = e
|
|
78
|
+
}
|
|
79
|
+
expect(err).toBeInstanceOf(KanbanError)
|
|
80
|
+
expect((err as KanbanError).code).toBe(ErrorCode.PROVIDER_AUTH_FAILED)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('403 maps to PROVIDER_AUTH_FAILED', async () => {
|
|
84
|
+
stub(new Response('', { status: 403 }))
|
|
85
|
+
const client = makeClient()
|
|
86
|
+
let err: unknown
|
|
87
|
+
try {
|
|
88
|
+
await client.getIssue('ABC-1')
|
|
89
|
+
} catch (e) {
|
|
90
|
+
err = e
|
|
91
|
+
}
|
|
92
|
+
expect(err).toBeInstanceOf(KanbanError)
|
|
93
|
+
expect((err as KanbanError).code).toBe(ErrorCode.PROVIDER_AUTH_FAILED)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('429 maps to PROVIDER_RATE_LIMITED', async () => {
|
|
97
|
+
stub(new Response('', { status: 429 }))
|
|
98
|
+
const client = makeClient()
|
|
99
|
+
let err: unknown
|
|
100
|
+
try {
|
|
101
|
+
await client.getIssue('ABC-1')
|
|
102
|
+
} catch (e) {
|
|
103
|
+
err = e
|
|
104
|
+
}
|
|
105
|
+
expect(err).toBeInstanceOf(KanbanError)
|
|
106
|
+
expect((err as KanbanError).code).toBe(ErrorCode.PROVIDER_RATE_LIMITED)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('400 with errorMessages and errors maps to PROVIDER_UPSTREAM_ERROR', async () => {
|
|
110
|
+
stub(
|
|
111
|
+
jsonResponse(
|
|
112
|
+
{
|
|
113
|
+
errorMessages: ['Field required'],
|
|
114
|
+
errors: { summary: 'is required' },
|
|
115
|
+
},
|
|
116
|
+
400,
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
const client = makeClient()
|
|
120
|
+
let err: unknown
|
|
121
|
+
try {
|
|
122
|
+
await client.createIssue({ fields: {} })
|
|
123
|
+
} catch (e) {
|
|
124
|
+
err = e
|
|
125
|
+
}
|
|
126
|
+
expect(err).toBeInstanceOf(KanbanError)
|
|
127
|
+
const ke = err as KanbanError
|
|
128
|
+
expect(ke.code).toBe(ErrorCode.PROVIDER_UPSTREAM_ERROR)
|
|
129
|
+
expect(ke.message).toContain('Field required')
|
|
130
|
+
expect(ke.message).toContain('summary')
|
|
131
|
+
expect(ke.message).toContain('is required')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('listIssues pagination passes startAt and maxResults as query params', async () => {
|
|
135
|
+
stub(jsonResponse({ startAt: 50, maxResults: 100, total: 0, issues: [] }))
|
|
136
|
+
const client = makeClient()
|
|
137
|
+
await client.listIssues({
|
|
138
|
+
jql: 'project = ABC',
|
|
139
|
+
startAt: 50,
|
|
140
|
+
maxResults: 100,
|
|
141
|
+
})
|
|
142
|
+
expect(lastRequest).not.toBeNull()
|
|
143
|
+
const url = lastRequest!.url
|
|
144
|
+
expect(url).toContain('/rest/api/3/search/jql')
|
|
145
|
+
expect(url).toContain('startAt=50')
|
|
146
|
+
expect(url).toContain('maxResults=100')
|
|
147
|
+
// URLSearchParams canonical form uses '+' for spaces and %3D for '='
|
|
148
|
+
expect(url).toContain('jql=project+%3D+ABC')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('updateIssue succeeds on HTTP 204 without JSON parse', async () => {
|
|
152
|
+
stub(new Response(null, { status: 204 }))
|
|
153
|
+
const client = makeClient()
|
|
154
|
+
const result = await client.updateIssue('ABC-1', {
|
|
155
|
+
fields: { summary: 'new' },
|
|
156
|
+
})
|
|
157
|
+
expect(result).toBeUndefined()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('transitionIssue succeeds on HTTP 204', async () => {
|
|
161
|
+
stub(new Response(null, { status: 204 }))
|
|
162
|
+
const client = makeClient()
|
|
163
|
+
await client.transitionIssue('ABC-1', '11')
|
|
164
|
+
expect(lastRequest).not.toBeNull()
|
|
165
|
+
expect(lastRequest!.init!.method).toBe('POST')
|
|
166
|
+
const sentBody = String(lastRequest!.init!.body)
|
|
167
|
+
expect(sentBody).toContain('"transition":{"id":"11"}')
|
|
168
|
+
})
|
|
169
|
+
})
|