@andypai/agent-kanban 0.6.3 → 0.6.5
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 +11 -3
- package/src/__tests__/index.test.ts +27 -1
- package/src/__tests__/jira-cache.test.ts +68 -0
- package/src/__tests__/jira-provider-read.test.ts +130 -0
- package/src/__tests__/jira-wiring.test.ts +41 -0
- package/src/__tests__/linear-provider-comment.test.ts +81 -0
- package/src/__tests__/linear-provider-sync.test.ts +107 -0
- package/src/__tests__/linear-state-resolution.test.ts +60 -0
- package/src/__tests__/local-provider-core.test.ts +245 -0
- package/src/__tests__/postgres-jira-provider.test.ts +55 -0
- package/src/__tests__/postgres-linear-provider.test.ts +90 -0
- package/src/__tests__/postgres-local-provider.test.ts +26 -0
- package/src/__tests__/provider-sync-core.test.ts +150 -0
- package/src/__tests__/provider-team-info.test.ts +37 -0
- package/src/__tests__/ui-board-slice.test.ts +14 -0
- package/src/__tests__/use-cases.test.ts +21 -42
- package/src/api.ts +78 -132
- package/src/column-roles.ts +3 -2
- package/src/index.ts +135 -92
- package/src/mcp/core.ts +7 -8
- package/src/provider-runtime.ts +12 -56
- package/src/providers/cache-task-mappers.ts +123 -0
- package/src/providers/factory.ts +103 -0
- package/src/providers/index.ts +4 -25
- package/src/providers/jira-cache.ts +41 -99
- package/src/providers/jira-core.ts +81 -81
- package/src/providers/linear-cache.ts +42 -69
- package/src/providers/linear-core.ts +44 -56
- package/src/providers/local-core.ts +226 -0
- package/src/providers/local.ts +6 -182
- package/src/providers/postgres-batch.ts +10 -0
- package/src/providers/postgres-jira-cache.ts +255 -213
- package/src/providers/postgres-jira.ts +2 -20
- package/src/providers/postgres-linear-cache.ts +279 -183
- package/src/providers/postgres-linear.ts +2 -20
- package/src/providers/postgres-local.ts +22 -47
- package/src/providers/sqlite-local-store.ts +121 -0
- package/src/providers/sync-core.ts +91 -0
- package/src/providers/team-info.ts +24 -0
- package/src/providers/warn-once.ts +15 -0
- package/src/use-cases.ts +7 -131
- package/src/webhook-events.ts +29 -1
- package/ui/dist/assets/index-DcZH7fI3.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-Cigv8a9S.js +0 -40
|
@@ -10,72 +10,11 @@ import {
|
|
|
10
10
|
} from './jira-cache'
|
|
11
11
|
import type { JiraCachePort } from './jira-core'
|
|
12
12
|
import { ensureWebhookEventsSchema } from '../webhook-events'
|
|
13
|
+
import { jiraTaskFromRow, type JiraTaskRow } from './cache-task-mappers'
|
|
14
|
+
import { type Exec, recordsetJson } from './postgres-batch'
|
|
15
|
+
import { parseProviderTeamInfo } from './team-info'
|
|
13
16
|
|
|
14
|
-
export
|
|
15
|
-
id: string
|
|
16
|
-
key: string
|
|
17
|
-
summary: string
|
|
18
|
-
description_text: string
|
|
19
|
-
status_id: string
|
|
20
|
-
priority_name: string
|
|
21
|
-
issue_type_name: string
|
|
22
|
-
assignee_account_id: string | null
|
|
23
|
-
assignee_name: string
|
|
24
|
-
labels: string
|
|
25
|
-
comment_count: number
|
|
26
|
-
project_key: string
|
|
27
|
-
url: string | null
|
|
28
|
-
created_at: string
|
|
29
|
-
updated_at: string
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function mapPriorityNameToCanonical(name: string): Task['priority'] {
|
|
33
|
-
switch (name.trim().toLowerCase()) {
|
|
34
|
-
case 'highest':
|
|
35
|
-
return 'urgent'
|
|
36
|
-
case 'high':
|
|
37
|
-
return 'high'
|
|
38
|
-
case 'medium':
|
|
39
|
-
return 'medium'
|
|
40
|
-
default:
|
|
41
|
-
return 'low'
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function parseLabels(raw: string): string[] {
|
|
46
|
-
try {
|
|
47
|
-
const parsed: unknown = JSON.parse(raw)
|
|
48
|
-
return Array.isArray(parsed)
|
|
49
|
-
? parsed.filter((value): value is string => typeof value === 'string')
|
|
50
|
-
: []
|
|
51
|
-
} catch {
|
|
52
|
-
return []
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function taskFromRow(row: JiraIssueRow): Task {
|
|
57
|
-
return {
|
|
58
|
-
id: `jira:${row.id}`,
|
|
59
|
-
providerId: row.id,
|
|
60
|
-
externalRef: row.key,
|
|
61
|
-
url: row.url,
|
|
62
|
-
title: row.summary,
|
|
63
|
-
description: row.description_text,
|
|
64
|
-
column_id: row.status_id,
|
|
65
|
-
position: 0,
|
|
66
|
-
priority: mapPriorityNameToCanonical(row.priority_name),
|
|
67
|
-
assignee: row.assignee_name,
|
|
68
|
-
assignees: row.assignee_name ? [row.assignee_name] : [],
|
|
69
|
-
labels: parseLabels(row.labels),
|
|
70
|
-
comment_count: row.comment_count,
|
|
71
|
-
project: row.project_key,
|
|
72
|
-
metadata: '{}',
|
|
73
|
-
created_at: row.created_at,
|
|
74
|
-
updated_at: row.updated_at,
|
|
75
|
-
version: row.updated_at,
|
|
76
|
-
source_updated_at: row.updated_at,
|
|
77
|
-
}
|
|
78
|
-
}
|
|
17
|
+
export type JiraIssueRow = JiraTaskRow
|
|
79
18
|
|
|
80
19
|
/**
|
|
81
20
|
* Postgres-backed cache/repository for the Jira provider. Mirrors the role of the
|
|
@@ -193,20 +132,33 @@ export class PostgresJiraCache implements JiraCachePort {
|
|
|
193
132
|
'lastFullSyncAt',
|
|
194
133
|
'lastWebhookAt',
|
|
195
134
|
] as const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
135
|
+
await this.sql.begin(async (tx) => {
|
|
136
|
+
for (const key of keys) {
|
|
137
|
+
if (!Object.prototype.hasOwnProperty.call(meta, key)) continue
|
|
138
|
+
const value = meta[key]
|
|
139
|
+
if (value === null) {
|
|
140
|
+
await tx`DELETE FROM jira_sync_meta WHERE key = ${key}`
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (key === 'boardId') {
|
|
144
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
145
|
+
await tx`
|
|
146
|
+
INSERT INTO jira_sync_meta (key, value)
|
|
147
|
+
VALUES (${key}, ${String(value)})
|
|
148
|
+
ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value
|
|
149
|
+
`
|
|
150
|
+
}
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
if (typeof value === 'string') {
|
|
154
|
+
await tx`
|
|
155
|
+
INSERT INTO jira_sync_meta (key, value)
|
|
156
|
+
VALUES (${key}, ${value})
|
|
157
|
+
ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value
|
|
158
|
+
`
|
|
159
|
+
}
|
|
207
160
|
}
|
|
208
|
-
|
|
209
|
-
}
|
|
161
|
+
})
|
|
210
162
|
}
|
|
211
163
|
|
|
212
164
|
async loadSyncMeta(): Promise<JiraSyncMeta> {
|
|
@@ -231,30 +183,15 @@ export class PostgresJiraCache implements JiraCachePort {
|
|
|
231
183
|
}
|
|
232
184
|
|
|
233
185
|
async loadTeamInfo(): Promise<ProviderTeamInfo | null> {
|
|
234
|
-
|
|
235
|
-
if (raw === null) return null
|
|
236
|
-
try {
|
|
237
|
-
const parsed = JSON.parse(raw) as ProviderTeamInfo
|
|
238
|
-
return typeof parsed.id === 'string' &&
|
|
239
|
-
typeof parsed.key === 'string' &&
|
|
240
|
-
typeof parsed.name === 'string'
|
|
241
|
-
? parsed
|
|
242
|
-
: null
|
|
243
|
-
} catch {
|
|
244
|
-
return null
|
|
245
|
-
}
|
|
186
|
+
return parseProviderTeamInfo(await this.getMeta('team'))
|
|
246
187
|
}
|
|
247
188
|
|
|
248
189
|
// Catalog refreshes (columns, priorities, issue types) UPSERT the current rows
|
|
249
|
-
// on every sync
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
// stale, and a stale snapshot's DELETE would drop a row another replica just
|
|
255
|
-
// added with a fresher snapshot. Confining the delete to the periodic full
|
|
256
|
-
// reconcile (and self-healing via the every-sync UPSERT) keeps catalog pruning
|
|
257
|
-
// consistent with issue pruning and out of the common delta path.
|
|
190
|
+
// on every sync. The obsolete-row DELETE (`prune`) runs ONLY on a full
|
|
191
|
+
// reconcile, mirroring issue pruning: a delta snapshot can be stale, and a
|
|
192
|
+
// stale snapshot must not delete rows another replica just added. Each catalog
|
|
193
|
+
// write runs in one advisory-locked transaction so the full-reconcile upsert
|
|
194
|
+
// and prune step are visible as one cache update.
|
|
258
195
|
async replaceColumns(
|
|
259
196
|
columns: Array<{
|
|
260
197
|
id: string
|
|
@@ -265,74 +202,119 @@ export class PostgresJiraCache implements JiraCachePort {
|
|
|
265
202
|
}>,
|
|
266
203
|
prune: boolean,
|
|
267
204
|
): Promise<void> {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
205
|
+
const rows = columns.map((column) => ({
|
|
206
|
+
id: column.id,
|
|
207
|
+
name: column.name,
|
|
208
|
+
position: column.position,
|
|
209
|
+
status_ids: JSON.stringify(column.statusIds),
|
|
210
|
+
source: column.source,
|
|
211
|
+
}))
|
|
212
|
+
await this.sql.begin(async (tx) => {
|
|
213
|
+
await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-jira:catalog'))`
|
|
214
|
+
if (rows.length > 0) {
|
|
215
|
+
await tx`
|
|
216
|
+
INSERT INTO jira_columns (id, name, position, status_ids, source)
|
|
217
|
+
SELECT id, name, position, status_ids, source
|
|
218
|
+
FROM jsonb_to_recordset(${recordsetJson(tx, rows)}) AS data(
|
|
219
|
+
id text,
|
|
220
|
+
name text,
|
|
221
|
+
position integer,
|
|
222
|
+
status_ids text,
|
|
223
|
+
source text
|
|
224
|
+
)
|
|
225
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
226
|
+
name = EXCLUDED.name,
|
|
227
|
+
position = EXCLUDED.position,
|
|
228
|
+
status_ids = EXCLUDED.status_ids,
|
|
229
|
+
source = EXCLUDED.source
|
|
230
|
+
`
|
|
231
|
+
}
|
|
232
|
+
if (!prune) return
|
|
233
|
+
if (columns.length === 0) {
|
|
234
|
+
await tx`DELETE FROM jira_columns`
|
|
235
|
+
} else {
|
|
236
|
+
await tx`DELETE FROM jira_columns WHERE NOT (id = ANY(${columns.map((c) => c.id)}))`
|
|
237
|
+
}
|
|
238
|
+
})
|
|
288
239
|
}
|
|
289
240
|
|
|
290
241
|
async upsertUsers(
|
|
291
242
|
users: Array<{ accountId: string; displayName: string; active?: boolean }>,
|
|
292
243
|
): Promise<void> {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
244
|
+
if (users.length === 0) return
|
|
245
|
+
const now = new Date().toISOString()
|
|
246
|
+
const rows = users.map((user) => ({
|
|
247
|
+
account_id: user.accountId,
|
|
248
|
+
display_name: user.displayName,
|
|
249
|
+
active: user.active === false ? 0 : 1,
|
|
250
|
+
updated_at: now,
|
|
251
|
+
}))
|
|
252
|
+
await this.sql`
|
|
253
|
+
INSERT INTO jira_users (account_id, display_name, active, updated_at)
|
|
254
|
+
SELECT account_id, display_name, active, updated_at
|
|
255
|
+
FROM jsonb_to_recordset(${recordsetJson(this.sql, rows)}) AS data(
|
|
256
|
+
account_id text,
|
|
257
|
+
display_name text,
|
|
258
|
+
active integer,
|
|
259
|
+
updated_at text
|
|
260
|
+
)
|
|
261
|
+
ON CONFLICT(account_id) DO UPDATE SET
|
|
262
|
+
display_name = EXCLUDED.display_name,
|
|
263
|
+
active = EXCLUDED.active,
|
|
264
|
+
updated_at = EXCLUDED.updated_at
|
|
265
|
+
`
|
|
303
266
|
}
|
|
304
267
|
|
|
305
268
|
async replacePriorities(
|
|
306
269
|
priorities: Array<{ id: string; name: string }>,
|
|
307
270
|
prune: boolean,
|
|
308
271
|
): Promise<void> {
|
|
309
|
-
|
|
310
|
-
await
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
272
|
+
await this.sql.begin(async (tx) => {
|
|
273
|
+
await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-jira:catalog'))`
|
|
274
|
+
if (priorities.length > 0) {
|
|
275
|
+
await tx`
|
|
276
|
+
INSERT INTO jira_priorities (id, name)
|
|
277
|
+
SELECT id, name
|
|
278
|
+
FROM jsonb_to_recordset(${recordsetJson(tx, priorities)}) AS data(
|
|
279
|
+
id text,
|
|
280
|
+
name text
|
|
281
|
+
)
|
|
282
|
+
ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name
|
|
283
|
+
`
|
|
284
|
+
}
|
|
285
|
+
if (!prune) return
|
|
286
|
+
if (priorities.length === 0) {
|
|
287
|
+
await tx`DELETE FROM jira_priorities`
|
|
288
|
+
} else {
|
|
289
|
+
await tx`DELETE FROM jira_priorities WHERE NOT (id = ANY(${priorities.map((p) => p.id)}))`
|
|
290
|
+
}
|
|
291
|
+
})
|
|
320
292
|
}
|
|
321
293
|
|
|
322
294
|
async replaceIssueTypes(
|
|
323
295
|
types: Array<{ id: string; name: string }>,
|
|
324
296
|
prune: boolean,
|
|
325
297
|
): Promise<void> {
|
|
326
|
-
|
|
327
|
-
await
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
298
|
+
await this.sql.begin(async (tx) => {
|
|
299
|
+
await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-jira:catalog'))`
|
|
300
|
+
if (types.length > 0) {
|
|
301
|
+
await tx`
|
|
302
|
+
INSERT INTO jira_issue_types (id, name)
|
|
303
|
+
SELECT id, name
|
|
304
|
+
FROM jsonb_to_recordset(${recordsetJson(tx, types)}) AS data(
|
|
305
|
+
id text,
|
|
306
|
+
name text
|
|
307
|
+
)
|
|
308
|
+
ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name
|
|
309
|
+
`
|
|
310
|
+
}
|
|
311
|
+
if (!prune) return
|
|
312
|
+
if (types.length === 0) {
|
|
313
|
+
await tx`DELETE FROM jira_issue_types`
|
|
314
|
+
} else {
|
|
315
|
+
await tx`DELETE FROM jira_issue_types WHERE NOT (id = ANY(${types.map((t) => t.id)}))`
|
|
316
|
+
}
|
|
317
|
+
})
|
|
336
318
|
}
|
|
337
319
|
|
|
338
320
|
async upsertIssues(
|
|
@@ -355,71 +337,118 @@ export class PostgresJiraCache implements JiraCachePort {
|
|
|
355
337
|
}>,
|
|
356
338
|
): Promise<void> {
|
|
357
339
|
if (issues.length === 0) return
|
|
340
|
+
// A sync batch can repeat an issue id (an issue updated while pagination is
|
|
341
|
+
// in flight shows up on two pages); the batched ON CONFLICT DO UPDATE
|
|
342
|
+
// statement errors on intra-statement duplicates, so keep the last
|
|
343
|
+
// occurrence — matching the old row-by-row and SQLite last-wins behavior.
|
|
344
|
+
const rows = [
|
|
345
|
+
...new Map(
|
|
346
|
+
issues.map((issue) => [
|
|
347
|
+
issue.id,
|
|
348
|
+
{
|
|
349
|
+
id: issue.id,
|
|
350
|
+
key: issue.key,
|
|
351
|
+
summary: issue.summary,
|
|
352
|
+
description_text: issue.descriptionText,
|
|
353
|
+
status_id: issue.statusId,
|
|
354
|
+
priority_name: issue.priorityName ?? '',
|
|
355
|
+
issue_type_name: issue.issueTypeName ?? '',
|
|
356
|
+
assignee_account_id: issue.assigneeAccountId ?? null,
|
|
357
|
+
assignee_name: issue.assigneeName ?? '',
|
|
358
|
+
labels: JSON.stringify(issue.labels ?? []),
|
|
359
|
+
comment_count: issue.commentCount ?? 0,
|
|
360
|
+
project_key: issue.projectKey,
|
|
361
|
+
url: issue.url ?? null,
|
|
362
|
+
created_at: issue.createdAt,
|
|
363
|
+
updated_at: issue.updatedAt,
|
|
364
|
+
},
|
|
365
|
+
]),
|
|
366
|
+
).values(),
|
|
367
|
+
]
|
|
358
368
|
await this.sql.begin(async (tx) => {
|
|
359
369
|
await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-jira:issues'))`
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
370
|
+
await tx`
|
|
371
|
+
INSERT INTO jira_issues (
|
|
372
|
+
id, key, summary, description_text, status_id, priority_name, issue_type_name,
|
|
373
|
+
assignee_account_id, assignee_name, labels, comment_count, project_key, url, created_at, updated_at
|
|
374
|
+
)
|
|
375
|
+
SELECT
|
|
376
|
+
id, key, summary, description_text, status_id, priority_name, issue_type_name,
|
|
377
|
+
assignee_account_id, assignee_name, labels, comment_count, project_key, url, created_at, updated_at
|
|
378
|
+
FROM jsonb_to_recordset(${recordsetJson(tx, rows)}) AS data(
|
|
379
|
+
id text,
|
|
380
|
+
key text,
|
|
381
|
+
summary text,
|
|
382
|
+
description_text text,
|
|
383
|
+
status_id text,
|
|
384
|
+
priority_name text,
|
|
385
|
+
issue_type_name text,
|
|
386
|
+
assignee_account_id text,
|
|
387
|
+
assignee_name text,
|
|
388
|
+
labels text,
|
|
389
|
+
comment_count integer,
|
|
390
|
+
project_key text,
|
|
391
|
+
url text,
|
|
392
|
+
created_at text,
|
|
393
|
+
updated_at text
|
|
394
|
+
)
|
|
395
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
396
|
+
key = EXCLUDED.key,
|
|
397
|
+
summary = EXCLUDED.summary,
|
|
398
|
+
description_text = EXCLUDED.description_text,
|
|
399
|
+
status_id = EXCLUDED.status_id,
|
|
400
|
+
priority_name = EXCLUDED.priority_name,
|
|
401
|
+
issue_type_name = EXCLUDED.issue_type_name,
|
|
402
|
+
assignee_account_id = EXCLUDED.assignee_account_id,
|
|
403
|
+
assignee_name = EXCLUDED.assignee_name,
|
|
404
|
+
labels = EXCLUDED.labels,
|
|
405
|
+
comment_count = EXCLUDED.comment_count,
|
|
406
|
+
project_key = EXCLUDED.project_key,
|
|
407
|
+
url = EXCLUDED.url,
|
|
408
|
+
created_at = EXCLUDED.created_at,
|
|
409
|
+
updated_at = EXCLUDED.updated_at
|
|
410
|
+
`
|
|
388
411
|
})
|
|
389
412
|
}
|
|
390
413
|
|
|
391
414
|
async deleteIssue(idOrKey: string): Promise<void> {
|
|
392
|
-
await this.sql
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
415
|
+
await this.sql.begin(async (tx) => {
|
|
416
|
+
await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-jira:issues'))`
|
|
417
|
+
await tx`
|
|
418
|
+
DELETE FROM jira_activity
|
|
419
|
+
WHERE issue_id = ${idOrKey}
|
|
420
|
+
OR issue_id IN (SELECT id FROM jira_issues WHERE key = ${idOrKey})
|
|
421
|
+
`
|
|
422
|
+
await tx`DELETE FROM jira_issues WHERE id = ${idOrKey} OR key = ${idOrKey}`
|
|
423
|
+
})
|
|
398
424
|
}
|
|
399
425
|
|
|
400
426
|
async pruneIssuesMissingUpstream(projectKey: string, upstreamIssueIds: string[]): Promise<void> {
|
|
401
|
-
|
|
402
|
-
await
|
|
427
|
+
await this.sql.begin(async (tx) => {
|
|
428
|
+
await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-jira:issues'))`
|
|
429
|
+
if (upstreamIssueIds.length === 0) {
|
|
430
|
+
await tx`
|
|
431
|
+
DELETE FROM jira_activity
|
|
432
|
+
WHERE issue_id IN (SELECT id FROM jira_issues WHERE project_key = ${projectKey})
|
|
433
|
+
`
|
|
434
|
+
await tx`DELETE FROM jira_issues WHERE project_key = ${projectKey}`
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
await tx`
|
|
403
439
|
DELETE FROM jira_activity
|
|
404
|
-
WHERE issue_id IN (
|
|
440
|
+
WHERE issue_id IN (
|
|
441
|
+
SELECT id FROM jira_issues
|
|
442
|
+
WHERE project_key = ${projectKey}
|
|
443
|
+
AND NOT (id = ANY(${upstreamIssueIds}))
|
|
444
|
+
)
|
|
405
445
|
`
|
|
406
|
-
await
|
|
407
|
-
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
await this.sql`
|
|
411
|
-
DELETE FROM jira_activity
|
|
412
|
-
WHERE issue_id IN (
|
|
413
|
-
SELECT id FROM jira_issues
|
|
446
|
+
await tx`
|
|
447
|
+
DELETE FROM jira_issues
|
|
414
448
|
WHERE project_key = ${projectKey}
|
|
415
449
|
AND NOT (id = ANY(${upstreamIssueIds}))
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
await this.sql`
|
|
419
|
-
DELETE FROM jira_issues
|
|
420
|
-
WHERE project_key = ${projectKey}
|
|
421
|
-
AND NOT (id = ANY(${upstreamIssueIds}))
|
|
422
|
-
`
|
|
450
|
+
`
|
|
451
|
+
})
|
|
423
452
|
}
|
|
424
453
|
|
|
425
454
|
async getColumns(): Promise<JiraColumnRow[]> {
|
|
@@ -441,7 +470,7 @@ export class PostgresJiraCache implements JiraCachePort {
|
|
|
441
470
|
const boardColumns = []
|
|
442
471
|
for (const column of columns) {
|
|
443
472
|
const tasks = (await this.selectIssuesByStatusIds(decodeColumnStatusIds(column))).map(
|
|
444
|
-
|
|
473
|
+
jiraTaskFromRow,
|
|
445
474
|
)
|
|
446
475
|
boardColumns.push({
|
|
447
476
|
id: column.id,
|
|
@@ -463,7 +492,7 @@ export class PostgresJiraCache implements JiraCachePort {
|
|
|
463
492
|
WHERE id = ${normalized} OR key = ${normalized}
|
|
464
493
|
LIMIT 1
|
|
465
494
|
`
|
|
466
|
-
return row ?
|
|
495
|
+
return row ? jiraTaskFromRow(row) : null
|
|
467
496
|
}
|
|
468
497
|
|
|
469
498
|
async adjustIssueCommentCount(idOrKey: string, delta: number): Promise<void> {
|
|
@@ -480,13 +509,15 @@ export class PostgresJiraCache implements JiraCachePort {
|
|
|
480
509
|
SELECT status_ids FROM jira_columns WHERE id = ${params.columnId}
|
|
481
510
|
`
|
|
482
511
|
if (!columnRow) return []
|
|
483
|
-
return (await this.selectIssuesByStatusIds(decodeColumnStatusIds(columnRow))).map(
|
|
512
|
+
return (await this.selectIssuesByStatusIds(decodeColumnStatusIds(columnRow))).map(
|
|
513
|
+
jiraTaskFromRow,
|
|
514
|
+
)
|
|
484
515
|
}
|
|
485
516
|
return (
|
|
486
517
|
await this.sql<JiraIssueRow[]>`
|
|
487
518
|
SELECT * FROM jira_issues ORDER BY updated_at DESC, summary ASC
|
|
488
519
|
`
|
|
489
|
-
).map(
|
|
520
|
+
).map(jiraTaskFromRow)
|
|
490
521
|
}
|
|
491
522
|
|
|
492
523
|
async getCachedConfig(): Promise<JiraCacheConfig> {
|
|
@@ -513,13 +544,24 @@ export class PostgresJiraCache implements JiraCachePort {
|
|
|
513
544
|
}
|
|
514
545
|
|
|
515
546
|
async saveActivity(rows: JiraActivityRow[]): Promise<void> {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
547
|
+
await this.insertActivityRows(this.sql, rows)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private async insertActivityRows(sql: Exec, rows: JiraActivityRow[]): Promise<void> {
|
|
551
|
+
if (rows.length === 0) return
|
|
552
|
+
await sql`
|
|
553
|
+
INSERT INTO jira_activity (issue_id, history_id, item_field, from_value, to_value, created_at)
|
|
554
|
+
SELECT issue_id, history_id, item_field, from_value, to_value, created_at
|
|
555
|
+
FROM jsonb_to_recordset(${recordsetJson(sql, rows)}) AS data(
|
|
556
|
+
issue_id text,
|
|
557
|
+
history_id text,
|
|
558
|
+
item_field text,
|
|
559
|
+
from_value text,
|
|
560
|
+
to_value text,
|
|
561
|
+
created_at text
|
|
562
|
+
)
|
|
563
|
+
ON CONFLICT(issue_id, history_id, item_field) DO NOTHING
|
|
564
|
+
`
|
|
523
565
|
}
|
|
524
566
|
|
|
525
567
|
async getCachedActivity(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Sql } from 'postgres'
|
|
2
2
|
|
|
3
3
|
import type { WebhookRequest, WebhookResult } from '../webhooks'
|
|
4
|
-
import {
|
|
4
|
+
import { withWebhookRecording } from '../webhook-events'
|
|
5
5
|
import type { JiraClient } from './jira-client'
|
|
6
6
|
import { JiraProviderCore, type JiraProviderConfig } from './jira-core'
|
|
7
7
|
import { PostgresJiraCache } from './postgres-jira-cache'
|
|
@@ -18,24 +18,6 @@ export class PostgresJiraProvider extends JiraProviderCore {
|
|
|
18
18
|
// The shared dispatch lives in JiraProviderCore.handleWebhookCore; this wrapper
|
|
19
19
|
// only adds the audit persistence around it.
|
|
20
20
|
override async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
|
|
21
|
-
|
|
22
|
-
let result: WebhookResult
|
|
23
|
-
try {
|
|
24
|
-
result = await this.handleWebhookCore(payload)
|
|
25
|
-
} catch (err) {
|
|
26
|
-
void recordWebhookEvent(this.sql, {
|
|
27
|
-
provider: 'jira',
|
|
28
|
-
...meta,
|
|
29
|
-
status: 'error',
|
|
30
|
-
detail: { error: err instanceof Error ? err.message : String(err) },
|
|
31
|
-
})
|
|
32
|
-
throw err
|
|
33
|
-
}
|
|
34
|
-
void recordWebhookEvent(this.sql, {
|
|
35
|
-
provider: 'jira',
|
|
36
|
-
...meta,
|
|
37
|
-
status: webhookEventStatus(result),
|
|
38
|
-
})
|
|
39
|
-
return result
|
|
21
|
+
return withWebhookRecording(this.sql, 'jira', payload, () => this.handleWebhookCore(payload))
|
|
40
22
|
}
|
|
41
23
|
}
|