@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.
@@ -14,9 +14,21 @@ import type {
14
14
  TaskComment,
15
15
  } from '../types'
16
16
  import { JIRA_CAPABILITIES } from './capabilities'
17
- import { decodeColumnStatusIds, type JiraActivityRow, type JiraColumnRow } from './jira-cache'
17
+ import {
18
+ decodeColumnStatusIds,
19
+ jiraBoardColumnRows,
20
+ resolveJiraColumnId,
21
+ type JiraActivityRow,
22
+ type JiraColumnRow,
23
+ } from './jira-cache'
18
24
  import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
19
- 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'
20
32
  import type { JiraProviderConfig } from './jira'
21
33
  import { providerUpstreamError, unsupportedOperation } from './errors'
22
34
  import type {
@@ -319,6 +331,16 @@ export class PostgresJiraProvider implements KanbanProvider {
319
331
  }
320
332
  }
321
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.
322
344
  private async replaceColumns(
323
345
  columns: Array<{
324
346
  id: string
@@ -327,22 +349,28 @@ export class PostgresJiraProvider implements KanbanProvider {
327
349
  statusIds: string[]
328
350
  source: 'board' | 'status'
329
351
  }>,
352
+ prune: boolean,
330
353
  ): Promise<void> {
331
- await this.sql.begin(async (tx) => {
332
- await tx`DELETE FROM jira_columns`
333
- for (const column of columns) {
334
- await tx`
335
- INSERT INTO jira_columns (id, name, position, status_ids, source)
336
- VALUES (
337
- ${column.id},
338
- ${column.name},
339
- ${column.position},
340
- ${JSON.stringify(column.statusIds)},
341
- ${column.source}
342
- )
343
- `
344
- }
345
- })
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
+ }
346
374
  }
347
375
 
348
376
  private async upsertUsers(
@@ -360,28 +388,37 @@ export class PostgresJiraProvider implements KanbanProvider {
360
388
  }
361
389
  }
362
390
 
363
- private async replacePriorities(priorities: Array<{ id: string; name: string }>): Promise<void> {
364
- await this.sql.begin(async (tx) => {
365
- await tx`DELETE FROM jira_priorities`
366
- for (const priority of priorities) {
367
- await tx`
368
- INSERT INTO jira_priorities (id, name)
369
- VALUES (${priority.id}, ${priority.name})
370
- `
371
- }
372
- })
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
+ }
373
406
  }
374
407
 
375
- private async replaceIssueTypes(types: Array<{ id: string; name: string }>): Promise<void> {
376
- await this.sql.begin(async (tx) => {
377
- await tx`DELETE FROM jira_issue_types`
378
- for (const type of types) {
379
- await tx`
380
- INSERT INTO jira_issue_types (id, name)
381
- VALUES (${type.id}, ${type.name})
382
- `
383
- }
384
- })
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
+ }
385
422
  }
386
423
 
387
424
  private async upsertIssues(
@@ -597,22 +634,25 @@ export class PostgresJiraProvider implements KanbanProvider {
597
634
  const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
598
635
  const now = Date.now()
599
636
  if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
600
- 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)
601
643
 
602
644
  const project = await this.client.getProject(this.config.projectKey)
603
645
  await this.saveTeamInfo({ id: project.id, key: project.key, name: project.name })
604
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).
605
650
  if (this.config.boardId !== undefined) {
606
651
  const boardCfg = await this.client.getBoardColumns(this.config.boardId)
607
652
  const boardId = this.config.boardId
608
653
  await this.replaceColumns(
609
- boardCfg.columnConfig.columns.map((column, index) => ({
610
- id: `board:${boardId}:${column.name}`,
611
- name: column.name,
612
- position: index,
613
- statusIds: column.statuses.map((status) => status.id),
614
- source: 'board' as const,
615
- })),
654
+ jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns),
655
+ fullReconcile,
616
656
  )
617
657
  } else {
618
658
  const statusCats = await this.client.getProjectStatuses(project.key)
@@ -633,6 +673,7 @@ export class PostgresJiraProvider implements KanbanProvider {
633
673
  statusIds: [status.id],
634
674
  source: 'status' as const,
635
675
  })),
676
+ fullReconcile,
636
677
  )
637
678
  }
638
679
 
@@ -654,18 +695,17 @@ export class PostgresJiraProvider implements KanbanProvider {
654
695
  )
655
696
  await this.replacePriorities(
656
697
  priorities.map((priority) => ({ id: priority.id, name: priority.name })),
698
+ fullReconcile,
657
699
  )
658
700
  await this.replaceIssueTypes(
659
701
  issueTypes.map((issueType) => ({ id: issueType.id, name: issueType.name })),
702
+ fullReconcile,
660
703
  )
