@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.
@@ -22,7 +22,13 @@ import {
22
22
  type JiraColumnRow,
23
23
  } from './jira-cache'
24
24
  import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
25
- import { JiraClient, normalizeJiraLabels, type JiraComment, type JiraIssue } from './jira-client'
25
+ import {
26
+ JiraClient,
27
+ decideJiraPagination,
28
+ normalizeJiraLabels,
29
+ type JiraComment,
30
+ type JiraIssue,
31
+ } from './jira-client'
26
32
  import type { JiraProviderConfig } from './jira'
27
33
  import { providerUpstreamError, unsupportedOperation } from './errors'
28
34
  import type {
@@ -325,6 +331,16 @@ export class PostgresJiraProvider implements KanbanProvider {
325
331
  }
326
332
  }
327
333
 
334
+ // Catalog refreshes (columns, priorities, issue types) UPSERT the current rows
335
+ // on every sync — UPSERT serializes per row and never collides on the primary
336
+ // key when multiple Dispatch replicas refresh concurrently, so a newly-created
337
+ // status/column/priority is reflected on the next sync. The obsolete-row DELETE
338
+ // (`prune`) runs ONLY on a full reconcile, mirroring how upstream-missing issues
339
+ // are pruned (see pruneIssuesMissingUpstream): a delta sync's snapshot can be
340
+ // stale, and a stale snapshot's DELETE would drop a row another replica just
341
+ // added with a fresher snapshot. Confining the delete to the periodic full
342
+ // reconcile (and self-healing via the every-sync UPSERT) keeps catalog pruning
343
+ // consistent with issue pruning and out of the common delta path.
328
344
  private async replaceColumns(
329
345
  columns: Array<{
330
346
  id: string
@@ -333,22 +349,28 @@ export class PostgresJiraProvider implements KanbanProvider {
333
349
  statusIds: string[]
334
350
  source: 'board' | 'status'
335
351
  }>,
352
+ prune: boolean,
336
353
  ): Promise<void> {
337
- await this.sql.begin(async (tx) => {
338
- await tx`DELETE FROM jira_columns`
339
- for (const column of columns) {
340
- await tx`
341
- INSERT INTO jira_columns (id, name, position, status_ids, source)
342
- VALUES (
343
- ${column.id},
344
- ${column.name},
345
- ${column.position},
346
- ${JSON.stringify(column.statusIds)},
347
- ${column.source}
348
- )
349
- `
350
- }
351
- })
354
+ for (const column of columns) {
355
+ await this.sql`
356
+ INSERT INTO jira_columns (id, name, position, status_ids, source)
357
+ VALUES (
358
+ ${column.id},
359
+ ${column.name},
360
+ ${column.position},
361
+ ${JSON.stringify(column.statusIds)},
362
+ ${column.source}
363
+ )
364
+ ON CONFLICT(id) DO UPDATE SET
365
+ name = EXCLUDED.name,
366
+ position = EXCLUDED.position,
367
+ status_ids = EXCLUDED.status_ids,
368
+ source = EXCLUDED.source
369
+ `
370
+ }
371
+ if (prune) {
372
+ await this.sql`DELETE FROM jira_columns WHERE NOT (id = ANY(${columns.map((c) => c.id)}))`
373
+ }
352
374
  }
353
375
 
354
376
  private async upsertUsers(
@@ -366,28 +388,37 @@ export class PostgresJiraProvider implements KanbanProvider {
366
388
  }
367
389
  }
368
390
 
369
- private async replacePriorities(priorities: Array<{ id: string; name: string }>): Promise<void> {
370
- await this.sql.begin(async (tx) => {
371
- await tx`DELETE FROM jira_priorities`
372
- for (const priority of priorities) {
373
- await tx`
374
- INSERT INTO jira_priorities (id, name)
375
- VALUES (${priority.id}, ${priority.name})
376
- `
377
- }
378
- })
391
+ private async replacePriorities(
392
+ priorities: Array<{ id: string; name: string }>,
393
+ prune: boolean,
394
+ ): Promise<void> {
395
+ for (const priority of priorities) {
396
+ await this.sql`
397
+ INSERT INTO jira_priorities (id, name)
398
+ VALUES (${priority.id}, ${priority.name})
399
+ ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name
400
+ `
401
+ }
402
+ if (prune) {
403
+ await this
404
+ .sql`DELETE FROM jira_priorities WHERE NOT (id = ANY(${priorities.map((p) => p.id)}))`
405
+ }
379
406
  }
380
407
 
381
- private async replaceIssueTypes(types: Array<{ id: string; name: string }>): Promise<void> {
382
- await this.sql.begin(async (tx) => {
383
- await tx`DELETE FROM jira_issue_types`
384
- for (const type of types) {
385
- await tx`
386
- INSERT INTO jira_issue_types (id, name)
387
- VALUES (${type.id}, ${type.name})
388
- `
389
- }
390
- })
408
+ private async replaceIssueTypes(
409
+ types: Array<{ id: string; name: string }>,
410
+ prune: boolean,
411
+ ): Promise<void> {
412
+ for (const type of types) {
413
+ await this.sql`
414
+ INSERT INTO jira_issue_types (id, name)
415
+ VALUES (${type.id}, ${type.name})
416
+ ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name
417
+ `
418
+ }
419
+ if (prune) {
420
+ await this.sql`DELETE FROM jira_issue_types WHERE NOT (id = ANY(${types.map((t) => t.id)}))`
421
+ }
391
422
  }
392
423
 
393
424
  private async upsertIssues(
@@ -603,15 +634,26 @@ export class PostgresJiraProvider implements KanbanProvider {
603
634
  const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
604
635
  const now = Date.now()
605
636
  if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
606
- const fullReconcile = force || shouldRunFullReconcile(meta.lastFullSyncAt, now)
637
+ // `force` bypasses the poll throttle (so create/move/update see their own
638
+ // write) but must NOT force a full 1970-based reconcile: on a large project
639
+ // that re-fetches every issue plus a per-issue changelog call (~minutes) on
640
+ // every write. A delta sync (updated >= lastIssueUpdatedAt) is cheap and
641
+ // still catches the just-written issue, which is always among the newest.
642
+ const fullReconcile = shouldRunFullReconcile(meta.lastFullSyncAt, now)
607
643
 
608
644
  const project = await this.client.getProject(this.config.projectKey)
609
645
  await this.saveTeamInfo({ id: project.id, key: project.key, name: project.name })
610
646
 
647
+ // Columns + catalogs (users/priorities/issue types) UPSERT on every sync so a
648
+ // newly-created Jira status/column/priority/user is reflected promptly; the
649
+ // obsolete-row prune is confined to the full reconcile (see replaceColumns).
611
650
  if (this.config.boardId !== undefined) {
612
651
  const boardCfg = await this.client.getBoardColumns(this.config.boardId)
613
652
  const boardId = this.config.boardId
614
- await this.replaceColumns(jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns))
653
+ await this.replaceColumns(
654
+ jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns),
655
+ fullReconcile,
656
+ )
615
657
  } else {
616
658
  const statusCats = await this.client.getProjectStatuses(project.key)
617
659
  const seen = new Set<string>()
@@ -631,6 +673,7 @@ export class PostgresJiraProvider implements KanbanProvider {
631
673
  statusIds: [status.id],
632
674
  source: 'status' as const,
633
675
  })),
676
+ fullReconcile,
634
677
  )
635
678
  }
