@andypai/agent-kanban 0.5.1 → 0.6.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/package.json +1 -1
- package/src/__tests__/jira-client.test.ts +98 -1
- package/src/__tests__/jira-provider-mutations.test.ts +84 -59
- package/src/__tests__/jira-provider-read.test.ts +177 -20
- package/src/__tests__/postgres-jira-provider.test.ts +155 -0
- package/src/providers/jira-client.ts +70 -4
- package/src/providers/jira.ts +115 -33
- package/src/providers/postgres-jira.ts +187 -63
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andypai/agent-kanban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structured JSON output.",
|
|
5
5
|
"homepage": "https://github.com/abpai/agent-kanban#readme",
|
|
6
6
|
"repository": {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
import { Buffer } from 'node:buffer'
|
|
3
3
|
import { ErrorCode, KanbanError } from '../errors'
|
|
4
|
-
import { JiraClient } from '../providers/jira-client'
|
|
4
|
+
import { JiraClient, decideJiraPagination } from '../providers/jira-client'
|
|
5
|
+
import type { JiraIssue } from '../providers/jira-client'
|
|
5
6
|
|
|
6
7
|
const origFetch = globalThis.fetch
|
|
7
8
|
let lastRequest: { url: string; init?: RequestInit } | null = null
|
|
@@ -167,3 +168,99 @@ describe('JiraClient', () => {
|
|
|
167
168
|
expect(sentBody).toContain('"transition":{"id":"11"}')
|
|
168
169
|
})
|
|
169
170
|
})
|
|
171
|
+
|
|
172
|
+
describe('decideJiraPagination', () => {
|
|
173
|
+
const MAX = 100
|
|
174
|
+
// Only issues.length matters to the decision; build a right-sized stub array.
|
|
175
|
+
const issuesOfLength = (n: number): JiraIssue[] =>
|
|
176
|
+
Array.from({ length: n }, () => ({})) as unknown as JiraIssue[]
|
|
177
|
+
|
|
178
|
+
test('isLast=false with a fresh cursor advances', () => {
|
|
179
|
+
const d = decideJiraPagination(
|
|
180
|
+
{ isLast: false, nextPageToken: 'p2', issues: issuesOfLength(MAX) },
|
|
181
|
+
new Set(),
|
|
182
|
+
)
|
|
183
|
+
expect(d).toEqual({ nextToken: 'p2', complete: false })
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('isLast=true is a definitive complete end even if a token is present', () => {
|
|
187
|
+
const d = decideJiraPagination(
|
|
188
|
+
{ isLast: true, nextPageToken: 'p2', issues: issuesOfLength(MAX) },
|
|
189
|
+
new Set(),
|
|
190
|
+
)
|
|
191
|
+
expect(d).toEqual({ complete: true })
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('isLast=false with no cursor is incomplete (server claims more but gave no way to fetch)', () => {
|
|
195
|
+
const d = decideJiraPagination({ isLast: false, issues: issuesOfLength(MAX) }, new Set())
|
|
196
|
+
expect(d).toEqual({ complete: false })
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('isLast=false with a repeated cursor is incomplete (stalled, not a false complete)', () => {
|
|
200
|
+
const d = decideJiraPagination(
|
|
201
|
+
{ isLast: false, nextPageToken: 'seen', issues: issuesOfLength(MAX) },
|
|
202
|
+
new Set(['seen']),
|
|
203
|
+
)
|
|
204
|
+
expect(d).toEqual({ complete: false })
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('isLast absent: a full page with a fresh cursor advances', () => {
|
|
208
|
+
const d = decideJiraPagination({ nextPageToken: 'p2', issues: issuesOfLength(MAX) }, new Set())
|
|
209
|
+
expect(d).toEqual({ nextToken: 'p2', complete: false })
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// No completeness signal (no isLast, no total, no usable cursor): the scan is
|
|
213
|
+
// treated as incomplete REGARDLESS of page size. Guessing "complete" from a
|
|
214
|
+
// short/empty page would prune the entire cache on a full reconcile when a
|
|
215
|
+
// server returns a degraded response.
|
|
216
|
+
test('isLast absent: a full page with no cursor is incomplete (no completeness proof)', () => {
|
|
217
|
+
const d = decideJiraPagination({ issues: issuesOfLength(MAX) }, new Set())
|
|
218
|
+
expect(d).toEqual({ complete: false })
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('isLast absent: a short page with no cursor is incomplete (no completeness proof)', () => {
|
|
222
|
+
const d = decideJiraPagination({ issues: issuesOfLength(MAX - 1) }, new Set())
|
|
223
|
+
expect(d).toEqual({ complete: false })
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('isLast absent: an empty page is incomplete (a degraded {issues:[]} must not prune)', () => {
|
|
227
|
+
const d = decideJiraPagination({ issues: [] }, new Set())
|
|
228
|
+
expect(d).toEqual({ complete: false })
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('isLast absent: a repeated (stalled) cursor is incomplete', () => {
|
|
232
|
+
const d = decideJiraPagination(
|
|
233
|
+
{ nextPageToken: 'seen', issues: issuesOfLength(1) },
|
|
234
|
+
new Set(['seen']),
|
|
235
|
+
)
|
|
236
|
+
expect(d).toEqual({ complete: false })
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
// Legacy total/startAt awareness: the live /search/jql endpoint omits `total`,
|
|
240
|
+
// but a non-standard or mock server may supply it without a cursor. When it
|
|
241
|
+
// does, completeness is proven by total/startAt rather than page size, so a
|
|
242
|
+
// full *last* page (the whole result set) is not misread as "more remain".
|
|
243
|
+
test('isLast absent: total present, full last page is complete', () => {
|
|
244
|
+
const d = decideJiraPagination(
|
|
245
|
+
{ issues: issuesOfLength(MAX), total: MAX, startAt: 0 },
|
|
246
|
+
new Set(),
|
|
247
|
+
)
|
|
248
|
+
expect(d).toEqual({ complete: true })
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('isLast absent: total present, full non-last page is incomplete', () => {
|
|
252
|
+
const d = decideJiraPagination(
|
|
253
|
+
{ issues: issuesOfLength(MAX), total: MAX * 3, startAt: 0 },
|
|
254
|
+
new Set(),
|
|
255
|
+
)
|
|
256
|
+
expect(d).toEqual({ complete: false })
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('isLast absent: total present, final offset page reaching total is complete', () => {
|
|
260
|
+
const d = decideJiraPagination(
|
|
261
|
+
{ issues: issuesOfLength(MAX), total: MAX * 2, startAt: MAX },
|
|
262
|
+
new Set(),
|
|
263
|
+
)
|
|
264
|
+
expect(d).toEqual({ complete: true })
|
|
265
|
+
})
|
|
266
|
+
})
|
|
@@ -194,6 +194,19 @@ function standardSyncRoutes(opts: SyncRoutesOpts): StubRoute[] {
|
|
|
194
194
|
match: (u) => u.includes('/rest/api/3/issuetype/project'),
|
|
195
195
|
handler: () => jsonResponse(opts.issueTypes ?? []),
|
|
196
196
|
},
|
|
197
|
+
{
|
|
198
|
+
// GET /rest/api/3/issue/{key} backs hydrateIssueByKey's read-after-write.
|
|
199
|
+
// Serves the post-mutation issue from the same pool as /search so tests
|
|
200
|
+
// that push a created/updated issue into `opts.issues` see it here too.
|
|
201
|
+
match: (u, init) =>
|
|
202
|
+
/\/rest\/api\/3\/issue\/[A-Z]+-\d+$/.test(new URL(u).pathname) &&
|
|
203
|
+
(init?.method ?? 'GET') === 'GET',
|
|
204
|
+
handler: (u) => {
|
|
205
|
+
const key = new URL(u).pathname.split('/').pop()!
|
|
206
|
+
const found = (opts.issues ?? []).find((iss) => (iss as { key?: string }).key === key)
|
|
207
|
+
return found ? jsonResponse(found) : jsonResponse({ errorMessages: ['not found'] }, 404)
|
|
208
|
+
},
|
|
209
|
+
},
|
|
197
210
|
{
|
|
198
211
|
match: (u) => u.includes('/rest/api/3/search/jql'),
|
|
199
212
|
handler: () =>
|
|
@@ -298,16 +311,16 @@ function fullSyncRoutes(extraIssues: Record<string, unknown>[] = []): StubRoute[
|
|
|
298
311
|
describe('JiraProvider mutations', () => {
|
|
299
312
|
test('createTask happy path: plainTextToAdf invoked, priority mapped, assignee and issueType resolved', async () => {
|
|
300
313
|
fullSeed(db)
|
|
301
|
-
// Shared
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
const createdIssues: Record<string, unknown>[] = []
|
|
314
|
+
// Shared issue pool: the POST /issue handler appends the created issue, which
|
|
315
|
+
// hydrateIssueByKey then reads back through the GET /issue/{key} route so the
|
|
316
|
+
// returned task reflects the create.
|
|
305
317
|
const createdIssue = makeJiraIssueFixture({
|
|
306
318
|
id: '600',
|
|
307
319
|
key: 'ENG-10',
|
|
308
320
|
statusId: '20000',
|
|
309
321
|
summary: 'Fix',
|
|
310
322
|
})
|
|
323
|
+
const issues: Record<string, unknown>[] = [makeJiraIssueFixture(seedIssues[0]!)]
|
|
311
324
|
const syncRoutes: StubRoute[] = standardSyncRoutes({
|
|
312
325
|
projectKey: 'ENG',
|
|
313
326
|
columns: [
|
|
@@ -317,28 +330,12 @@ describe('JiraProvider mutations', () => {
|
|
|
317
330
|
users: seedUsers,
|
|
318
331
|
priorities: seedPriorities,
|
|
319
332
|
issueTypes: seedIssueTypes,
|
|
320
|
-
issues
|
|
333
|
+
issues,
|
|
321
334
|
})
|
|
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
335
|
const mutationRoute: StubRoute = {
|
|
339
336
|
match: (u, init) => u.endsWith('/rest/api/3/issue') && (init?.method ?? 'GET') === 'POST',
|
|
340
337
|
handler: () => {
|
|
341
|
-
|
|
338
|
+
issues.push(createdIssue)
|
|
342
339
|
return jsonResponse({
|
|
343
340
|
id: '600',
|
|
344
341
|
key: 'ENG-10',
|
|
@@ -483,6 +480,62 @@ describe('JiraProvider mutations', () => {
|
|
|
483
480
|
expect(body.transition.id).toBe('21')
|
|
484
481
|
})
|
|
485
482
|
|
|
483
|
+
test('moveTask ingests the moved issue changelog via hydration (activity recorded immediately)', async () => {
|
|
484
|
+
// seedCache sets a recent lastSyncAt, so moveTask's pre-move sync() is
|
|
485
|
+
// throttled and fetches no changelog. The only changelog call must therefore
|
|
486
|
+
// come from hydrateIssueByKey's read-after-write — proving the transition
|
|
487
|
+
// lands in jira_activity immediately rather than waiting for a later sync.
|
|
488
|
+
seedCache(db, {
|
|
489
|
+
priorities: seedPriorities,
|
|
490
|
+
users: seedUsers,
|
|
491
|
+
issueTypes: seedIssueTypes,
|
|
492
|
+
columns: seedColumns,
|
|
493
|
+
issues: [{ id: '501', key: 'ENG-1', statusId: '20000' }],
|
|
494
|
+
projectKey: 'ENG',
|
|
495
|
+
})
|
|
496
|
+
const syncRoutes = fullSyncRoutes()
|
|
497
|
+
let changelogCalls = 0
|
|
498
|
+
const changelogRoute: StubRoute = {
|
|
499
|
+
match: (u) => /\/rest\/api\/3\/issue\/501\/changelog/.test(new URL(u).pathname),
|
|
500
|
+
handler: () => {
|
|
501
|
+
changelogCalls += 1
|
|
502
|
+
return jsonResponse({
|
|
503
|
+
values: [
|
|
504
|
+
{
|
|
505
|
+
id: 'h1',
|
|
506
|
+
created: '2026-01-06T00:00:00Z',
|
|
507
|
+
items: [{ field: 'status', from: '20000', to: '10001' }],
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
})
|
|
511
|
+
},
|
|
512
|
+
}
|
|
513
|
+
const transitionsRoute: StubRoute = {
|
|
514
|
+
match: (u, init) =>
|
|
515
|
+
u.endsWith('/rest/api/3/issue/ENG-1/transitions') && (init?.method ?? 'GET') === 'GET',
|
|
516
|
+
handler: () =>
|
|
517
|
+
jsonResponse({
|
|
518
|
+
transitions: [{ id: '21', name: 'Done', to: { id: '10001', name: 'Done' } }],
|
|
519
|
+
}),
|
|
520
|
+
}
|
|
521
|
+
const postTransitionRoute: StubRoute = {
|
|
522
|
+
match: (u, init) =>
|
|
523
|
+
u.endsWith('/rest/api/3/issue/ENG-1/transitions') && (init?.method ?? 'GET') === 'POST',
|
|
524
|
+
handler: () => emptyResponse(204),
|
|
525
|
+
}
|
|
526
|
+
const { provider } = makeProvider(db, [
|
|
527
|
+
changelogRoute,
|
|
528
|
+
transitionsRoute,
|
|
529
|
+
postTransitionRoute,
|
|
530
|
+
...syncRoutes,
|
|
531
|
+
])
|
|
532
|
+
await provider.moveTask('ENG-1', 'Done')
|
|
533
|
+
|
|
534
|
+
expect(changelogCalls).toBe(1)
|
|
535
|
+
const activity = await provider.getActivity(50, 'jira:501')
|
|
536
|
+
expect(activity.length).toBeGreaterThan(0)
|
|
537
|
+
})
|
|
538
|
+
|
|
486
539
|
test('moveTask no-match failure: error message names target and lists available transitions', async () => {
|
|
487
540
|
seedCache(db, {
|
|
488
541
|
priorities: seedPriorities,
|
|
@@ -492,7 +545,9 @@ describe('JiraProvider mutations', () => {
|
|
|
492
545
|
issues: [{ id: '501', key: 'ENG-1', statusId: '20000' }],
|
|
493
546
|
projectKey: 'ENG',
|
|
494
547
|
})
|
|
495
|
-
// No standard sync routes
|
|
548
|
+
// No standard sync/hydrate routes needed: the move resolves against the
|
|
549
|
+
// seeded cache and throws at transition resolution (no transition reaches the
|
|
550
|
+
// target status) before any read-after-write hydration runs.
|
|
496
551
|
const transitionsRoute: StubRoute = {
|
|
497
552
|
match: (u, init) =>
|
|
498
553
|
u.endsWith('/rest/api/3/issue/ENG-1/transitions') && (init?.method ?? 'GET') === 'GET',
|
|
@@ -652,13 +707,13 @@ describe('JiraProvider mutations', () => {
|
|
|
652
707
|
|
|
653
708
|
test('createTask project field omitted is accepted', async () => {
|
|
654
709
|
fullSeed(db)
|
|
655
|
-
const createdIssues: Record<string, unknown>[] = []
|
|
656
710
|
const createdIssue = makeJiraIssueFixture({
|
|
657
711
|
id: '600',
|
|
658
712
|
key: 'ENG-10',
|
|
659
713
|
statusId: '20000',
|
|
660
714
|
summary: 'x',
|
|
661
715
|
})
|
|
716
|
+
const issues: Record<string, unknown>[] = [makeJiraIssueFixture(seedIssues[0]!)]
|
|
662
717
|
const syncRoutes = standardSyncRoutes({
|
|
663
718
|
projectKey: 'ENG',
|
|
664
719
|
columns: [
|
|
@@ -668,27 +723,12 @@ describe('JiraProvider mutations', () => {
|
|
|
668
723
|
users: seedUsers,
|
|
669
724
|
priorities: seedPriorities,
|
|
670
725
|
issueTypes: seedIssueTypes,
|
|
671
|
-
issues
|
|
726
|
+
issues,
|
|
672
727
|
})
|
|
673
|
-
const searchIdx = syncRoutes.findIndex((r) =>
|
|
674
|
-
r.match('https://example.atlassian.net/rest/api/3/search/jql'),
|
|
675
|
-
)
|
|
676
|
-
syncRoutes[searchIdx] = {
|
|
677
|
-
match: (u) => u.includes('/rest/api/3/search/jql'),
|
|
678
|
-
handler: () => {
|
|
679
|
-
const all = [makeJiraIssueFixture(seedIssues[0]!), ...createdIssues]
|
|
680
|
-
return jsonResponse({
|
|
681
|
-
startAt: 0,
|
|
682
|
-
maxResults: 100,
|
|
683
|
-
total: all.length,
|
|
684
|
-
issues: all,
|
|
685
|
-
})
|
|
686
|
-
},
|
|
687
|
-
}
|
|
688
728
|
const mutationRoute: StubRoute = {
|
|
689
729
|
match: (u, init) => u.endsWith('/rest/api/3/issue') && (init?.method ?? 'GET') === 'POST',
|
|
690
730
|
handler: () => {
|
|
691
|
-
|
|
731
|
+
issues.push(createdIssue)
|
|
692
732
|
return jsonResponse({ id: '600', key: 'ENG-10', self: 'x' })
|
|
693
733
|
},
|
|
694
734
|
}
|
|
@@ -706,13 +746,13 @@ describe('JiraProvider mutations', () => {
|
|
|
706
746
|
|
|
707
747
|
test('createTask project field matching configured projectKey is accepted', async () => {
|
|
708
748
|
fullSeed(db)
|
|
709
|
-
const createdIssues: Record<string, unknown>[] = []
|
|
710
749
|
const createdIssue = makeJiraIssueFixture({
|
|
711
750
|
id: '601',
|
|
712
751
|
key: 'ENG-11',
|
|
713
752
|
statusId: '20000',
|
|
714
753
|
summary: 'x',
|
|
715
754
|
})
|
|
755
|
+
const issues: Record<string, unknown>[] = [makeJiraIssueFixture(seedIssues[0]!)]
|
|
716
756
|
const syncRoutes = standardSyncRoutes({
|
|
717
757
|
projectKey: 'ENG',
|
|
718
758
|
columns: [
|
|
@@ -722,27 +762,12 @@ describe('JiraProvider mutations', () => {
|
|
|
722
762
|
users: seedUsers,
|
|
723
763
|
priorities: seedPriorities,
|
|
724
764
|
issueTypes: seedIssueTypes,
|
|
725
|
-
issues
|
|
765
|
+
issues,
|
|
726
766
|
})
|
|
727
|
-
const searchIdx = syncRoutes.findIndex((r) =>
|
|
728
|
-
r.match('https://example.atlassian.net/rest/api/3/search/jql'),
|
|
729
|
-
)
|
|
730
|
-
syncRoutes[searchIdx] = {
|
|
731
|
-
match: (u) => u.includes('/rest/api/3/search/jql'),
|
|
732
|
-
handler: () => {
|
|
733
|
-
const all = [makeJiraIssueFixture(seedIssues[0]!), ...createdIssues]
|
|
734
|
-
return jsonResponse({
|
|
735
|
-
startAt: 0,
|
|
736
|
-
maxResults: 100,
|
|
737
|
-
total: all.length,
|
|
738
|
-
issues: all,
|
|
739
|
-
})
|
|
740
|
-
},
|
|
741
|
-
}
|
|
742
767
|
const mutationRoute: StubRoute = {
|
|
743
768
|
match: (u, init) => u.endsWith('/rest/api/3/issue') && (init?.method ?? 'GET') === 'POST',
|
|
744
769
|
handler: () => {
|
|
745
|
-
|
|
770
|
+
issues.push(createdIssue)
|
|
746
771
|
return jsonResponse({ id: '601', key: 'ENG-11', self: 'x' })
|
|
747
772
|
},
|
|
748
773
|
}
|
|
@@ -331,6 +331,85 @@ describe('JiraProvider read path', () => {
|
|
|
331
331
|
)
|
|
332
332
|
})
|
|
333
333
|
|
|
334
|
+
test('sync follows the nextPageToken cursor across pages when total is omitted', async () => {
|
|
335
|
+
// /rest/api/3/search/jql omits `total` and paginates by an opaque
|
|
336
|
+
// nextPageToken. The previous offset/total loop stopped after page 1 once
|
|
337
|
+
// `total` came back undefined, so every issue beyond the first 100 (by
|
|
338
|
+
// updated ASC) — including newly-created tickets — was silently dropped.
|
|
339
|
+
const requestedTokens: Array<string | null> = []
|
|
340
|
+
const searchHandler: StubHandler = (url) => {
|
|
341
|
+
const token = new URL(url).searchParams.get('nextPageToken')
|
|
342
|
+
requestedTokens.push(token)
|
|
343
|
+
if (token === null) {
|
|
344
|
+
return jsonResponse({
|
|
345
|
+
nextPageToken: 'page-2',
|
|
346
|
+
isLast: false,
|
|
347
|
+
issues: [makeIssue({ id: '1', key: 'ENG-1', statusId: '10001' })],
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
if (token === 'page-2') {
|
|
351
|
+
return jsonResponse({
|
|
352
|
+
isLast: true,
|
|
353
|
+
issues: [makeIssue({ id: '2', key: 'ENG-2', statusId: '10002' })],
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
return jsonResponse({ isLast: true, issues: [] })
|
|
357
|
+
}
|
|
358
|
+
const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
|
|
359
|
+
await provider.getBoard()
|
|
360
|
+
|
|
361
|
+
// Both pages land in the cache, not just page 1.
|
|
362
|
+
expect(
|
|
363
|
+
getCachedTasks(db)
|
|
364
|
+
.map((task) => task.externalRef)
|
|
365
|
+
.sort(),
|
|
366
|
+
).toEqual(['ENG-1', 'ENG-2'])
|
|
367
|
+
// Page 1 sends no cursor; page 2 follows the returned token; then it stops.
|
|
368
|
+
expect(requestedTokens).toEqual([null, 'page-2'])
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test('sync does not drop a page when an intermediate page is empty but carries a token', async () => {
|
|
372
|
+
// A non-last page may legitimately return zero issues alongside a cursor;
|
|
373
|
+
// the loop must follow the token rather than treat the empty page as the end.
|
|
374
|
+
const searchHandler: StubHandler = (url) => {
|
|
375
|
+
const token = new URL(url).searchParams.get('nextPageToken')
|
|
376
|
+
if (token === null) {
|
|
377
|
+
return jsonResponse({ nextPageToken: 'page-2', isLast: false, issues: [] })
|
|
378
|
+
}
|
|
379
|
+
return jsonResponse({
|
|
380
|
+
isLast: true,
|
|
381
|
+
issues: [makeIssue({ id: '2', key: 'ENG-2', statusId: '10002' })],
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
|
|
385
|
+
await provider.getBoard()
|
|
386
|
+
expect(getCachedTasks(db).map((task) => task.externalRef)).toEqual(['ENG-2'])
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
test('sync terminates instead of spinning when the server repeats a cursor token', async () => {
|
|
390
|
+
// A misbehaving server that keeps handing back the same non-last token would
|
|
391
|
+
// otherwise loop forever and hang the poll cycle; the seen-token guard stops
|
|
392
|
+
// after the cursor fails to advance, without dropping the page it did return.
|
|
393
|
+
let call = 0
|
|
394
|
+
const searchHandler: StubHandler = () => {
|
|
395
|
+
call += 1
|
|
396
|
+
return jsonResponse({
|
|
397
|
+
nextPageToken: 'stuck',
|
|
398
|
+
isLast: false,
|
|
399
|
+
issues: [makeIssue({ id: String(call), key: `ENG-${call}`, statusId: '10001' })],
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
|
|
403
|
+
await provider.getBoard()
|
|
404
|
+
// Two fetches: the first records token 'stuck', the second sees it repeat and breaks.
|
|
405
|
+
expect(call).toBe(2)
|
|
406
|
+
expect(
|
|
407
|
+
getCachedTasks(db)
|
|
408
|
+
.map((task) => task.externalRef)
|
|
409
|
+
.sort(),
|
|
410
|
+
).toEqual(['ENG-1', 'ENG-2'])
|
|
411
|
+
})
|
|
412
|
+
|
|
334
413
|
test('periodic full reconciliation prunes cached issues missing upstream', async () => {
|
|
335
414
|
const capturedJql: string[] = []
|
|
336
415
|
let searchCalls = 0
|
|
@@ -400,6 +479,91 @@ describe('JiraProvider read path', () => {
|
|
|
400
479
|
expect(getCachedTasks(db).map((task) => task.externalRef)).toEqual(['ENG-1'])
|
|
401
480
|
})
|
|
402
481
|
|
|
482
|
+
test('full reconcile with a stalled cursor does not prune issues from the unfetched pages', async () => {
|
|
483
|
+
// If the cursor stalls (a repeated non-last token) mid-scan, seenIssueIds is
|
|
484
|
+
// only partial. Pruning against it would delete issues that exist upstream on
|
|
485
|
+
// pages we never fetched, so an incomplete scan must not prune.
|
|
486
|
+
let stalled = false
|
|
487
|
+
const searchHandler: StubHandler = () => {
|
|
488
|
+
if (!stalled) {
|
|
489
|
+
// First full reconcile: a clean single terminal page with both issues.
|
|
490
|
+
return jsonResponse({
|
|
491
|
+
isLast: true,
|
|
492
|
+
issues: [
|
|
493
|
+
makeIssue({ id: '1', key: 'ENG-1', statusId: '10001' }),
|
|
494
|
+
makeIssue({ id: '2', key: 'ENG-2', statusId: '10001' }),
|
|
495
|
+
],
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
// Later full reconcile: the cursor stalls after returning only ENG-1, so
|
|
499
|
+
// ENG-2's page is never reached.
|
|
500
|
+
return jsonResponse({
|
|
501
|
+
nextPageToken: 'stuck',
|
|
502
|
+
isLast: false,
|
|
503
|
+
issues: [makeIssue({ id: '1', key: 'ENG-1', statusId: '10001' })],
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
|
|
507
|
+
const baseNow = originalDateNow()
|
|
508
|
+
|
|
509
|
+
Date.now = () => baseNow
|
|
510
|
+
await provider.getBoard()
|
|
511
|
+
expect(
|
|
512
|
+
getCachedTasks(db)
|
|
513
|
+
.map((task) => task.externalRef)
|
|
514
|
+
.sort(),
|
|
515
|
+
).toEqual(['ENG-1', 'ENG-2'])
|
|
516
|
+
|
|
517
|
+
// Next full reconcile (past the interval) hits the stalled cursor.
|
|
518
|
+
stalled = true
|
|
519
|
+
Date.now = () => baseNow + 5 * 60_000 + 31_000
|
|
520
|
+
await provider.getBoard()
|
|
521
|
+
|
|
522
|
+
// ENG-2 must survive: the incomplete scan is not authoritative for pruning.
|
|
523
|
+
expect(
|
|
524
|
+
getCachedTasks(db)
|
|
525
|
+
.map((task) => task.externalRef)
|
|
526
|
+
.sort(),
|
|
527
|
+
).toEqual(['ENG-1', 'ENG-2'])
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
test('full reconcile does not prune when the server reports isLast=false but omits a cursor', async () => {
|
|
531
|
+
// A server can contradict itself: claim more pages (isLast=false) while giving
|
|
532
|
+
// no nextPageToken. That must be treated as an incomplete scan, not a complete
|
|
533
|
+
// one, so a partial result never prunes issues that still exist upstream.
|
|
534
|
+
let degraded = false
|
|
535
|
+
const searchHandler: StubHandler = () => {
|
|
536
|
+
if (!degraded) {
|
|
537
|
+
return jsonResponse({
|
|
538
|
+
isLast: true,
|
|
539
|
+
issues: [
|
|
540
|
+
makeIssue({ id: '1', key: 'ENG-1', statusId: '10001' }),
|
|
541
|
+
makeIssue({ id: '2', key: 'ENG-2', statusId: '10001' }),
|
|
542
|
+
],
|
|
543
|
+
})
|
|
544
|
+
}
|
|
545
|
+
return jsonResponse({
|
|
546
|
+
isLast: false,
|
|
547
|
+
issues: [makeIssue({ id: '1', key: 'ENG-1', statusId: '10001' })],
|
|
548
|
+
})
|
|
549
|
+
}
|
|
550
|
+
const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
|
|
551
|
+
const baseNow = originalDateNow()
|
|
552
|
+
|
|
553
|
+
Date.now = () => baseNow
|
|
554
|
+
await provider.getBoard()
|
|
555
|
+
|
|
556
|
+
degraded = true
|
|
557
|
+
Date.now = () => baseNow + 5 * 60_000 + 31_000
|
|
558
|
+
await provider.getBoard()
|
|
559
|
+
|
|
560
|
+
expect(
|
|
561
|
+
getCachedTasks(db)
|
|
562
|
+
.map((task) => task.externalRef)
|
|
563
|
+
.sort(),
|
|
564
|
+
).toEqual(['ENG-1', 'ENG-2'])
|
|
565
|
+
})
|
|
566
|
+
|
|
403
567
|
test('listTasks filters by columnId with many-to-one mapping', async () => {
|
|
404
568
|
const { provider } = makeProvider(standardRoutes({}))
|
|
405
569
|
// Sync first (populates statuses-based columns)
|
|
@@ -628,7 +792,7 @@ describe('JiraProvider read path', () => {
|
|
|
628
792
|
})
|
|
629
793
|
|
|
630
794
|
test('sync paginates listIssues across multiple pages', async () => {
|
|
631
|
-
const searchCalls: Array<{
|
|
795
|
+
const searchCalls: Array<{ token: string | null }> = []
|
|
632
796
|
const page1Issues = Array.from({ length: 100 }, (_, i) =>
|
|
633
797
|
makeIssue({
|
|
634
798
|
id: String(i + 1),
|
|
@@ -645,38 +809,31 @@ describe('JiraProvider read path', () => {
|
|
|
645
809
|
updated: '2026-01-02T00:00:00Z',
|
|
646
810
|
}),
|
|
647
811
|
)
|
|
812
|
+
// /rest/api/3/search/jql paginates by an opaque nextPageToken (no `total`);
|
|
813
|
+
// the loop follows the cursor until `isLast`.
|
|
648
814
|
const searchHandler: StubHandler = (url) => {
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
if (startAt === 0) {
|
|
815
|
+
const token = new URL(url).searchParams.get('nextPageToken')
|
|
816
|
+
searchCalls.push({ token })
|
|
817
|
+
if (token === null) {
|
|
653
818
|
return jsonResponse({
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
total: 150,
|
|
819
|
+
nextPageToken: 'page-2',
|
|
820
|
+
isLast: false,
|
|
657
821
|
issues: page1Issues,
|
|
658
822
|
})
|
|
659
823
|
}
|
|
660
|
-
if (
|
|
824
|
+
if (token === 'page-2') {
|
|
661
825
|
return jsonResponse({
|
|
662
|
-
|
|
663
|
-
maxResults: 100,
|
|
664
|
-
total: 150,
|
|
826
|
+
isLast: true,
|
|
665
827
|
issues: page2Issues,
|
|
666
828
|
})
|
|
667
829
|
}
|
|
668
|
-
return jsonResponse({
|
|
669
|
-
startAt,
|
|
670
|
-
maxResults: 100,
|
|
671
|
-
total: 150,
|
|
672
|
-
issues: [],
|
|
673
|
-
})
|
|
830
|
+
return jsonResponse({ isLast: true, issues: [] })
|
|
674
831
|
}
|
|
675
832
|
const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
|
|
676
833
|
await provider.getBoard()
|
|
677
834
|
expect(searchCalls).toHaveLength(2)
|
|
678
|
-
expect(searchCalls[0]!.
|
|
679
|
-
expect(searchCalls[1]!.
|
|
835
|
+
expect(searchCalls[0]!.token).toBeNull()
|
|
836
|
+
expect(searchCalls[1]!.token).toBe('page-2')
|
|
680
837
|
expect(getCachedTasks(db)).toHaveLength(150)
|
|
681
838
|
})
|
|
682
839
|
|