@andypai/agent-kanban 0.5.0 → 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__/board-utils.test.ts +37 -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 +206 -20
- package/src/__tests__/postgres-jira-provider.test.ts +214 -1
- package/src/providers/jira-cache.ts +64 -1
- package/src/providers/jira-client.ts +70 -4
- package/src/providers/jira.ts +119 -52
- package/src/providers/postgres-jira.ts +193 -79
- package/ui/dist/assets/index-CFhtfqCn.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-qNVJ6clH.js +0 -40
|
@@ -67,7 +67,7 @@ function makeIssue(
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function standardRoutes(): StubRoute[] {
|
|
70
|
+
function standardRoutes(opts: { boardCfg?: unknown } = {}): StubRoute[] {
|
|
71
71
|
const issues = [makeIssue()]
|
|
72
72
|
const comments: Record<
|
|
73
73
|
string,
|
|
@@ -100,6 +100,19 @@ function standardRoutes(): StubRoute[] {
|
|
|
100
100
|
match: (url) => url.includes('/rest/api/3/project/ENG'),
|
|
101
101
|
handler: () => jsonResponse(projectFixture),
|
|
102
102
|
},
|
|
103
|
+
{
|
|
104
|
+
match: (url) => url.includes('/rest/agile/1.0/board/'),
|
|
105
|
+
handler: () =>
|
|
106
|
+
jsonResponse(
|
|
107
|
+
opts.boardCfg ?? {
|
|
108
|
+
id: 1006,
|
|
109
|
+
name: 'ENG Board',
|
|
110
|
+
columnConfig: {
|
|
111
|
+
columns: [{ name: 'Done', statuses: [{ id: '10' }, { id: '20' }] }],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
),
|
|
115
|
+
},
|
|
103
116
|
{
|
|
104
117
|
match: (url) => url.includes('/rest/api/3/user/assignable/search'),
|
|
105
118
|
handler: () => jsonResponse([{ accountId: 'a1', displayName: 'Alice', active: true }]),
|
|
@@ -132,6 +145,16 @@ function standardRoutes(): StubRoute[] {
|
|
|
132
145
|
)
|
|
133
146
|
},
|
|
134
147
|
},
|
|
148
|
+
{
|
|
149
|
+
// GET /rest/api/3/issue/{key} backs hydrateIssueByKey's read-after-write.
|
|
150
|
+
match: (url) => /\/rest\/api\/3\/issue\/ENG-\d+$/.test(new URL(url).pathname),
|
|
151
|
+
handler: (url) => {
|
|
152
|
+
const issueKey = new URL(url).pathname.match(/\/issue\/(ENG-\d+)$/)![1]!
|
|
153
|
+
const issue = issues.find((candidate) => String(candidate.key) === issueKey)
|
|
154
|
+
if (!issue) return jsonResponse({ errorMessages: ['missing'] }, 404)
|
|
155
|
+
return jsonResponse(issue)
|
|
156
|
+
},
|
|
157
|
+
},
|
|
135
158
|
{
|
|
136
159
|
match: (url) => /\/rest\/api\/3\/issue\/ENG-\d+\/comment$/.test(new URL(url).pathname),
|
|
137
160
|
handler: async (url, init) => {
|
|
@@ -334,6 +357,51 @@ describe('postgres jira provider', () => {
|
|
|
334
357
|
expect(body.fields?.labels).toEqual(['garage-smoke', 'garage-owner-local'])
|
|
335
358
|
})
|
|
336
359
|
|
|
360
|
+
pgTest('keeps duplicate Jira board column names as distinct cached columns', async () => {
|
|
361
|
+
expect(sql).not.toBeNull()
|
|
362
|
+
if (!sql) throw new Error('expected postgres test connection')
|
|
363
|
+
const stub = jiraFetchStub(
|
|
364
|
+
standardRoutes({
|
|
365
|
+
boardCfg: {
|
|
366
|
+
id: 1006,
|
|
367
|
+
name: 'ENG Board',
|
|
368
|
+
columnConfig: {
|
|
369
|
+
columns: [
|
|
370
|
+
{ name: 'Backlog', statuses: [{ id: '10' }] },
|
|
371
|
+
{ name: 'Backlog', statuses: [{ id: '20' }] },
|
|
372
|
+
],
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
}),
|
|
376
|
+
)
|
|
377
|
+
globalThis.fetch = stub.fn
|
|
378
|
+
const provider = new PostgresJiraProvider(sql, {
|
|
379
|
+
baseUrl: 'https://example.atlassian.net',
|
|
380
|
+
email: 'user@example.com',
|
|
381
|
+
apiToken: 'token',
|
|
382
|
+
projectKey: 'ENG',
|
|
383
|
+
boardId: 1006,
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
const board = await provider.getBoard()
|
|
387
|
+
|
|
388
|
+
expect(board.columns.map((column) => column.id)).toEqual([
|
|
389
|
+
'board:1006:Backlog',
|
|
390
|
+
'board:1006:Backlog:1',
|
|
391
|
+
])
|
|
392
|
+
expect(board.columns.map((column) => column.tasks.map((task) => task.externalRef))).toEqual([
|
|
393
|
+
['ENG-1'],
|
|
394
|
+
[],
|
|
395
|
+
])
|
|
396
|
+
await expect(provider.listTasks({ column: 'Backlog' })).rejects.toMatchObject({
|
|
397
|
+
code: 'COLUMN_NOT_FOUND',
|
|
398
|
+
message: expect.stringContaining('ambiguous'),
|
|
399
|
+
})
|
|
400
|
+
expect(
|
|
401
|
+
(await provider.listTasks({ column: 'board:1006:Backlog' })).map((task) => task.id),
|
|
402
|
+
).toEqual(['jira:10001'])
|
|
403
|
+
})
|
|
404
|
+
|
|
337
405
|
pgTest('moves Jira tasks through Postgres storage', async () => {
|
|
338
406
|
const moved = expectOk<Task>(await run(['task', 'move', 'ENG-1', 'Done']))
|
|
339
407
|
|
|
@@ -343,4 +411,149 @@ describe('postgres jira provider', () => {
|
|
|
343
411
|
column_id: '20',
|
|
344
412
|
})
|
|
345
413
|
})
|
|
414
|
+
|
|
415
|
+
pgTest(
|
|
416
|
+
'delta sync adds new catalog rows but only a full reconcile prunes obsolete ones',
|
|
417
|
+
async () => {
|
|
418
|
+
if (!sql) throw new Error('expected postgres test connection')
|
|
419
|
+
const prioritiesState = {
|
|
420
|
+
list: [
|
|
421
|
+
{ id: '2', name: 'High' },
|
|
422
|
+
{ id: '3', name: 'Medium' },
|
|
423
|
+
] as Array<{ id: string; name: string }>,
|
|
424
|
+
}
|
|
425
|
+
const routes = standardRoutes()
|
|
426
|
+
const pIdx = routes.findIndex((route) =>
|
|
427
|
+
route.match('https://example.atlassian.net/rest/api/3/priority'),
|
|
428
|
+
)
|
|
429
|
+
routes[pIdx] = {
|
|
430
|
+
match: (url) => url.includes('/rest/api/3/priority'),
|
|
431
|
+
handler: () => jsonResponse(prioritiesState.list),
|
|
432
|
+
}
|
|
433
|
+
globalThis.fetch = jiraFetchStub(routes).fn
|
|
434
|
+
// pollingSyncIntervalMs: 0 lets every getBoard run; the first is a full
|
|
435
|
+
// reconcile (no lastFullSyncAt yet), later ones are delta syncs because the
|
|
436
|
+
// full-reconcile interval has not elapsed.
|
|
437
|
+
const provider = new PostgresJiraProvider(sql, {
|
|
438
|
+
baseUrl: 'https://example.atlassian.net',
|
|
439
|
+
email: 'user@example.com',
|
|
440
|
+
apiToken: 'token',
|
|
441
|
+
projectKey: 'ENG',
|
|
442
|
+
boardId: 1006,
|
|
443
|
+
pollingSyncIntervalMs: 0,
|
|
444
|
+
})
|
|
445
|
+
const priorityNames = async () =>
|
|
446
|
+
(await sql!<{ name: string }[]>`SELECT name FROM jira_priorities ORDER BY name`).map(
|
|
447
|
+
(row) => row.name,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
await provider.getBoard()
|
|
451
|
+
expect(await priorityNames()).toEqual(['High', 'Medium'])
|
|
452
|
+
|
|
453
|
+
// Drop Medium, add Low upstream. A delta sync must pick up the new row
|
|
454
|
+
// (Low) via the every-sync UPSERT but must NOT prune the now-obsolete row
|
|
455
|
+
// (Medium): pruning on a possibly-stale delta snapshot could delete a row
|
|
456
|
+
// another replica just added. Pruning is confined to the full reconcile.
|
|
457
|
+
prioritiesState.list = [
|
|
458
|
+
{ id: '2', name: 'High' },
|
|
459
|
+
{ id: '4', name: 'Low' },
|
|
460
|
+
]
|
|
461
|
+
await provider.getBoard()
|
|
462
|
+
expect(await priorityNames()).toEqual(['High', 'Low', 'Medium'])
|
|
463
|
+
|
|
464
|
+
// Force the next sync to be a full reconcile; only then is the obsolete
|
|
465
|
+
// Medium pruned.
|
|
466
|
+
await sql`DELETE FROM jira_sync_meta WHERE key = 'lastFullSyncAt'`
|
|
467
|
+
await provider.getBoard()
|
|
468
|
+
expect(await priorityNames()).toEqual(['High', 'Low'])
|
|
469
|
+
},
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
pgTest('concurrent catalog refreshes do not collide on a primary key', async () => {
|
|
473
|
+
if (!sql) throw new Error('expected postgres test connection')
|
|
474
|
+
globalThis.fetch = jiraFetchStub(standardRoutes()).fn
|
|
475
|
+
const sql2 = postgres(databaseUrl as string, { max: 1, onnotice: () => {} })
|
|
476
|
+
try {
|
|
477
|
+
const config = {
|
|
478
|
+
baseUrl: 'https://example.atlassian.net',
|
|
479
|
+
email: 'user@example.com',
|
|
480
|
+
apiToken: 'token',
|
|
481
|
+
projectKey: 'ENG',
|
|
482
|
+
boardId: 1006,
|
|
483
|
+
pollingSyncIntervalMs: 0,
|
|
484
|
+
}
|
|
485
|
+
// Initialize schema sequentially first; concurrent CREATE TABLE IF NOT
|
|
486
|
+
// EXISTS races at the DDL level (a pre-existing Postgres quirk unrelated to
|
|
487
|
+
// catalog refresh). The race under test is the catalog DML below.
|
|
488
|
+
const p1 = new PostgresJiraProvider(sql, config)
|
|
489
|
+
await p1.initialize()
|
|
490
|
+
const p2 = new PostgresJiraProvider(sql2, config)
|
|
491
|
+
await p2.initialize()
|
|
492
|
+
// Two replicas on separate connections refreshing the same shared cache
|
|
493
|
+
// concurrently must not trip a primary-key collision. Both first syncs are
|
|
494
|
+
// full reconciles (no lastFullSyncAt yet), so this also exercises the
|
|
495
|
+
// obsolete-row DELETE running concurrently: UPSERT + conditional DELETE is
|
|
496
|
+
// idempotent and order-independent.
|
|
497
|
+
await Promise.all([p1.getBoard(), p2.getBoard()])
|
|
498
|
+
const rows = await sql<
|
|
499
|
+
{ count: number }[]
|
|
500
|
+
>`SELECT COUNT(*)::int AS count FROM jira_priorities`
|
|
501
|
+
expect(rows[0]?.count ?? 0).toBeGreaterThan(0)
|
|
502
|
+
} finally {
|
|
503
|
+
await sql2.end({ timeout: 1 })
|
|
504
|
+
}
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
pgTest('full reconcile with a stalled cursor does not prune unfetched-page issues', async () => {
|
|
508
|
+
if (!sql) throw new Error('expected postgres test connection')
|
|
509
|
+
// Mirror of the SQLite prune-safety coverage: if the cursor stalls (a
|
|
510
|
+
// repeated non-last token) mid-scan, seenIssueIds is only partial, so the
|
|
511
|
+
// incomplete scan must NOT prune issues that exist upstream on pages we never
|
|
512
|
+
// fetched.
|
|
513
|
+
let stalled = false
|
|
514
|
+
const routes = standardRoutes()
|
|
515
|
+
const sIdx = routes.findIndex((route) => route.match('https://x/rest/api/3/search/jql'))
|
|
516
|
+
routes[sIdx] = {
|
|
517
|
+
match: (url) => url.includes('/rest/api/3/search/jql'),
|
|
518
|
+
handler: () =>
|
|
519
|
+
stalled
|
|
520
|
+
? // Later full reconcile: the cursor stalls after returning only ENG-1,
|
|
521
|
+
// so ENG-2's page is never reached. isLast=false + a repeated token =>
|
|
522
|
+
// incomplete.
|
|
523
|
+
jsonResponse({
|
|
524
|
+
nextPageToken: 'stuck',
|
|
525
|
+
isLast: false,
|
|
526
|
+
issues: [makeIssue({ id: '1', key: 'ENG-1' })],
|
|
527
|
+
})
|
|
528
|
+
: // First full reconcile: a clean single terminal page with both issues.
|
|
529
|
+
jsonResponse({
|
|
530
|
+
isLast: true,
|
|
531
|
+
issues: [makeIssue({ id: '1', key: 'ENG-1' }), makeIssue({ id: '2', key: 'ENG-2' })],
|
|
532
|
+
}),
|
|
533
|
+
}
|
|
534
|
+
globalThis.fetch = jiraFetchStub(routes).fn
|
|
535
|
+
const provider = new PostgresJiraProvider(sql, {
|
|
536
|
+
baseUrl: 'https://example.atlassian.net',
|
|
537
|
+
email: 'user@example.com',
|
|
538
|
+
apiToken: 'token',
|
|
539
|
+
projectKey: 'ENG',
|
|
540
|
+
boardId: 1006,
|
|
541
|
+
pollingSyncIntervalMs: 0,
|
|
542
|
+
})
|
|
543
|
+
const issueKeys = async () =>
|
|
544
|
+
(await sql!<{ key: string }[]>`SELECT key FROM jira_issues ORDER BY key`).map(
|
|
545
|
+
(row) => row.key,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
await provider.getBoard()
|
|
549
|
+
expect(await issueKeys()).toEqual(['ENG-1', 'ENG-2'])
|
|
550
|
+
|
|
551
|
+
// Force the next sync to be a full reconcile and hit the stalled cursor.
|
|
552
|
+
stalled = true
|
|
553
|
+
await sql`DELETE FROM jira_sync_meta WHERE key = 'lastFullSyncAt'`
|
|
554
|
+
await provider.getBoard()
|
|
555
|
+
|
|
556
|
+
// ENG-2 must survive: the incomplete scan is not authoritative for pruning.
|
|
557
|
+
expect(await issueKeys()).toEqual(['ENG-1', 'ENG-2'])
|
|
558
|
+
})
|
|
346
559
|
})
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import { ErrorCode, KanbanError } from '../errors'
|
|
2
3
|
import type { BoardView, ProviderTeamInfo, Task } from '../types'
|
|
3
4
|
|
|
4
5
|
// Column ids are prefixed to avoid collisions across sources:
|
|
5
|
-
// - board-sourced columns: 'board:<boardId>:<columnName>'
|
|
6
|
+
// - board-sourced columns: 'board:<boardId>:<columnName>' with an index suffix
|
|
7
|
+
// only when Jira returns duplicate board column names
|
|
6
8
|
// - status-fallback columns: 'status:<statusId>'
|
|
7
9
|
// The provider (T04) picks ONE source per sync, so mixed-source boards
|
|
8
10
|
// do not occur in practice.
|
|
@@ -30,6 +32,67 @@ export interface JiraCacheConfig {
|
|
|
30
32
|
issueTypes: Array<{ id: string; name: string }>
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
export interface JiraBoardColumnInput {
|
|
36
|
+
name: string
|
|
37
|
+
statuses: Array<{ id: string }>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function jiraBoardColumnRows(
|
|
41
|
+
boardId: number,
|
|
42
|
+
columns: JiraBoardColumnInput[],
|
|
43
|
+
): Array<{
|
|
44
|
+
id: string
|
|
45
|
+
name: string
|
|
46
|
+
position: number
|
|
47
|
+
statusIds: string[]
|
|
48
|
+
source: 'board'
|
|
49
|
+
}> {
|
|
50
|
+
const seen = new Set<string>()
|
|
51
|
+
return columns.map((column, index) => {
|
|
52
|
+
const baseId = `board:${boardId}:${column.name}`
|
|
53
|
+
let id = baseId
|
|
54
|
+
if (seen.has(id)) {
|
|
55
|
+
id = `${baseId}:${index}`
|
|
56
|
+
let suffix = 2
|
|
57
|
+
while (seen.has(id)) {
|
|
58
|
+
id = `${baseId}:${index}:${suffix}`
|
|
59
|
+
suffix += 1
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
seen.add(id)
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
name: column.name,
|
|
66
|
+
position: index,
|
|
67
|
+
statusIds: column.statuses.map((status) => status.id),
|
|
68
|
+
source: 'board',
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Resolves a user-supplied column reference to a cached column id, trying
|
|
74
|
+
// (1) exact id, (2) case-insensitive name, (3) raw status id containment.
|
|
75
|
+
// A name that matches multiple columns (possible when Jira returns duplicate
|
|
76
|
+
// board column names) is rejected as ambiguous so the caller picks an id.
|
|
77
|
+
export function resolveJiraColumnId(columns: JiraColumnRow[], input: string): string {
|
|
78
|
+
const byId = columns.find((column) => column.id === input)
|
|
79
|
+
if (byId) return byId.id
|
|
80
|
+
const lower = input.toLowerCase()
|
|
81
|
+
const byName = columns.filter((column) => column.name.toLowerCase() === lower)
|
|
82
|
+
if (byName.length === 1) return byName[0]!.id
|
|
83
|
+
if (byName.length > 1) {
|
|
84
|
+
throw new KanbanError(
|
|
85
|
+
ErrorCode.COLUMN_NOT_FOUND,
|
|
86
|
+
`Jira column name '${input}' is ambiguous; use one of these column ids: ${byName
|
|
87
|
+
.map((column) => column.id)
|
|
88
|
+
.join(', ')}`,
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
const byStatus = columns.find((column) => decodeColumnStatusIds(column).includes(input))
|
|
92
|
+
if (byStatus) return byStatus.id
|
|
93
|
+
throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No Jira column matching '${input}'`)
|
|
94
|
+
}
|
|
95
|
+
|
|
33
96
|
interface JiraIssueRow {
|
|
34
97
|
id: string
|
|
35
98
|
key: string
|
|
@@ -45,12 +45,73 @@ export interface JiraIssue {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export interface JiraSearchPage {
|
|
48
|
-
startAt
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
// The legacy /rest/api/3/search endpoint returned startAt/maxResults/total.
|
|
49
|
+
// The current /rest/api/3/search/jql endpoint omits `total` and paginates by
|
|
50
|
+
// an opaque `nextPageToken` (with `isLast`), so these are optional now.
|
|
51
|
+
startAt?: number
|
|
52
|
+
maxResults?: number
|
|
53
|
+
total?: number
|
|
54
|
+
nextPageToken?: string
|
|
55
|
+
isLast?: boolean
|
|
51
56
|
issues: JiraIssue[]
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
export interface JiraPaginationDecision {
|
|
60
|
+
// When set, advance the scan to this cursor. When absent, the scan is over and
|
|
61
|
+
// `complete` says whether it ended definitively (safe to prune against the
|
|
62
|
+
// accumulated issue set) or was cut short (must NOT prune — issues on unfetched
|
|
63
|
+
// pages would be wrongly deleted).
|
|
64
|
+
nextToken?: string
|
|
65
|
+
complete: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Decide how a `/rest/api/3/search/jql` cursor scan should proceed after a page.
|
|
69
|
+
// The endpoint signals continuation with `isLast`/`nextPageToken`, but real and
|
|
70
|
+
// degraded servers vary, so termination must be conservative: a scan is reported
|
|
71
|
+
// `complete` (safe to prune against the accumulated issue set) ONLY when the
|
|
72
|
+
// server proves the end, never from a page-size guess.
|
|
73
|
+
// - usable cursor (fresh `nextPageToken`, isLast !== true) → advance.
|
|
74
|
+
// - isLast === true → definitive end (complete).
|
|
75
|
+
// - isLast === false → more pages exist; if the
|
|
76
|
+
// cursor is missing/stale we
|
|
77
|
+
// cannot fetch them, so the
|
|
78
|
+
// scan is incomplete.
|
|
79
|
+
// - isLast absent, `total` present → legacy total/startAt proof:
|
|
80
|
+
// complete once startAt+count
|
|
81
|
+
// reaches `total`.
|
|
82
|
+
// - isLast absent, no `total`, no usable cursor → NO completeness proof. A
|
|
83
|
+
// short/empty page must NOT be
|
|
84
|
+
// read as "the end": a degraded
|
|
85
|
+
// `{ issues: [] }` would then
|
|
86
|
+
// prune the entire cache on a
|
|
87
|
+
// full reconcile. Treat as
|
|
88
|
+
// incomplete and retry instead.
|
|
89
|
+
// A cursor already in `seenPageTokens` counts as not usable (a stalled cursor
|
|
90
|
+
// that would otherwise loop forever).
|
|
91
|
+
export function decideJiraPagination(
|
|
92
|
+
page: Pick<JiraSearchPage, 'isLast' | 'nextPageToken' | 'issues' | 'total' | 'startAt'>,
|
|
93
|
+
seenPageTokens: ReadonlySet<string>,
|
|
94
|
+
): JiraPaginationDecision {
|
|
95
|
+
const token = page.nextPageToken
|
|
96
|
+
const canAdvance = !!token && page.isLast !== true && !seenPageTokens.has(token)
|
|
97
|
+
if (canAdvance) return { nextToken: token, complete: false }
|
|
98
|
+
if (page.isLast === true) return { complete: true }
|
|
99
|
+
if (page.isLast === false) return { complete: false }
|
|
100
|
+
// isLast absent below. Prefer the legacy total/startAt signal when a
|
|
101
|
+
// (non-standard) server supplies it: complete once we have fetched everything
|
|
102
|
+
// `total` promises. The live /search/jql endpoint omits `total` and ignores
|
|
103
|
+
// `startAt`, so the loop never advances by offset — a `total` promising more
|
|
104
|
+
// than this page is reported incomplete rather than fetched.
|
|
105
|
+
if (typeof page.total === 'number') {
|
|
106
|
+
const issueCount = page.issues?.length ?? 0
|
|
107
|
+
return { complete: (page.startAt ?? 0) + issueCount >= page.total }
|
|
108
|
+
}
|
|
109
|
+
// No completeness signal at all (no isLast, no total, no usable cursor). We
|
|
110
|
+
// cannot prove the scan reached the end, so never guess `complete` from page
|
|
111
|
+
// size — that would prune the whole cache on a degraded short/empty response.
|
|
112
|
+
return { complete: false }
|
|
113
|
+
}
|
|
114
|
+
|
|
54
115
|
export interface JiraCreatePayload {
|
|
55
116
|
fields: Record<string, unknown>
|
|
56
117
|
}
|
|
@@ -254,12 +315,17 @@ export class JiraClient {
|
|
|
254
315
|
startAt: number
|
|
255
316
|
maxResults: number
|
|
256
317
|
fields?: string[]
|
|
318
|
+
nextPageToken?: string
|
|
257
319
|
}): Promise<JiraSearchPage> {
|
|
258
320
|
const query: QueryParams = {
|
|
259
321
|
jql: params.jql,
|
|
260
|
-
startAt: params.startAt,
|
|
261
322
|
maxResults: params.maxResults,
|
|
262
323
|
}
|
|
324
|
+
// /rest/api/3/search/jql ignores startAt and paginates by nextPageToken.
|
|
325
|
+
// Send the cursor when we have one; only send startAt on the first page for
|
|
326
|
+
// back-compat with the legacy endpoint.
|
|
327
|
+
if (params.nextPageToken) query.nextPageToken = params.nextPageToken
|
|
328
|
+
else query.startAt = params.startAt
|
|
263
329
|
if (params.fields && params.fields.length > 0) {
|
|
264
330
|
query.fields = params.fields.join(',')
|
|
265
331
|
}
|
package/src/providers/jira.ts
CHANGED
|
@@ -20,7 +20,13 @@ import {
|
|
|
20
20
|
import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
|
|
21
21
|
import { JIRA_CAPABILITIES } from './capabilities'
|
|
22
22
|
import { providerUpstreamError, unsupportedOperation } from './errors'
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
JiraClient,
|
|
25
|
+
decideJiraPagination,
|
|
26
|
+
normalizeJiraLabels,
|
|
27
|
+
type JiraComment,
|
|
28
|
+
type JiraIssue,
|
|
29
|
+
} from './jira-client'
|
|
24
30
|
import {
|
|
25
31
|
adjustJiraIssueCommentCount,
|
|
26
32
|
decodeColumnStatusIds,
|
|
@@ -32,6 +38,8 @@ import {
|
|
|
32
38
|
getCachedTask,
|
|
33
39
|
getCachedTasks,
|
|
34
40
|
initJiraCacheSchema,
|
|
41
|
+
jiraBoardColumnRows,
|
|
42
|
+
resolveJiraColumnId,
|
|
35
43
|
loadJiraSyncMeta,
|
|
36
44
|
loadTeamInfo,
|
|
37
45
|
pruneJiraIssuesMissingUpstream,
|
|
@@ -112,7 +120,11 @@ export class JiraProvider implements KanbanProvider {
|
|
|
112
120
|
const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
|
|
113
121
|
const now = Date.now()
|
|
114
122
|
if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
|
|
115
|
-
|
|
123
|
+
// `force` only bypasses the poll throttle; it must NOT imply a full
|
|
124
|
+
// 1970-based reconcile, which re-fetches every issue plus a per-issue
|
|
125
|
+
// changelog call (~minutes). Writes get read-after-write freshness from
|
|
126
|
+
// hydrateIssueByKey instead, so a forced sync stays a cheap delta.
|
|
127
|
+
const fullReconcile = shouldRunFullReconcile(meta.lastFullSyncAt, now)
|
|
116
128
|
|
|
117
129
|
// 1. Resolve project.
|
|
118
130
|
const project = await this.client.getProject(this.config.projectKey)
|
|
@@ -122,13 +134,7 @@ export class JiraProvider implements KanbanProvider {
|
|
|
122
134
|
if (this.config.boardId !== undefined) {
|
|
123
135
|
const boardCfg = await this.client.getBoardColumns(this.config.boardId)
|
|
124
136
|
const boardId = this.config.boardId
|
|
125
|
-
const rows = boardCfg.columnConfig.columns
|
|
126
|
-
id: `board:${boardId}:${col.name}`,
|
|
127
|
-
name: col.name,
|
|
128
|
-
position: i,
|
|
129
|
-
statusIds: col.statuses.map((s) => s.id),
|
|
130
|
-
source: 'board' as const,
|
|
131
|
-
}))
|
|
137
|
+
const rows = jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns)
|
|
132
138
|
replaceJiraColumns(this.db, rows)
|
|
133
139
|
} else {
|
|
134
140
|
const statusCats = await this.client.getProjectStatuses(project.key)
|
|
@@ -185,10 +191,7 @@ export class JiraProvider implements KanbanProvider {
|
|
|
185
191
|
const sinceClause = since ?? '1970-01-01 00:00'
|
|
186
192
|
const jql = `project = ${project.key} AND updated >= "${sinceClause}" ORDER BY updated ASC`
|
|
187
193
|
|
|
188
|
-
let startAt = 0
|
|
189
194
|
const maxResults = 100
|
|
190
|
-
let accumulated = 0
|
|
191
|
-
let total = Infinity
|
|
192
195
|
let newestUpdatedAt: string | null = meta.lastIssueUpdatedAt
|
|
193
196
|
const seenIssueIds = new Set<string>()
|
|
194
197
|
const issueFields = [
|
|
@@ -204,12 +207,28 @@ export class JiraProvider implements KanbanProvider {
|
|
|
204
207
|
'updated',
|
|
205
208
|
'project',
|
|
206
209
|
]
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
// /rest/api/3/search/jql paginates by an opaque nextPageToken and omits
|
|
211
|
+
// `total`, so we follow the cursor until the server reports `isLast` or stops
|
|
212
|
+
// handing back a token. The previous total/startAt loop fetched only the first
|
|
213
|
+
// page (oldest 100 by updated ASC) once `total` came back undefined, so every
|
|
214
|
+
// issue beyond the first page — including newly-created tickets on a project
|
|
215
|
+
// with >100 issues — was never cached. seenPageTokens guards against a server
|
|
216
|
+
// that repeats a cursor, which would otherwise spin this loop forever and hang
|
|
217
|
+
// the poll cycle; an empty page is not treated as terminal because a non-last
|
|
218
|
+
// page may legitimately carry a token with zero issues.
|
|
219
|
+
const seenPageTokens = new Set<string>()
|
|
220
|
+
let nextPageToken: string | undefined
|
|
221
|
+
let firstPage = true
|
|
222
|
+
let paginationComplete = false
|
|
223
|
+
while (firstPage || nextPageToken !== undefined) {
|
|
224
|
+
firstPage = false
|
|
225
|
+
const page = await this.client.listIssues({
|
|
226
|
+
jql,
|
|
227
|
+
startAt: 0,
|
|
228
|
+
maxResults,
|
|
229
|
+
fields: issueFields,
|
|
230
|
+
nextPageToken,
|
|
231
|
+
})
|
|
213
232
|
|
|
214
233
|
upsertJiraIssues(
|
|
215
234
|
this.db,
|
|
@@ -253,10 +272,37 @@ export class JiraProvider implements KanbanProvider {
|
|
|
253
272
|
})
|
|
254
273
|
}
|
|
255
274
|
|
|
256
|
-
|
|
257
|
-
|
|
275
|
+
const decision = decideJiraPagination(page, seenPageTokens)
|
|
276
|
+
if (decision.nextToken !== undefined) {
|
|
277
|
+
seenPageTokens.add(decision.nextToken)
|
|
278
|
+
nextPageToken = decision.nextToken
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
paginationComplete = decision.complete
|
|
282
|
+
if (!paginationComplete) {
|
|
283
|
+
// The server stopped advancing the cursor before reporting a definitive
|
|
284
|
+
// end (stalled/contradictory). seenIssueIds is only a partial scan, so we
|
|
285
|
+
// must not treat it as authoritative for pruning below.
|
|
286
|
+
console.warn(
|
|
287
|
+
`[jira] search/jql scan ended without a definitive last page; treating as incomplete`,
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
break
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!paginationComplete) {
|
|
294
|
+
// The scan ended early (stalled/contradictory cursor). Leave sync metadata
|
|
295
|
+
// unchanged so this partial result is not recorded as a clean sync: lastSyncAt
|
|
296
|
+
// is not advanced, so the next sync is not throttled and retries promptly, and
|
|
297
|
+
// the full-reconcile marker stays due. The issues we did fetch are already
|
|
298
|
+
// cached (additive); we only skip pruning — which would delete issues that
|
|
299
|
+
// exist upstream on pages we never fetched — and the metadata advance.
|
|
300
|
+
return
|
|
258
301
|
}
|
|
259
302
|
|
|
303
|
+
// Prune against seenIssueIds and advance lastFullSyncAt only on a full
|
|
304
|
+
// reconcile; a delta sync's seenIssueIds is intentionally partial. (The scan
|
|
305
|
+
// is known complete here — an incomplete scan returned above.)
|
|
260
306
|
if (fullReconcile) {
|
|
261
307
|
pruneJiraIssuesMissingUpstream(this.db, project.key, [...seenIssueIds])
|
|
262
308
|
}
|
|
@@ -275,18 +321,7 @@ export class JiraProvider implements KanbanProvider {
|
|
|
275
321
|
}
|
|
276
322
|
|
|
277
323
|
private resolveColumnId(input: string): string {
|
|
278
|
-
|
|
279
|
-
// Priority 1: exact id.
|
|
280
|
-
const byId = columns.find((c) => c.id === input)
|
|
281
|
-
if (byId) return byId.id
|
|
282
|
-
// Priority 2: case-insensitive name.
|
|
283
|
-
const lower = input.toLowerCase()
|
|
284
|
-
const byName = columns.find((c) => c.name.toLowerCase() === lower)
|
|
285
|
-
if (byName) return byName.id
|
|
286
|
-
// Priority 3: status_ids containment (raw status id).
|
|
287
|
-
const byStatus = columns.find((c) => decodeColumnStatusIds(c).includes(input))
|
|
288
|
-
if (byStatus) return byStatus.id
|
|
289
|
-
throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No Jira column matching '${input}'`)
|
|
324
|
+
return resolveJiraColumnId(getCachedColumns(this.db), input)
|
|
290
325
|
}
|
|
291
326
|
|
|
292
327
|
private async buildBoardConfig(): Promise<BoardConfig> {
|
|
@@ -467,6 +502,55 @@ export class JiraProvider implements KanbanProvider {
|
|
|
467
502
|
}
|
|
468
503
|
}
|
|
469
504
|
|
|
505
|
+
// Read-after-write via the direct issue endpoint. Unlike JQL search (used by
|
|
506
|
+
// sync()), GET /issue/{key} has no search-index lag, so a just-created or
|
|
507
|
+
// just-transitioned issue is reflected immediately. This replaces the previous
|
|
508
|
+
// "create/transition then sync(true) then getCachedTask" pattern, which raced
|
|
509
|
+
// the search index (creates reported "not yet visible"; a move's new status
|
|
510
|
+
// sometimes didn't land) and forced a full whole-project reconcile per write.
|
|
511
|
+
private async hydrateIssueByKey(key: string): Promise<Task> {
|
|
512
|
+
// getIssue throws on a missing key (404), so reaching this method means the
|
|
513
|
+
// issue exists upstream. A null read-back would therefore be a genuine cache
|
|
514
|
+
// anomaly (the upsert above did not land), not an ordinary not-found — surface
|
|
515
|
+
// it rather than threading an unreachable null through every caller.
|
|
516
|
+
const issue = await this.client.getIssue(key)
|
|
517
|
+
upsertJiraIssues(this.db, [
|
|
518
|
+
{
|
|
519
|
+
id: issue.id,
|
|
520
|
+
key: issue.key,
|
|
521
|
+
summary: issue.fields.summary,
|
|
522
|
+
descriptionText: issue.fields.description
|
|
523
|
+
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
524
|
+
: '',
|
|
525
|
+
statusId: issue.fields.status.id,
|
|
526
|
+
priorityName: issue.fields.priority?.name ?? null,
|
|
527
|
+
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
528
|
+
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
529
|
+
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
530
|
+
labels: issue.fields.labels ?? [],
|
|
531
|
+
commentCount: issue.fields.comment?.total ?? 0,
|
|
532
|
+
projectKey: issue.fields.project?.key ?? this.config.projectKey,
|
|
533
|
+
url: `${this.config.baseUrl}/browse/${issue.key}`,
|
|
534
|
+
createdAt: issue.fields.created,
|
|
535
|
+
updatedAt: issue.fields.updated,
|
|
536
|
+
},
|
|
537
|
+
])
|
|
538
|
+
// Ingest the changelog like the sync loop does, so a just-applied transition
|
|
539
|
+
// is recorded in jira_activity immediately (backs getActivity and the
|
|
540
|
+
// poll-based `moved` trigger) rather than waiting for the next unthrottled
|
|
541
|
+
// sync. Best-effort: activity must not fail the mutation.
|
|
542
|
+
await this.ingestIssueActivity(issue.id).catch((err) => {
|
|
543
|
+
console.warn(`[jira] activity fetch for ${issue.key} failed:`, err)
|
|
544
|
+
})
|
|
545
|
+
const task = getCachedTask(this.db, key)
|
|
546
|
+
if (!task) {
|
|
547
|
+
providerUpstreamError(
|
|
548
|
+
`Jira issue ${key} was hydrated from GET /issue but is missing from the cache.`,
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
return task
|
|
552
|
+
}
|
|
553
|
+
|
|
470
554
|
async createTask(input: CreateTaskInput): Promise<Task> {
|
|
471
555
|
await this.sync()
|
|
472
556
|
this.normalizeProjectField(input.project)
|
|
@@ -494,14 +578,7 @@ export class JiraProvider implements KanbanProvider {
|
|
|
494
578
|
// issues land in the project workflow's default start state. Use
|
|
495
579
|
// `moveTask` after create to change status.
|
|
496
580
|
const created = await this.client.createIssue({ fields })
|
|
497
|
-
|
|
498
|
-
const fresh = getCachedTask(this.db, created.key)
|
|
499
|
-
if (!fresh) {
|
|
500
|
-
providerUpstreamError(
|
|
501
|
-
`Jira issue ${created.key} was created but is not yet visible in the cache after sync.`,
|
|
502
|
-
)
|
|
503
|
-
}
|
|
504
|
-
return fresh
|
|
581
|
+
return this.hydrateIssueByKey(created.key)
|
|
505
582
|
}
|
|
506
583
|
|
|
507
584
|
async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
|
|
@@ -536,12 +613,7 @@ export class JiraProvider implements KanbanProvider {
|
|
|
536
613
|
if (Object.keys(fields).length > 0) {
|
|
537
614
|
await this.client.updateIssue(issueKey, { fields })
|
|
538
615
|
}
|
|
539
|
-
|
|
540
|
-
const fresh = getCachedTask(this.db, issueKey)
|
|
541
|
-
if (!fresh) {
|
|
542
|
-
providerUpstreamError(`Jira issue ${issueKey} disappeared from cache after update.`)
|
|
543
|
-
}
|
|
544
|
-
return fresh
|
|
616
|
+
return this.hydrateIssueByKey(issueKey)
|
|
545
617
|
}
|
|
546
618
|
|
|
547
619
|
async moveTask(idOrRef: string, column: string): Promise<Task> {
|
|
@@ -578,12 +650,7 @@ export class JiraProvider implements KanbanProvider {
|
|
|
578
650
|
)
|
|
579
651
|
}
|
|
580
652
|
await this.client.transitionIssue(issueKey, match.id)
|
|
581
|
-
|
|
582
|
-
const fresh = getCachedTask(this.db, issueKey)
|
|
583
|
-
if (!fresh) {
|
|
584
|
-
providerUpstreamError(`Jira issue ${issueKey} missing from cache after transition.`)
|
|
585
|
-
}
|
|
586
|
-
return fresh
|
|
653
|
+
return this.hydrateIssueByKey(issueKey)
|
|
587
654
|
}
|
|
588
655
|
|
|
589
656
|
async deleteTask(_idOrRef: string): Promise<Task> {
|