@andypai/agent-kanban 0.6.4 → 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.
Files changed (45) hide show
  1. package/package.json +11 -3
  2. package/src/__tests__/index.test.ts +27 -1
  3. package/src/__tests__/jira-cache.test.ts +68 -0
  4. package/src/__tests__/jira-provider-read.test.ts +130 -0
  5. package/src/__tests__/jira-wiring.test.ts +41 -0
  6. package/src/__tests__/linear-provider-comment.test.ts +81 -0
  7. package/src/__tests__/linear-provider-sync.test.ts +107 -0
  8. package/src/__tests__/linear-state-resolution.test.ts +60 -0
  9. package/src/__tests__/local-provider-core.test.ts +245 -0
  10. package/src/__tests__/postgres-jira-provider.test.ts +55 -0
  11. package/src/__tests__/postgres-linear-provider.test.ts +90 -0
  12. package/src/__tests__/postgres-local-provider.test.ts +26 -0
  13. package/src/__tests__/provider-sync-core.test.ts +150 -0
  14. package/src/__tests__/provider-team-info.test.ts +37 -0
  15. package/src/__tests__/ui-board-slice.test.ts +14 -0
  16. package/src/__tests__/use-cases.test.ts +21 -42
  17. package/src/api.ts +78 -132
  18. package/src/column-roles.ts +3 -2
  19. package/src/index.ts +135 -92
  20. package/src/mcp/core.ts +7 -8
  21. package/src/provider-runtime.ts +12 -56
  22. package/src/providers/cache-task-mappers.ts +123 -0
  23. package/src/providers/factory.ts +103 -0
  24. package/src/providers/index.ts +4 -25
  25. package/src/providers/jira-cache.ts +41 -99
  26. package/src/providers/jira-core.ts +78 -80
  27. package/src/providers/linear-cache.ts +42 -69
  28. package/src/providers/linear-core.ts +41 -55
  29. package/src/providers/local-core.ts +226 -0
  30. package/src/providers/local.ts +6 -182
  31. package/src/providers/postgres-batch.ts +10 -0
  32. package/src/providers/postgres-jira-cache.ts +255 -213
  33. package/src/providers/postgres-jira.ts +2 -20
  34. package/src/providers/postgres-linear-cache.ts +279 -183
  35. package/src/providers/postgres-linear.ts +2 -20
  36. package/src/providers/postgres-local.ts +22 -47
  37. package/src/providers/sqlite-local-store.ts +121 -0
  38. package/src/providers/sync-core.ts +91 -0
  39. package/src/providers/team-info.ts +24 -0
  40. package/src/providers/warn-once.ts +6 -0
  41. package/src/use-cases.ts +7 -131
  42. package/src/webhook-events.ts +29 -1
  43. package/ui/dist/assets/index-DcZH7fI3.js +40 -0
  44. package/ui/dist/index.html +1 -1
  45. 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 interface JiraIssueRow {
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
- for (const key of keys) {
197
- if (!Object.prototype.hasOwnProperty.call(meta, key)) continue
198
- const value = meta[key]
199
- if (value === null) {
200
- await this.deleteMeta(key)
201
- continue
202
- }
203
- if (key === 'boardId') {
204
- if (typeof value === 'number' && Number.isFinite(value))
205
- await this.setMeta(key, String(value))
206
- continue
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
- if (typeof value === 'string') await this.setMeta(key, value)
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
- const raw = await this.getMeta('team')
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 UPSERT serializes per row and never collides on the primary
250
- // key when multiple Dispatch replicas refresh concurrently, so a newly-created
251
- // status/column/priority is reflected on the next sync. The obsolete-row DELETE
252
- // (`prune`) runs ONLY on a full reconcile, mirroring how upstream-missing issues
253
- // are pruned (see pruneIssuesMissingUpstream): a delta sync's snapshot can be
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
- for (const column of columns) {
269
- await this.sql`
270
- INSERT INTO jira_columns (id, name, position, status_ids, source)
271
- VALUES (
272
- ${column.id},
273
- ${column.name},
274
- ${column.position},
275
- ${JSON.stringify(column.statusIds)},
276
- ${column.source}
277
- )
278
- ON CONFLICT(id) DO UPDATE SET
279
- name = EXCLUDED.name,
280
- position = EXCLUDED.position,
281
- status_ids = EXCLUDED.status_ids,
282
- source = EXCLUDED.source
283
- `
284
- }
285
- if (prune) {
286
- await this.sql`DELETE FROM jira_columns WHERE NOT (id = ANY(${columns.map((c) => c.id)}))`
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
- for (const user of users) {
294
- await this.sql`
295
- INSERT INTO jira_users (account_id, display_name, active, updated_at)
296
- VALUES (${user.accountId}, ${user.displayName}, ${user.active === false ? 0 : 1}, ${new Date().toISOString()})
297
- ON CONFLICT(account_id) DO UPDATE SET
298
- display_name = EXCLUDED.display_name,
299
- active = EXCLUDED.active,
300
- updated_at = EXCLUDED.updated_at
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
- for (const priority of priorities) {
310
- await this.sql`
311
- INSERT INTO jira_priorities (id, name)
312
- VALUES (${priority.id}, ${priority.name})
313
- ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name
314
- `
315
- }
316
- if (prune) {
317
- await this
318
- .sql`DELETE FROM jira_priorities WHERE NOT (id = ANY(${priorities.map((p) => p.id)}))`
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
- for (const type of types) {
327
- await this.sql`
328
- INSERT INTO jira_issue_types (id, name)
329
- VALUES (${type.id}, ${type.name})
330
- ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name
331
- `
332
- }
333
- if (prune) {
334
- await this.sql`DELETE FROM jira_issue_types WHERE NOT (id = ANY(${types.map((t) => t.id)}))`
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
- for (const issue of issues) {
361
- await tx`
362
- INSERT INTO jira_issues (
363
- id, key, summary, description_text, status_id, priority_name, issue_type_name,
364
- assignee_account_id, assignee_name, labels, comment_count, project_key, url, created_at, updated_at
365
- ) VALUES (
366
- ${issue.id}, ${issue.key}, ${issue.summary}, ${issue.descriptionText}, ${issue.statusId},
367
- ${issue.priorityName ?? ''}, ${issue.issueTypeName ?? ''}, ${issue.assigneeAccountId ?? null},
368
- ${issue.assigneeName ?? ''}, ${JSON.stringify(issue.labels ?? [])}, ${issue.commentCount ?? 0},
369
- ${issue.projectKey}, ${issue.url ?? null}, ${issue.createdAt}, ${issue.updatedAt}
370
- )
371
- ON CONFLICT(id) DO UPDATE SET
372
- key = EXCLUDED.key,
373
- summary = EXCLUDED.summary,
374
- description_text = EXCLUDED.description_text,
375
- status_id = EXCLUDED.status_id,
376
- priority_name = EXCLUDED.priority_name,
377
- issue_type_name = EXCLUDED.issue_type_name,
378
- assignee_account_id = EXCLUDED.assignee_account_id,
379
- assignee_name = EXCLUDED.assignee_name,
380
- labels = EXCLUDED.labels,
381
- comment_count = EXCLUDED.comment_count,
382
- project_key = EXCLUDED.project_key,
383
- url = EXCLUDED.url,
384
- created_at = EXCLUDED.created_at,
385
- updated_at = EXCLUDED.updated_at
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
- DELETE FROM jira_activity
394
- WHERE issue_id = ${idOrKey}
395
- OR issue_id IN (SELECT id FROM jira_issues WHERE key = ${idOrKey})
396
- `
397
- await this.sql`DELETE FROM jira_issues WHERE id = ${idOrKey} OR key = ${idOrKey}`
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
- if (upstreamIssueIds.length === 0) {
402
- await this.sql`
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 (SELECT id FROM jira_issues WHERE project_key = ${projectKey})
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 this.sql`DELETE FROM jira_issues WHERE project_key = ${projectKey}`
407
- return
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
- taskFromRow,
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 ? taskFromRow(row) : null
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(taskFromRow)
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(taskFromRow)
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
- for (const row of rows) {
517
- await this.sql`
518
- INSERT INTO jira_activity (issue_id, history_id, item_field, from_value, to_value, created_at)
519
- VALUES (${row.issue_id}, ${row.history_id}, ${row.item_field}, ${row.from_value}, ${row.to_value}, ${row.created_at})
520
- ON CONFLICT(issue_id, history_id, item_field) DO NOTHING
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 { extractWebhookMeta, recordWebhookEvent, webhookEventStatus } from '../webhook-events'
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
- const meta = extractWebhookMeta('jira', payload.rawBody)
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
  }