@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.
@@ -145,6 +145,16 @@ function standardRoutes(opts: { boardCfg?: unknown } = {}): StubRoute[] {
145
145
  )
146
146
  },
147
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
+ },
148
158
  {
149
159
  match: (url) => /\/rest\/api\/3\/issue\/ENG-\d+\/comment$/.test(new URL(url).pathname),
150
160
  handler: async (url, init) => {
@@ -401,4 +411,149 @@ describe('postgres jira provider', () => {
401
411
  column_id: '20',
402
412
  })
403
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
+ })
404
559
  })
@@ -45,12 +45,73 @@ export interface JiraIssue {
45
45
  }
46
46
 
47
47
  export interface JiraSearchPage {
48
- startAt: number
49
- maxResults: number
50
- total: number
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
  }
@@ -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 { JiraClient, normalizeJiraLabels, type JiraComment, type JiraIssue } from './jira-client'
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,
@@ -114,7 +120,11 @@ export class JiraProvider implements KanbanProvider {
114
120
  const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
115
121
  const now = Date.now()
116
122
  if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
117
- const fullReconcile = force || shouldRunFullReconcile(meta.lastFullSyncAt, now)
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)
118
128
 
119
129
  // 1. Resolve project.
120
130
  const project = await this.client.getProject(this.config.projectKey)
@@ -181,10 +191,7 @@ export class JiraProvider implements KanbanProvider {
181
191
  const sinceClause = since ?? '1970-01-01 00:00'
182
192
  const jql = `project = ${project.key} AND updated >= "${sinceClause}" ORDER BY updated ASC`
183
193
 
184
- let startAt = 0
185
194
  const maxResults = 100
186
- let accumulated = 0
187
- let total = Infinity
188
195
  let newestUpdatedAt: string | null = meta.lastIssueUpdatedAt
189
196
  const seenIssueIds = new Set<string>()
190
197
  const issueFields = [
@@ -200,12 +207,28 @@ export class JiraProvider implements KanbanProvider {
200
207
  'updated',
201
208
  'project',
202
209
  ]
203
- // Terminates when accumulated reaches total, or when the server returns
204
- // an empty page (defensive against buggy servers not advancing startAt).
205
- while (accumulated < total) {
206
- const page = await this.client.listIssues({ jql, startAt, maxResults, fields: issueFields })
207
- total = page.total
208
- if (page.issues.length === 0) break
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
+ })
209
232
 
210
233
  upsertJiraIssues(
211
234
  this.db,
@@ -249,10 +272,37 @@ export class JiraProvider implements KanbanProvider {
249
272
  })
250
273
  }
251
274
 
252
- accumulated += page.issues.length
253
- startAt += page.issues.length
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
254
291
  }
255
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
301
+ }
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.)
256
306
  if (fullReconcile) {
257
307
  pruneJiraIssuesMissingUpstream(this.db, project.key, [...seenIssueIds])
258
308
  }
@@ -452,6 +502,55 @@ export class JiraProvider implements KanbanProvider {
452
502
  }
453
503
  }
454
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
+
455
554
  async createTask(input: CreateTaskInput): Promise<Task> {
456
555
  await this.sync()
457
556
  this.normalizeProjectField(input.project)
@@ -479,14 +578,7 @@ export class JiraProvider implements KanbanProvider {
479
578
  // issues land in the project workflow's default start state. Use
480
579
  // `moveTask` after create to change status.
481
580
  const created = await this.client.createIssue({ fields })
482
- await this.sync(true)
483
- const fresh = getCachedTask(this.db, created.key)
484
- if (!fresh) {
485
- providerUpstreamError(
486
- `Jira issue ${created.key} was created but is not yet visible in the cache after sync.`,
487
- )
488
- }
489
- return fresh
581
+ return this.hydrateIssueByKey(created.key)
490
582
  }
491
583
 
492
584
  async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
@@ -521,12 +613,7 @@ export class JiraProvider implements KanbanProvider {
521
613
  if (Object.keys(fields).length > 0) {
522
614
  await this.client.updateIssue(issueKey, { fields })
523
615
  }
524
- await this.sync(true)
525
- const fresh = getCachedTask(this.db, issueKey)
526
- if (!fresh) {
527
- providerUpstreamError(`Jira issue ${issueKey} disappeared from cache after update.`)
528
- }
529
- return fresh
616
+ return this.hydrateIssueByKey(issueKey)
530
617
  }
531
618
 
532
619
  async moveTask(idOrRef: string, column: string): Promise<Task> {
@@ -563,12 +650,7 @@ export class JiraProvider implements KanbanProvider {
563
650
  )
564
651
  }
565
652
  await this.client.transitionIssue(issueKey, match.id)
566
- await this.sync(true)
567
- const fresh = getCachedTask(this.db, issueKey)
568
- if (!fresh) {
569
- providerUpstreamError(`Jira issue ${issueKey} missing from cache after transition.`)
570
- }
571
- return fresh
653
+ return this.hydrateIssueByKey(issueKey)
572
654
  }
573
655
 
574
656
  async deleteTask(_idOrRef: string): Promise<Task> {