661
704
 
662
705
  const since = fullReconcile ? null : meta.lastIssueUpdatedAt
663
706
  const sinceClause = since ?? '1970-01-01 00:00'
664
707
  const jql = `project = ${project.key} AND updated >= "${sinceClause}" ORDER BY updated ASC`
665
- let startAt = 0
666
708
  const maxResults = 100
667
- let accumulated = 0
668
- let total = Infinity
669
709
  let newestUpdatedAt: string | null = meta.lastIssueUpdatedAt
670
710
  const seenIssueIds = new Set<string>()
671
711
  const issueFields = [
@@ -682,10 +722,28 @@ export class PostgresJiraProvider implements KanbanProvider {
682
722
  'project',
683
723
  ]
684
724
 
685
- while (accumulated < total) {
686
- const page = await this.client.listIssues({ jql, startAt, maxResults, fields: issueFields })
687
- total = page.total
688
- 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
+ })
689
747
 
690
748
  await this.upsertIssues(
691
749
  page.issues.map((issue) => ({
@@ -722,10 +780,37 @@ export class PostgresJiraProvider implements KanbanProvider {
722
780
  })
723
781
  }
724
782
 
725
- accumulated += page.issues.length
726
- 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
727
809
  }
728
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.)
729
814
  if (fullReconcile) {
730
815
  await this.pruneIssuesMissingUpstream(project.key, [...seenIssueIds])
731
816
  }
@@ -741,15 +826,7 @@ export class PostgresJiraProvider implements KanbanProvider {
741
826
  }
742
827
 
743
828
  private async resolveColumnId(input: string): Promise<string> {
744
- const columns = await this.getColumns()
745
- const byId = columns.find((column) => column.id === input)
746
- if (byId) return byId.id
747
- const lower = input.toLowerCase()
748
- const byName = columns.find((column) => column.name.toLowerCase() === lower)
749
- if (byName) return byName.id
750
- const byStatus = columns.find((column) => decodeColumnStatusIds(column).includes(input))
751
- if (byStatus) return byStatus.id
752
- throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No Jira column matching '${input}'`)
829
+ return resolveJiraColumnId(await this.getColumns(), input)
753
830
  }
754
831
 
755
832
  private async buildBoardConfig(): Promise<BoardConfig> {
@@ -931,6 +1008,56 @@ export class PostgresJiraProvider implements KanbanProvider {
931
1008
  await this.saveActivity(rows)
932
1009
  }
933
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
+
934
1061
  async createTask(input: CreateTaskInput): Promise<Task> {
935
1062
  await this.sync()
936
1063
  this.normalizeProjectField(input.project)
@@ -951,14 +1078,7 @@ export class PostgresJiraProvider implements KanbanProvider {
951
1078
  const labels = normalizeJiraLabels(input.labels)
952
1079
  if (labels.length > 0) fields['labels'] = labels
953
1080
  const created = await this.client.createIssue({ fields })
954
- await this.sync(true)
955
- const fresh = await this.getCachedTask(created.key)
956
- if (!fresh) {
957
- providerUpstreamError(
958
- `Jira issue ${created.key} was created but is not yet visible in the cache after sync.`,
959
- )
960
- }
961
- return fresh
1081
+ return this.hydrateIssueByKey(created.key)
962
1082
  }
963
1083
 
964
1084
  async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
@@ -986,10 +1106,7 @@ export class PostgresJiraProvider implements KanbanProvider {
986
1106
  : null
987
1107
  }
988
1108
  if (Object.keys(fields).length > 0) await this.client.updateIssue(issueKey, { fields })
989
- await this.sync(true)
990
- const fresh = await this.getCachedTask(issueKey)
991
- if (!fresh) providerUpstreamError(`Jira issue ${issueKey} disappeared from cache after update.`)
992
- return fresh
1109
+ return this.hydrateIssueByKey(issueKey)
993
1110
  }
994
1111
 
995
1112
  async moveTask(idOrRef: string, column: string): Promise<Task> {
@@ -1023,10 +1140,7 @@ export class PostgresJiraProvider implements KanbanProvider {
1023
1140
  )
1024
1141
  }
1025
1142
  await this.client.transitionIssue(issueKey, match.id)
1026
- await this.sync(true)
1027
- const fresh = await this.getCachedTask(issueKey)
1028
- if (!fresh) providerUpstreamError(`Jira issue ${issueKey} missing from cache after transition.`)
1029
- return fresh
1143
+ return this.hydrateIssueByKey(issueKey)
1030
1144
  }
1031
1145
 
1032
1146
  async deleteTask(_idOrRef: string): Promise<Task> {