@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
|
@@ -14,9 +14,21 @@ import type {
|
|
|
14
14
|
TaskComment,
|
|
15
15
|
} from '../types'
|
|
16
16
|
import { JIRA_CAPABILITIES } from './capabilities'
|
|
17
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
332
|
-
await
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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(
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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(
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
|
610
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
726
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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> {
|