636
679
 
@@ -652,18 +695,17 @@ export class PostgresJiraProvider implements KanbanProvider {
652
695
  )
653
696
  await this.replacePriorities(
654
697
  priorities.map((priority) => ({ id: priority.id, name: priority.name })),
698
+ fullReconcile,
655
699
  )
656
700
  await this.replaceIssueTypes(
657
701
  issueTypes.map((issueType) => ({ id: issueType.id, name: issueType.name })),
702
+ fullReconcile,
658
703
  )
659
704
 
660
705
  const since = fullReconcile ? null : meta.lastIssueUpdatedAt
661
706
  const sinceClause = since ?? '1970-01-01 00:00'
662
707
  const jql = `project = ${project.key} AND updated >= "${sinceClause}" ORDER BY updated ASC`
663
- let startAt = 0
664
708
  const maxResults = 100
665
- let accumulated = 0
666
- let total = Infinity
667
709
  let newestUpdatedAt: string | null = meta.lastIssueUpdatedAt
668
710
  const seenIssueIds = new Set<string>()
669
711
  const issueFields = [
@@ -680,10 +722,28 @@ export class PostgresJiraProvider implements KanbanProvider {
680
722
  'project',
681
723
  ]
682
724
 
683
- while (accumulated < total) {
684
- const page = await this.client.listIssues({ jql, startAt, maxResults, fields: issueFields })
685
- total = page.total
686
- if (page.issues.length === 0) break
725
+ // /rest/api/3/search/jql paginates by an opaque nextPageToken and omits
726
+ // `total`, so we follow the cursor until the server reports `isLast` or stops
727
+ // handing back a token. The previous total/startAt loop fetched only the first
728
+ // page (oldest 100 by updated ASC) once `total` came back undefined, so every
729
+ // issue beyond the first page — including newly-created tickets on a project
730
+ // with >100 issues — was never cached. seenPageTokens guards against a server
731
+ // that repeats a cursor, which would otherwise spin this loop forever and hang
732
+ // the poll cycle; an empty page is not treated as terminal because a non-last
733
+ // page may legitimately carry a token with zero issues.
734
+ const seenPageTokens = new Set<string>()
735
+ let nextPageToken: string | undefined
736
+ let firstPage = true
737
+ let paginationComplete = false
738
+ while (firstPage || nextPageToken !== undefined) {
739
+ firstPage = false
740
+ const page = await this.client.listIssues({
741
+ jql,
742
+ startAt: 0,
743
+ maxResults,
744
+ fields: issueFields,
745
+ nextPageToken,
746
+ })
687
747
 
688
748
  await this.upsertIssues(
689
749
  page.issues.map((issue) => ({
@@ -720,10 +780,37 @@ export class PostgresJiraProvider implements KanbanProvider {
720
780
  })
721
781
  }
722
782
 
723
- accumulated += page.issues.length
724
- startAt += page.issues.length
783
+ const decision = decideJiraPagination(page, seenPageTokens)
784
+ if (decision.nextToken !== undefined) {
785
+ seenPageTokens.add(decision.nextToken)
786
+ nextPageToken = decision.nextToken
787
+ continue
788
+ }
789
+ paginationComplete = decision.complete
790
+ if (!paginationComplete) {
791
+ // The server stopped advancing the cursor before reporting a definitive
792
+ // end (stalled/contradictory). seenIssueIds is only a partial scan, so we
793
+ // must not treat it as authoritative for pruning below.
794
+ console.warn(
795
+ `[jira] search/jql scan ended without a definitive last page; treating as incomplete`,
796
+ )
797
+ }
798
+ break
799
+ }
800
+
801
+ if (!paginationComplete) {
802
+ // The scan ended early (stalled/contradictory cursor). Leave sync metadata
803
+ // unchanged so this partial result is not recorded as a clean sync: lastSyncAt
804
+ // is not advanced, so the next sync is not throttled and retries promptly, and
805
+ // the full-reconcile marker stays due. The issues we did fetch are already
806
+ // cached (additive); we only skip pruning — which would delete issues that
807
+ // exist upstream on pages we never fetched — and the metadata advance.
808
+ return
725
809
  }
726
810
 
811
+ // Prune against seenIssueIds and advance lastFullSyncAt only on a full
812
+ // reconcile; a delta sync's seenIssueIds is intentionally partial. (The scan
813
+ // is known complete here — an incomplete scan returned above.)
727
814
  if (fullReconcile) {
728
815
  await this.pruneIssuesMissingUpstream(project.key, [...seenIssueIds])
729
816
  }
@@ -921,6 +1008,56 @@ export class PostgresJiraProvider implements KanbanProvider {
921
1008
  await this.saveActivity(rows)
922
1009
  }
923
1010
 
1011
+ // Read-after-write via the direct issue endpoint. Unlike JQL search (used by
1012
+ // sync()), GET /issue/{key} has no search-index lag, so a just-created or
1013
+ // just-transitioned issue is reflected immediately. This replaces the previous
1014
+ // "transition/create then sync(true) then getCachedTask" pattern, which both
1015
+ // raced the search index (create reported "not yet visible"; a move's new
1016
+ // status didn't land, causing the daemon to re-issue the move in a loop) and
1017
+ // forced a full whole-project reconcile (~minutes) on every write.
1018
+ private async hydrateIssueByKey(key: string): Promise<Task> {
1019
+ // getIssue throws on a missing key (404), so reaching this method means the
1020
+ // issue exists upstream. A null read-back would therefore be a genuine cache
1021
+ // anomaly (the upsert above did not land), not an ordinary not-found — surface
1022
+ // it rather than threading an unreachable null through every caller.
1023
+ const issue = await this.client.getIssue(key)
1024
+ await this.upsertIssues([
1025
+ {
1026
+ id: issue.id,
1027
+ key: issue.key,
1028
+ summary: issue.fields.summary,
1029
+ descriptionText: issue.fields.description
1030
+ ? adfToPlainText(issue.fields.description as AdfDocument)
1031
+ : '',
1032
+ statusId: issue.fields.status.id,
1033
+ priorityName: issue.fields.priority?.name ?? null,
1034
+ issueTypeName: issue.fields.issuetype?.name ?? '',
1035
+ assigneeAccountId: issue.fields.assignee?.accountId ?? null,
1036
+ assigneeName: issue.fields.assignee?.displayName ?? null,
1037
+ labels: issue.fields.labels ?? [],
1038
+ commentCount: issue.fields.comment?.total ?? 0,
1039
+ projectKey: issue.fields.project?.key ?? this.config.projectKey,
1040
+ url: `${this.config.baseUrl}/browse/${issue.key}`,
1041
+ createdAt: issue.fields.created,
1042
+ updatedAt: issue.fields.updated,
1043
+ },
1044
+ ])
1045
+ // Ingest the changelog like the sync loop does, so a just-applied transition
1046
+ // is recorded in jira_activity immediately (backs getActivity and the
1047
+ // poll-based `moved` trigger) rather than waiting for the next unthrottled
1048
+ // sync. Best-effort: activity must not fail the mutation.
1049
+ await this.ingestIssueActivity(issue.id).catch((err) => {
1050
+ console.warn(`[jira] activity fetch for ${issue.key} failed:`, err)
1051
+ })
1052
+ const task = await this.getCachedTask(key)
1053
+ if (!task) {
1054
+ providerUpstreamError(
1055
+ `Jira issue ${key} was hydrated from GET /issue but is missing from the cache.`,
1056
+ )
1057
+ }
1058
+ return task
1059
+ }
1060
+
924
1061
  async createTask(input: CreateTaskInput): Promise<Task> {
925
1062
  await this.sync()
926
1063
  this.normalizeProjectField(input.project)
@@ -941,14 +1078,7 @@ export class PostgresJiraProvider implements KanbanProvider {
941
1078
  const labels = normalizeJiraLabels(input.labels)
942
1079
  if (labels.length > 0) fields['labels'] = labels
943
1080
  const created = await this.client.createIssue({ fields })
944
- await this.sync(true)
945
- const fresh = await this.getCachedTask(created.key)
946
- if (!fresh) {
947
- providerUpstreamError(
948
- `Jira issue ${created.key} was created but is not yet visible in the cache after sync.`,
949
- )
950
- }
951
- return fresh
1081
+ return this.hydrateIssueByKey(created.key)
952
1082
  }
953
1083
 
954
1084
  async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
@@ -976,10 +1106,7 @@ export class PostgresJiraProvider implements KanbanProvider {
976
1106
  : null
977
1107
  }
978
1108
  if (Object.keys(fields).length > 0) await this.client.updateIssue(issueKey, { fields })
979
- await this.sync(true)
980
- const fresh = await this.getCachedTask(issueKey)
981
- if (!fresh) providerUpstreamError(`Jira issue ${issueKey} disappeared from cache after update.`)
982
- return fresh
1109
+ return this.hydrateIssueByKey(issueKey)
983
1110
  }
984
1111
 
985
1112
  async moveTask(idOrRef: string, column: string): Promise<Task> {
@@ -1013,10 +1140,7 @@ export class PostgresJiraProvider implements KanbanProvider {
1013
1140
  )
1014
1141
  }
1015
1142
  await this.client.transitionIssue(issueKey, match.id)
1016
- await this.sync(true)
1017
- const fresh = await this.getCachedTask(issueKey)
1018
- if (!fresh) providerUpstreamError(`Jira issue ${issueKey} missing from cache after transition.`)
1019
- return fresh
1143
+ return this.hydrateIssueByKey(issueKey)
1020
1144
  }
1021
1145
 
1022
1146
  async deleteTask(_idOrRef: string): Promise<Task> {