@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.
- 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 +8 -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-DEnUD0fq.css +0 -1
- package/ui/dist/assets/index-DMRjw1nI.js +0 -40
|
@@ -0,0 +1,594 @@
|
|
|
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
|
+
getCachedColumns,
|
|
8
|
+
getCachedTasks,
|
|
9
|
+
decodeColumnStatusIds,
|
|
10
|
+
loadTeamInfo,
|
|
11
|
+
saveJiraSyncMeta,
|
|
12
|
+
saveTeamInfo,
|
|
13
|
+
initJiraCacheSchema,
|
|
14
|
+
} from '../providers/jira-cache.ts'
|
|
15
|
+
|
|
16
|
+
type FetchInit = RequestInit | undefined
|
|
17
|
+
type StubCall = { url: string; init?: FetchInit }
|
|
18
|
+
type StubHandler = (url: string, init?: FetchInit) => Response | Promise<Response>
|
|
19
|
+
type StubRoute = { match: (url: string) => boolean; handler: StubHandler }
|
|
20
|
+
|
|
21
|
+
function jiraFetchStub(routes: StubRoute[]): {
|
|
22
|
+
fn: typeof fetch
|
|
23
|
+
calls: StubCall[]
|
|
24
|
+
} {
|
|
25
|
+
const calls: StubCall[] = []
|
|
26
|
+
const fn = (async (input: string | URL | Request, init?: FetchInit) => {
|
|
27
|
+
const url =
|
|
28
|
+
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
|
|
29
|
+
calls.push({ url, init })
|
|
30
|
+
for (const r of routes) {
|
|
31
|
+
if (r.match(url)) return r.handler(url, init)
|
|
32
|
+
}
|
|
33
|
+
return new Response('route not stubbed: ' + url, { status: 500 })
|
|
34
|
+
}) as unknown as typeof fetch
|
|
35
|
+
return { fn, calls }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
39
|
+
return new Response(JSON.stringify(body), {
|
|
40
|
+
status,
|
|
41
|
+
headers: { 'content-type': 'application/json' },
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const baseConfig: JiraProviderConfig = {
|
|
46
|
+
baseUrl: 'https://example.atlassian.net',
|
|
47
|
+
email: 'user@example.com',
|
|
48
|
+
apiToken: 'token',
|
|
49
|
+
projectKey: 'ENG',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const projectFixture = { id: '10000', key: 'ENG', name: 'Engineering' }
|
|
53
|
+
const usersFixture = [
|
|
54
|
+
{ accountId: 'a1', displayName: 'Alice', active: true },
|
|
55
|
+
{ accountId: 'a2', displayName: 'Bob', active: true },
|
|
56
|
+
]
|
|
57
|
+
const prioritiesFixture = [
|
|
58
|
+
{ id: '1', name: 'Highest' },
|
|
59
|
+
{ id: '2', name: 'High' },
|
|
60
|
+
]
|
|
61
|
+
const issueTypesFixture = [{ id: '10000', name: 'Bug' }]
|
|
62
|
+
|
|
63
|
+
function makeIssue(opts: {
|
|
64
|
+
id: string
|
|
65
|
+
key: string
|
|
66
|
+
statusId: string
|
|
67
|
+
updated?: string
|
|
68
|
+
summary?: string
|
|
69
|
+
assignee?: { accountId: string; displayName: string } | null
|
|
70
|
+
}): Record<string, unknown> {
|
|
71
|
+
return {
|
|
72
|
+
id: opts.id,
|
|
73
|
+
key: opts.key,
|
|
74
|
+
fields: {
|
|
75
|
+
summary: opts.summary ?? opts.key,
|
|
76
|
+
description: null,
|
|
77
|
+
status: { id: opts.statusId, name: 'Status ' + opts.statusId },
|
|
78
|
+
issuetype: { id: '10000', name: 'Bug' },
|
|
79
|
+
priority: { id: '2', name: 'High' },
|
|
80
|
+
assignee: opts.assignee ?? null,
|
|
81
|
+
created: '2026-01-01T00:00:00Z',
|
|
82
|
+
updated: opts.updated ?? '2026-01-02T00:00:00Z',
|
|
83
|
+
project: { id: '10000', key: 'ENG' },
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const boardConfigFixture = {
|
|
89
|
+
id: 3,
|
|
90
|
+
name: 'ENG Board',
|
|
91
|
+
columnConfig: {
|
|
92
|
+
columns: [{ name: 'Done', statuses: [{ id: '10001' }, { id: '10002' }] }],
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const statusCategoriesFixture = [
|
|
97
|
+
{
|
|
98
|
+
id: 'cat-1',
|
|
99
|
+
name: 'To Do',
|
|
100
|
+
statuses: [
|
|
101
|
+
{ id: '10001', name: 'To Do' },
|
|
102
|
+
{ id: '10002', name: 'In Progress' },
|
|
103
|
+
{ id: '10003', name: 'Done' },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
function standardRoutes(opts: {
|
|
109
|
+
boardCfg?: unknown
|
|
110
|
+
statuses?: unknown
|
|
111
|
+
users?: unknown
|
|
112
|
+
priorities?: unknown
|
|
113
|
+
issueTypes?: unknown
|
|
114
|
+
changelogHandler?: StubHandler
|
|
115
|
+
searchHandler?: StubHandler
|
|
116
|
+
}): StubRoute[] {
|
|
117
|
+
return [
|
|
118
|
+
{
|
|
119
|
+
match: (u) => u.includes('/rest/api/3/project/ENG/statuses'),
|
|
120
|
+
handler: () => jsonResponse(opts.statuses ?? statusCategoriesFixture),
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
match: (u) => u.includes('/rest/api/3/project/ENG'),
|
|
124
|
+
handler: () => jsonResponse(projectFixture),
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
match: (u) => u.includes('/rest/agile/1.0/board/'),
|
|
128
|
+
handler: () => jsonResponse(opts.boardCfg ?? boardConfigFixture),
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
match: (u) => u.includes('/rest/api/3/user/assignable/search'),
|
|
132
|
+
handler: () => jsonResponse(opts.users ?? usersFixture),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
match: (u) => u.includes('/rest/api/3/priority'),
|
|
136
|
+
handler: () => jsonResponse(opts.priorities ?? prioritiesFixture),
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
match: (u) => u.includes('/rest/api/3/issuetype/project'),
|
|
140
|
+
handler: () => jsonResponse(opts.issueTypes ?? issueTypesFixture),
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
match: (u) => /\/rest\/api\/3\/issue\/[^/]+\/changelog/.test(u),
|
|
144
|
+
handler:
|
|
145
|
+
opts.changelogHandler ??
|
|
146
|
+
(() =>
|
|
147
|
+
jsonResponse({
|
|
148
|
+
startAt: 0,
|
|
149
|
+
maxResults: 100,
|
|
150
|
+
total: 0,
|
|
151
|
+
isLast: true,
|
|
152
|
+
values: [],
|
|
153
|
+
})),
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
match: (u) => u.includes('/rest/api/3/search/jql'),
|
|
157
|
+
handler:
|
|
158
|
+
opts.searchHandler ??
|
|
159
|
+
(() =>
|
|
160
|
+
jsonResponse({
|
|
161
|
+
startAt: 0,
|
|
162
|
+
maxResults: 100,
|
|
163
|
+
total: 0,
|
|
164
|
+
issues: [],
|
|
165
|
+
})),
|
|
166
|
+
},
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let db: Database
|
|
171
|
+
let originalFetch: typeof fetch
|
|
172
|
+
let originalDateNow: () => number
|
|
173
|
+
|
|
174
|
+
beforeEach(() => {
|
|
175
|
+
db = new Database(':memory:')
|
|
176
|
+
originalFetch = globalThis.fetch
|
|
177
|
+
originalDateNow = Date.now
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
afterEach(() => {
|
|
181
|
+
globalThis.fetch = originalFetch
|
|
182
|
+
Date.now = originalDateNow
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
function makeProvider(routes: StubRoute[]): {
|
|
186
|
+
provider: JiraProvider
|
|
187
|
+
calls: StubCall[]
|
|
188
|
+
config: JiraProviderConfig
|
|
189
|
+
} {
|
|
190
|
+
const { fn, calls } = jiraFetchStub(routes)
|
|
191
|
+
globalThis.fetch = fn
|
|
192
|
+
const client = new JiraClient({
|
|
193
|
+
baseUrl: baseConfig.baseUrl,
|
|
194
|
+
email: baseConfig.email,
|
|
195
|
+
apiToken: baseConfig.apiToken,
|
|
196
|
+
})
|
|
197
|
+
const provider = new JiraProvider(db, baseConfig, client)
|
|
198
|
+
return { provider, calls, config: baseConfig }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function makeProviderWithBoard(
|
|
202
|
+
routes: StubRoute[],
|
|
203
|
+
boardId: number,
|
|
204
|
+
): {
|
|
205
|
+
provider: JiraProvider
|
|
206
|
+
calls: StubCall[]
|
|
207
|
+
} {
|
|
208
|
+
const { fn, calls } = jiraFetchStub(routes)
|
|
209
|
+
globalThis.fetch = fn
|
|
210
|
+
const cfg = { ...baseConfig, boardId }
|
|
211
|
+
const client = new JiraClient({
|
|
212
|
+
baseUrl: cfg.baseUrl,
|
|
213
|
+
email: cfg.email,
|
|
214
|
+
apiToken: cfg.apiToken,
|
|
215
|
+
})
|
|
216
|
+
const provider = new JiraProvider(db, cfg, client)
|
|
217
|
+
return { provider, calls }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
describe('JiraProvider read path', () => {
|
|
221
|
+
test('sync populates columns from board with multi-status mapping', async () => {
|
|
222
|
+
const { provider } = makeProviderWithBoard(standardRoutes({}), 3)
|
|
223
|
+
await provider.getBoard()
|
|
224
|
+
const cols = getCachedColumns(db)
|
|
225
|
+
expect(cols).toHaveLength(1)
|
|
226
|
+
expect(cols[0]!.source).toBe('board')
|
|
227
|
+
expect(cols[0]!.position).toBe(0)
|
|
228
|
+
expect(decodeColumnStatusIds(cols[0]!)).toEqual(['10001', '10002'])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('sync populates columns from statuses when boardId is absent', async () => {
|
|
232
|
+
const { provider } = makeProvider(standardRoutes({}))
|
|
233
|
+
await provider.getBoard()
|
|
234
|
+
const cols = getCachedColumns(db)
|
|
235
|
+
expect(cols).toHaveLength(3)
|
|
236
|
+
for (const c of cols) {
|
|
237
|
+
expect(c.source).toBe('status')
|
|
238
|
+
expect(decodeColumnStatusIds(c)).toHaveLength(1)
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('sync delta JQL is exactly project = KEY AND updated >= "<ts>" ORDER BY updated ASC', async () => {
|
|
243
|
+
const capturedJql: string[] = []
|
|
244
|
+
// First sync: one issue returned so lastIssueUpdatedAt is set.
|
|
245
|
+
const searchHandler: StubHandler = (url) => {
|
|
246
|
+
const parsed = new URL(url)
|
|
247
|
+
const jql = parsed.searchParams.get('jql') ?? ''
|
|
248
|
+
capturedJql.push(jql)
|
|
249
|
+
const startAt = Number(parsed.searchParams.get('startAt') ?? '0')
|
|
250
|
+
if (startAt === 0 && capturedJql.length === 1) {
|
|
251
|
+
return jsonResponse({
|
|
252
|
+
startAt: 0,
|
|
253
|
+
maxResults: 100,
|
|
254
|
+
total: 1,
|
|
255
|
+
issues: [
|
|
256
|
+
makeIssue({
|
|
257
|
+
id: '99',
|
|
258
|
+
key: 'ENG-99',
|
|
259
|
+
statusId: '10001',
|
|
260
|
+
updated: '2026-01-05T00:00:00Z',
|
|
261
|
+
}),
|
|
262
|
+
],
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
return jsonResponse({ startAt: 0, maxResults: 100, total: 0, issues: [] })
|
|
266
|
+
}
|
|
267
|
+
const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
|
|
268
|
+
await provider.getBoard()
|
|
269
|
+
expect(capturedJql[0]).toBe(
|
|
270
|
+
'project = ENG AND updated >= "1970-01-01 00:00" ORDER BY updated ASC',
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
// Advance clock past throttle so second sync runs.
|
|
274
|
+
const origNow = originalDateNow
|
|
275
|
+
Date.now = () => origNow() + 31_000
|
|
276
|
+
await provider.getBoard()
|
|
277
|
+
expect(capturedJql[1]).toBe(
|
|
278
|
+
'project = ENG AND updated >= "2026-01-05T00:00:00Z" ORDER BY updated ASC',
|
|
279
|
+
)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
test('periodic full reconciliation prunes cached issues missing upstream', async () => {
|
|
283
|
+
const capturedJql: string[] = []
|
|
284
|
+
let searchCalls = 0
|
|
285
|
+
const searchHandler: StubHandler = (url) => {
|
|
286
|
+
const parsed = new URL(url)
|
|
287
|
+
const jql = parsed.searchParams.get('jql') ?? ''
|
|
288
|
+
capturedJql.push(jql)
|
|
289
|
+
searchCalls += 1
|
|
290
|
+
if (searchCalls === 1) {
|
|
291
|
+
return jsonResponse({
|
|
292
|
+
startAt: 0,
|
|
293
|
+
maxResults: 100,
|
|
294
|
+
total: 2,
|
|
295
|
+
issues: [
|
|
296
|
+
makeIssue({
|
|
297
|
+
id: '1',
|
|
298
|
+
key: 'ENG-1',
|
|
299
|
+
statusId: '10001',
|
|
300
|
+
updated: '2026-01-02T00:00:00Z',
|
|
301
|
+
}),
|
|
302
|
+
makeIssue({
|
|
303
|
+
id: '2',
|
|
304
|
+
key: 'ENG-2',
|
|
305
|
+
statusId: '10001',
|
|
306
|
+
updated: '2026-01-03T00:00:00Z',
|
|
307
|
+
}),
|
|
308
|
+
],
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
if (searchCalls === 2) {
|
|
312
|
+
return jsonResponse({ startAt: 0, maxResults: 100, total: 0, issues: [] })
|
|
313
|
+
}
|
|
314
|
+
return jsonResponse({
|
|
315
|
+
startAt: 0,
|
|
316
|
+
maxResults: 100,
|
|
317
|
+
total: 1,
|
|
318
|
+
issues: [
|
|
319
|
+
makeIssue({
|
|
320
|
+
id: '1',
|
|
321
|
+
key: 'ENG-1',
|
|
322
|
+
statusId: '10001',
|
|
323
|
+
updated: '2026-01-04T00:00:00Z',
|
|
324
|
+
}),
|
|
325
|
+
],
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
|
|
329
|
+
const baseNow = originalDateNow()
|
|
330
|
+
|
|
331
|
+
Date.now = () => baseNow
|
|
332
|
+
await provider.getBoard()
|
|
333
|
+
expect(getCachedTasks(db).map((task) => task.externalRef)).toEqual(['ENG-2', 'ENG-1'])
|
|
334
|
+
|
|
335
|
+
Date.now = () => baseNow + 31_000
|
|
336
|
+
await provider.getBoard()
|
|
337
|
+
expect(getCachedTasks(db).map((task) => task.externalRef)).toEqual(['ENG-2', 'ENG-1'])
|
|
338
|
+
|
|
339
|
+
Date.now = () => baseNow + 5 * 60_000 + 31_000
|
|
340
|
+
await provider.getBoard()
|
|
341
|
+
|
|
342
|
+
expect(searchCalls).toBe(3)
|
|
343
|
+
expect(capturedJql).toEqual([
|
|
344
|
+
'project = ENG AND updated >= "1970-01-01 00:00" ORDER BY updated ASC',
|
|
345
|
+
'project = ENG AND updated >= "2026-01-03T00:00:00Z" ORDER BY updated ASC',
|
|
346
|
+
'project = ENG AND updated >= "1970-01-01 00:00" ORDER BY updated ASC',
|
|
347
|
+
])
|
|
348
|
+
expect(getCachedTasks(db).map((task) => task.externalRef)).toEqual(['ENG-1'])
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test('listTasks filters by columnId with many-to-one mapping', async () => {
|
|
352
|
+
const { provider } = makeProvider(standardRoutes({}))
|
|
353
|
+
// Sync first (populates statuses-based columns)
|
|
354
|
+
await provider.getBoard()
|
|
355
|
+
// Overwrite with the test-scenario columns + issues directly.
|
|
356
|
+
const { replaceJiraColumns, upsertJiraIssues } = await import('../providers/jira-cache.ts')
|
|
357
|
+
replaceJiraColumns(db, [
|
|
358
|
+
{
|
|
359
|
+
id: 'board:3:Done',
|
|
360
|
+
name: 'Done',
|
|
361
|
+
position: 0,
|
|
362
|
+
statusIds: ['10001', '10002'],
|
|
363
|
+
source: 'board',
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
id: 'status:99999',
|
|
367
|
+
name: 'Other',
|
|
368
|
+
position: 1,
|
|
369
|
+
statusIds: ['99999'],
|
|
370
|
+
source: 'status',
|
|
371
|
+
},
|
|
372
|
+
])
|
|
373
|
+
upsertJiraIssues(db, [
|
|
374
|
+
{
|
|
375
|
+
id: '1',
|
|
376
|
+
key: 'ENG-1',
|
|
377
|
+
summary: 'a',
|
|
378
|
+
descriptionText: '',
|
|
379
|
+
statusId: '10001',
|
|
380
|
+
projectKey: 'ENG',
|
|
381
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
382
|
+
updatedAt: '2026-01-01T00:00:00Z',
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
id: '2',
|
|
386
|
+
key: 'ENG-2',
|
|
387
|
+
summary: 'b',
|
|
388
|
+
descriptionText: '',
|
|
389
|
+
statusId: '10002',
|
|
390
|
+
projectKey: 'ENG',
|
|
391
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
392
|
+
updatedAt: '2026-01-02T00:00:00Z',
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
id: '3',
|
|
396
|
+
key: 'ENG-3',
|
|
397
|
+
summary: 'c',
|
|
398
|
+
descriptionText: '',
|
|
399
|
+
statusId: '99999',
|
|
400
|
+
projectKey: 'ENG',
|
|
401
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
402
|
+
updatedAt: '2026-01-03T00:00:00Z',
|
|
403
|
+
},
|
|
404
|
+
])
|
|
405
|
+
// Throttle is set; listTasks will not re-fetch.
|
|
406
|
+
const byName = await provider.listTasks({ column: 'Done' })
|
|
407
|
+
expect(byName).toHaveLength(2)
|
|
408
|
+
const byId = await provider.listTasks({ column: 'status:99999' })
|
|
409
|
+
expect(byId).toHaveLength(1)
|
|
410
|
+
const all = await provider.listTasks({})
|
|
411
|
+
expect(all).toHaveLength(3)
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
test('getTask accepts both id and key', async () => {
|
|
415
|
+
const searchHandler: StubHandler = () =>
|
|
416
|
+
jsonResponse({
|
|
417
|
+
startAt: 0,
|
|
418
|
+
maxResults: 100,
|
|
419
|
+
total: 1,
|
|
420
|
+
issues: [makeIssue({ id: '10001', key: 'ENG-1', statusId: '10001' })],
|
|
421
|
+
})
|
|
422
|
+
const { provider } = makeProvider(standardRoutes({ searchHandler }))
|
|
423
|
+
await provider.getBoard()
|
|
424
|
+
const byId = await provider.getTask('10001')
|
|
425
|
+
expect(byId.externalRef).toBe('ENG-1')
|
|
426
|
+
const byKey = await provider.getTask('ENG-1')
|
|
427
|
+
expect(byKey.externalRef).toBe('ENG-1')
|
|
428
|
+
const byPrefixed = await provider.getTask('jira:10001')
|
|
429
|
+
expect(byPrefixed.externalRef).toBe('ENG-1')
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
test('getBootstrap returns provider=jira and the adapted BoardConfig', async () => {
|
|
433
|
+
const { provider } = makeProvider(standardRoutes({}))
|
|
434
|
+
const result = await provider.getBootstrap()
|
|
435
|
+
expect(result.provider).toBe('jira')
|
|
436
|
+
expect(result.capabilities.taskMove).toBe(true)
|
|
437
|
+
expect(result.capabilities.taskDelete).toBe(false)
|
|
438
|
+
expect(result.capabilities.comment).toBe(true)
|
|
439
|
+
expect(result.config.provider).toBe('jira')
|
|
440
|
+
expect(Array.isArray(result.config.members)).toBe(true)
|
|
441
|
+
expect(result.config.members.length).toBeGreaterThan(0)
|
|
442
|
+
expect(result.config.projects).toEqual(['ENG'])
|
|
443
|
+
expect('priorities' in result.config).toBe(false)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
test('getContext returns capabilities.taskMove=true and capabilities.taskDelete=false', async () => {
|
|
447
|
+
const { provider } = makeProvider(standardRoutes({}))
|
|
448
|
+
const ctx = await provider.getContext()
|
|
449
|
+
expect(ctx.capabilities.taskMove).toBe(true)
|
|
450
|
+
expect(ctx.capabilities.taskCreate).toBe(true)
|
|
451
|
+
expect(ctx.capabilities.taskUpdate).toBe(true)
|
|
452
|
+
expect(ctx.capabilities.taskDelete).toBe(false)
|
|
453
|
+
expect(ctx.capabilities.comment).toBe(true)
|
|
454
|
+
expect(ctx.capabilities.activity).toBe(false)
|
|
455
|
+
expect(ctx.capabilities.metrics).toBe(false)
|
|
456
|
+
expect(ctx.capabilities.columnCrud).toBe(false)
|
|
457
|
+
expect(ctx.capabilities.bulk).toBe(false)
|
|
458
|
+
expect(ctx.capabilities.configEdit).toBe(false)
|
|
459
|
+
expect(ctx.team).not.toBeNull()
|
|
460
|
+
expect(ctx.team?.name).toBe('Engineering')
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
test('getConfig adapter: members/projects present, priorities/issueTypes absent at BoardConfig level', async () => {
|
|
464
|
+
const { provider } = makeProvider(standardRoutes({}))
|
|
465
|
+
const config = await provider.getConfig()
|
|
466
|
+
expect(config.projects[0]).toBe('ENG')
|
|
467
|
+
expect(config.members.length).toBeGreaterThan(0)
|
|
468
|
+
const keys = Object.keys(config)
|
|
469
|
+
expect(keys).not.toContain('priorities')
|
|
470
|
+
expect(keys).not.toContain('issueTypes')
|
|
471
|
+
expect(keys).not.toContain('users')
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
test('sync throttle: two consecutive sync calls within 30s produce zero remote fetches on the second', async () => {
|
|
475
|
+
const { provider, calls } = makeProvider(standardRoutes({}))
|
|
476
|
+
await provider.getBoard()
|
|
477
|
+
const first = calls.length
|
|
478
|
+
expect(first).toBeGreaterThan(0)
|
|
479
|
+
await provider.getBoard()
|
|
480
|
+
expect(calls.length).toBe(first)
|
|
481
|
+
// Advance Date.now past the throttle window.
|
|
482
|
+
const origNow = originalDateNow
|
|
483
|
+
Date.now = () => origNow() + 31_000
|
|
484
|
+
await provider.getBoard()
|
|
485
|
+
expect(calls.length).toBeGreaterThanOrEqual(first + 2)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
test('recent webhook timestamps do not stretch polling past the normal 30 second cadence', async () => {
|
|
489
|
+
const { provider, calls } = makeProvider(standardRoutes({}))
|
|
490
|
+
const baseNow = originalDateNow()
|
|
491
|
+
|
|
492
|
+
Date.now = () => baseNow
|
|
493
|
+
await provider.getBoard()
|
|
494
|
+
const first = calls.length
|
|
495
|
+
const webhookAt = new Date(baseNow + 1_000).toISOString()
|
|
496
|
+
saveJiraSyncMeta(db, { lastWebhookAt: webhookAt })
|
|
497
|
+
|
|
498
|
+
Date.now = () => baseNow + 31_000
|
|
499
|
+
await provider.getBoard()
|
|
500
|
+
|
|
501
|
+
expect(calls.length).toBeGreaterThanOrEqual(first + 2)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
test('listColumns returns Column[] shape, not JiraColumnRow[]', async () => {
|
|
505
|
+
const { provider } = makeProvider(standardRoutes({}))
|
|
506
|
+
const cols = await provider.listColumns()
|
|
507
|
+
expect(cols.length).toBeGreaterThanOrEqual(1)
|
|
508
|
+
for (const col of cols) {
|
|
509
|
+
expect('id' in col).toBe(true)
|
|
510
|
+
expect('name' in col).toBe(true)
|
|
511
|
+
expect('position' in col).toBe(true)
|
|
512
|
+
expect('color' in col).toBe(true)
|
|
513
|
+
expect('created_at' in col).toBe(true)
|
|
514
|
+
expect('updated_at' in col).toBe(true)
|
|
515
|
+
expect('status_ids' in col).toBe(false)
|
|
516
|
+
expect('source' in col).toBe(false)
|
|
517
|
+
}
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
test('listTasks with non-existent column name throws COLUMN_NOT_FOUND', async () => {
|
|
521
|
+
// Board mode so only 'board:3:Done' column exists.
|
|
522
|
+
const { provider } = makeProviderWithBoard(standardRoutes({}), 3)
|
|
523
|
+
await provider.getBoard()
|
|
524
|
+
try {
|
|
525
|
+
await provider.listTasks({ column: 'Bogus' })
|
|
526
|
+
throw new Error('should have thrown')
|
|
527
|
+
} catch (err) {
|
|
528
|
+
expect(err).toBeInstanceOf(KanbanError)
|
|
529
|
+
expect((err as KanbanError).code).toBe(ErrorCode.COLUMN_NOT_FOUND)
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
test('sync paginates listIssues across multiple pages', async () => {
|
|
534
|
+
const searchCalls: Array<{ startAt: number }> = []
|
|
535
|
+
const page1Issues = Array.from({ length: 100 }, (_, i) =>
|
|
536
|
+
makeIssue({
|
|
537
|
+
id: String(i + 1),
|
|
538
|
+
key: `ENG-${i + 1}`,
|
|
539
|
+
statusId: '10001',
|
|
540
|
+
updated: '2026-01-02T00:00:00Z',
|
|
541
|
+
}),
|
|
542
|
+
)
|
|
543
|
+
const page2Issues = Array.from({ length: 50 }, (_, i) =>
|
|
544
|
+
makeIssue({
|
|
545
|
+
id: String(i + 101),
|
|
546
|
+
key: `ENG-${i + 101}`,
|
|
547
|
+
statusId: '10001',
|
|
548
|
+
updated: '2026-01-02T00:00:00Z',
|
|
549
|
+
}),
|
|
550
|
+
)
|
|
551
|
+
const searchHandler: StubHandler = (url) => {
|
|
552
|
+
const parsed = new URL(url)
|
|
553
|
+
const startAt = Number(parsed.searchParams.get('startAt') ?? '0')
|
|
554
|
+
searchCalls.push({ startAt })
|
|
555
|
+
if (startAt === 0) {
|
|
556
|
+
return jsonResponse({
|
|
557
|
+
startAt: 0,
|
|
558
|
+
maxResults: 100,
|
|
559
|
+
total: 150,
|
|
560
|
+
issues: page1Issues,
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
if (startAt === 100) {
|
|
564
|
+
return jsonResponse({
|
|
565
|
+
startAt: 100,
|
|
566
|
+
maxResults: 100,
|
|
567
|
+
total: 150,
|
|
568
|
+
issues: page2Issues,
|
|
569
|
+
})
|
|
570
|
+
}
|
|
571
|
+
return jsonResponse({
|
|
572
|
+
startAt,
|
|
573
|
+
maxResults: 100,
|
|
574
|
+
total: 150,
|
|
575
|
+
issues: [],
|
|
576
|
+
})
|
|
577
|
+
}
|
|
578
|
+
const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
|
|
579
|
+
await provider.getBoard()
|
|
580
|
+
expect(searchCalls).toHaveLength(2)
|
|
581
|
+
expect(searchCalls[0]!.startAt).toBe(0)
|
|
582
|
+
expect(searchCalls[1]!.startAt).toBe(100)
|
|
583
|
+
expect(getCachedTasks(db)).toHaveLength(150)
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
test('saveTeamInfo / loadTeamInfo roundtrip', () => {
|
|
587
|
+
initJiraCacheSchema(db)
|
|
588
|
+
saveTeamInfo(db, { id: '10000', key: 'ENG', name: 'Engineering' })
|
|
589
|
+
const loaded = loadTeamInfo(db)
|
|
590
|
+
expect(loaded).toEqual({ id: '10000', key: 'ENG', name: 'Engineering' })
|
|
591
|
+
saveTeamInfo(db, null)
|
|
592
|
+
expect(loadTeamInfo(db)).toBeNull()
|
|
593
|
+
})
|
|
594
|
+
})
|