@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
|
@@ -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 {
|
|
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
|
-
|
|
338
|
-
await
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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(
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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(
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
724
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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> {
|