@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
@@ -1,6 +1,6 @@
1
1
  import type { Sql } from 'postgres'
2
2
 
3
- import type { BoardConfig, BoardView, ProviderTeamInfo, Task } from '../types'
3
+ import type { BoardConfig, BoardView, Task } from '../types'
4
4
  import { ensureWebhookEventsSchema } from '../webhook-events'
5
5
  import type { LinearCachePort } from './linear-core'
6
6
  import {
@@ -9,75 +9,13 @@ import {
9
9
  type LinearStateRow,
10
10
  type LinearSyncMeta,
11
11
  } from './linear-cache'
12
+ import { linearTaskFromRow, type LinearTaskRow } from './cache-task-mappers'
13
+ import { type Exec, recordsetJson } from './postgres-batch'
14
+ import { parseProviderTeamInfo } from './team-info'
12
15
 
13
16
  export type { LinearActivityRow, LinearStateRow, LinearSyncMeta } from './linear-cache'
14
17
 
15
- export interface LinearIssueRow {
16
- id: string
17
- identifier: string
18
- title: string
19
- description: string
20
- state_id: string
21
- state_position: number
22
- priority: number
23
- assignee_name: string
24
- project_name: string
25
- labels: string
26
- comment_count: number
27
- url: string | null
28
- created_at: string
29
- updated_at: string
30
- }
31
-
32
- function mapPriority(priority: number): Task['priority'] {
33
- switch (priority) {
34
- case 1:
35
- return 'urgent'
36
- case 2:
37
- return 'high'
38
- case 3:
39
- return 'medium'
40
- case 0:
41
- case 4:
42
- default:
43
- return 'low'
44
- }
45
- }
46
-
47
- function parseLabels(raw: string): string[] {
48
- try {
49
- const parsed: unknown = JSON.parse(raw)
50
- return Array.isArray(parsed)
51
- ? parsed.filter((value): value is string => typeof value === 'string')
52
- : []
53
- } catch {
54
- return []
55
- }
56
- }
57
-
58
- function taskFromRow(row: LinearIssueRow): Task {
59
- return {
60
- id: `linear:${row.id}`,
61
- providerId: row.id,
62
- externalRef: row.identifier,
63
- url: row.url,
64
- title: row.title,
65
- description: row.description,
66
- column_id: row.state_id,
67
- position: row.state_position,
68
- priority: mapPriority(row.priority),
69
- assignee: row.assignee_name,
70
- assignees: row.assignee_name ? [row.assignee_name] : [],
71
- labels: parseLabels(row.labels),
72
- comment_count: row.comment_count,
73
- project: row.project_name,
74
- metadata: '{}',
75
- created_at: row.created_at,
76
- updated_at: row.updated_at,
77
- version: row.updated_at,
78
- source_updated_at: row.updated_at,
79
- }
80
- }
18
+ export type LinearIssueRow = LinearTaskRow
81
19
 
82
20
  /**
83
21
  * Postgres-backed cache/repository for the Linear provider. Mirrors the role of
@@ -200,25 +138,36 @@ export class PostgresLinearCache implements LinearCachePort {
200
138
  'lastIssueUpdatedAt',
201
139
  'lastWebhookAt',
202
140
  ] as const
203
- for (const key of keys) {
204
- if (!Object.prototype.hasOwnProperty.call(meta, key)) continue
205
- const value = meta[key]
206
- if (value === null) {
207
- await this.deleteMeta(key)
208
- continue
209
- }
210
- if (key === 'team') {
211
- await this.setMeta(key, JSON.stringify(value))
212
- continue
141
+ await this.sql.begin(async (tx) => {
142
+ for (const key of keys) {
143
+ if (!Object.prototype.hasOwnProperty.call(meta, key)) continue
144
+ const value = meta[key]
145
+ if (value === null) {
146
+ await tx`DELETE FROM linear_sync_meta WHERE key = ${key}`
147
+ continue
148
+ }
149
+ if (key === 'team') {
150
+ await tx`
151
+ INSERT INTO linear_sync_meta (key, value)
152
+ VALUES (${key}, ${JSON.stringify(value)})
153
+ ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value
154
+ `
155
+ continue
156
+ }
157
+ if (typeof value === 'string') {
158
+ await tx`
159
+ INSERT INTO linear_sync_meta (key, value)
160
+ VALUES (${key}, ${value})
161
+ ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value
162
+ `
163
+ }
213
164
  }
214
- if (typeof value === 'string') await this.setMeta(key, value)
215
- }
165
+ })
216
166
  }
217
167
 
218
168
  async loadSyncMeta(): Promise<LinearSyncMeta> {
219
- const teamRaw = await this.getMeta('team')
220
169
  return {
221
- team: teamRaw ? (JSON.parse(teamRaw) as ProviderTeamInfo) : null,
170
+ team: parseProviderTeamInfo(await this.getMeta('team')),
222
171
  lastSyncAt: await this.getMeta('lastSyncAt'),
223
172
  lastFullSyncAt: await this.getMeta('lastFullSyncAt'),
224
173
  lastIssueUpdatedAt: await this.getMeta('lastIssueUpdatedAt'),
@@ -236,56 +185,110 @@ export class PostgresLinearCache implements LinearCachePort {
236
185
  }>,
237
186
  ): Promise<void> {
238
187
  const now = new Date().toISOString()
188
+ const rows = states.map((state) => ({
189
+ id: state.id,
190
+ name: state.name,
191
+ position: state.position,
192
+ color: state.color ?? null,
193
+ type: state.type ?? null,
194
+ created_at: now,
195
+ updated_at: now,
196
+ }))
239
197
  await this.sql.begin(async (tx) => {
198
+ await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-linear:states'))`
240
199
  await tx`DELETE FROM linear_states`
241
- for (const state of states) {
200
+ if (rows.length > 0) {
242
201
  await tx`
243
202
  INSERT INTO linear_states (id, name, position, color, type, created_at, updated_at)
244
- VALUES (${state.id}, ${state.name}, ${state.position}, ${state.color ?? null}, ${state.type ?? null}, ${now}, ${now})
203
+ SELECT id, name, position, color, type, created_at, updated_at
204
+ FROM jsonb_to_recordset(${recordsetJson(tx, rows)}) AS data(
205
+ id text,
206
+ name text,
207
+ position integer,
208
+ color text,
209
+ type text,
210
+ created_at text,
211
+ updated_at text
212
+ )
245
213
  `
246
214
  }
247
215
  })
248
216
  }
249
217
 
250
218
  async upsertUsers(users: Array<{ id: string; name: string; active?: boolean }>): Promise<void> {
219
+ if (users.length === 0) return
251
220
  const now = new Date().toISOString()
252
- for (const user of users) {
253
- await this.sql`
254
- INSERT INTO linear_users (id, name, active, updated_at)
255
- VALUES (${user.id}, ${user.name}, ${user.active === false ? 0 : 1}, ${now})
256
- ON CONFLICT(id) DO UPDATE SET
257
- name = EXCLUDED.name,
258
- active = EXCLUDED.active,
259
- updated_at = EXCLUDED.updated_at
260
- `
261
- }
221
+ const rows = users.map((user) => ({
222
+ id: user.id,
223
+ name: user.name,
224
+ active: user.active === false ? 0 : 1,
225
+ updated_at: now,
226
+ }))
227
+ await this.sql`
228
+ INSERT INTO linear_users (id, name, active, updated_at)
229
+ SELECT id, name, active, updated_at
230
+ FROM jsonb_to_recordset(${recordsetJson(this.sql, rows)}) AS data(
231
+ id text,
232
+ name text,
233
+ active integer,
234
+ updated_at text
235
+ )
236
+ ON CONFLICT(id) DO UPDATE SET
237
+ name = EXCLUDED.name,
238
+ active = EXCLUDED.active,
239
+ updated_at = EXCLUDED.updated_at
240
+ `
262
241
  }
263
242
 
264
243
  async upsertProjects(
265
244
  projects: Array<{ id: string; name: string; url?: string | null; state?: string | null }>,
266
245
  ): Promise<void> {
246
+ if (projects.length === 0) return
267
247
  const now = new Date().toISOString()
268
- for (const project of projects) {
269
- await this.sql`
270
- INSERT INTO linear_projects (id, name, url, state, updated_at)
271
- VALUES (${project.id}, ${project.name}, ${project.url ?? null}, ${project.state ?? null}, ${now})
272
- ON CONFLICT(id) DO UPDATE SET
273
- name = EXCLUDED.name,
274
- url = EXCLUDED.url,
275
- state = EXCLUDED.state,
276
- updated_at = EXCLUDED.updated_at
277
- `
278
- }
248
+ const rows = projects.map((project) => ({
249
+ id: project.id,
250
+ name: project.name,
251
+ url: project.url ?? null,
252
+ state: project.state ?? null,
253
+ updated_at: now,
254
+ }))
255
+ await this.sql`
256
+ INSERT INTO linear_projects (id, name, url, state, updated_at)
257
+ SELECT id, name, url, state, updated_at
258
+ FROM jsonb_to_recordset(${recordsetJson(this.sql, rows)}) AS data(
259
+ id text,
260
+ name text,
261
+ url text,
262
+ state text,
263
+ updated_at text
264
+ )
265
+ ON CONFLICT(id) DO UPDATE SET
266
+ name = EXCLUDED.name,
267
+ url = EXCLUDED.url,
268
+ state = EXCLUDED.state,
269
+ updated_at = EXCLUDED.updated_at
270
+ `
279
271
  }
280
272
 
281
273
  async saveActivity(rows: LinearActivityRow[]): Promise<void> {
282
- for (const row of rows) {
283
- await this.sql`
284
- INSERT INTO linear_activity (issue_id, history_id, item_field, from_value, to_value, created_at)
285
- VALUES (${row.issue_id}, ${row.history_id}, ${row.item_field}, ${row.from_value}, ${row.to_value}, ${row.created_at})
286
- ON CONFLICT(issue_id, history_id, item_field) DO NOTHING
287
- `
288
- }
274
+ await this.insertActivityRows(this.sql, rows)
275
+ }
276
+
277
+ private async insertActivityRows(sql: Exec, rows: LinearActivityRow[]): Promise<void> {
278
+ if (rows.length === 0) return
279
+ await sql`
280
+ INSERT INTO linear_activity (issue_id, history_id, item_field, from_value, to_value, created_at)
281
+ SELECT issue_id, history_id, item_field, from_value, to_value, created_at
282
+ FROM jsonb_to_recordset(${recordsetJson(sql, rows)}) AS data(
283
+ issue_id text,
284
+ history_id text,
285
+ item_field text,
286
+ from_value text,
287
+ to_value text,
288
+ created_at text
289
+ )
290
+ ON CONFLICT(issue_id, history_id, item_field) DO NOTHING
291
+ `
289
292
  }
290
293
 
291
294
  async upsertIssues(
@@ -309,84 +312,177 @@ export class PostgresLinearCache implements LinearCachePort {
309
312
  updatedAt: string
310
313
  }>,
311
314
  ): Promise<void> {
312
- for (const issue of issues) {
313
- const nextDescription = issue.description ?? ''
314
- const [prior] = await this.sql<{ description: string }[]>`
315
- SELECT description FROM linear_issues WHERE id = ${issue.id} LIMIT 1
316
- `
317
- if (prior && prior.description !== nextDescription) {
318
- await this.saveActivity([
319
- {
320
- issue_id: issue.id,
321
- history_id: `desc:${issue.updatedAt}`,
322
- item_field: 'description',
323
- from_value: clampActivityValue(prior.description),
324
- to_value: clampActivityValue(nextDescription),
325
- created_at: issue.updatedAt,
326
- },
327
- ])
315
+ if (issues.length === 0) return
316
+ // A sync batch can repeat an issue id (Linear's updatedAt-ordered pagination
317
+ // can return an issue on two pages); the batched ON CONFLICT DO UPDATE
318
+ // statement errors on intra-statement duplicates, so keep the last
319
+ // occurrence — matching the old row-by-row and SQLite last-wins behavior.
320
+ const mapped = issues.map((issue) => {
321
+ const hasCommentCount = issue.commentCount !== undefined && issue.commentCount !== null
322
+ return {
323
+ id: issue.id,
324
+ identifier: issue.identifier,
325
+ title: issue.title,
326
+ description: issue.description ?? '',
327
+ priority: issue.priority ?? 0,
328
+ assignee_id: issue.assigneeId ?? null,
329
+ assignee_name: issue.assigneeName ?? '',
330
+ project_id: issue.projectId ?? null,
331
+ project_name: issue.projectName ?? '',
332
+ state_id: issue.stateId,
333
+ state_name: issue.stateName,
334
+ state_position: issue.statePosition,
335
+ labels: JSON.stringify(issue.labels ?? []),
336
+ comment_count: issue.commentCount ?? 0,
337
+ comment_count_provided: hasCommentCount,
338
+ url: issue.url ?? null,
339
+ created_at: issue.createdAt,
340
+ updated_at: issue.updatedAt,
328
341
  }
342
+ })
343
+ const rows = [...new Map(mapped.map((row) => [row.id, row])).values()]
329
344
 
330
- const hasCommentCount = issue.commentCount !== undefined && issue.commentCount !== null
331
- await this.sql`
332
- INSERT INTO linear_issues (
333
- id, identifier, title, description, priority, assignee_id, assignee_name,
334
- project_id, project_name, state_id, state_name, state_position, labels, comment_count,
335
- url, created_at, updated_at
336
- ) VALUES (
337
- ${issue.id}, ${issue.identifier}, ${issue.title}, ${nextDescription}, ${issue.priority ?? 0},
338
- ${issue.assigneeId ?? null}, ${issue.assigneeName ?? ''}, ${issue.projectId ?? null},
339
- ${issue.projectName ?? ''}, ${issue.stateId}, ${issue.stateName}, ${issue.statePosition},
340
- ${JSON.stringify(issue.labels ?? [])}, ${hasCommentCount ? (issue.commentCount ?? 0) : 0},
341
- ${issue.url ?? null}, ${issue.createdAt}, ${issue.updatedAt}
342
- )
343
- ON CONFLICT(id) DO UPDATE SET
344
- identifier = EXCLUDED.identifier,
345
- title = EXCLUDED.title,
346
- description = EXCLUDED.description,
347
- priority = EXCLUDED.priority,
348
- assignee_id = EXCLUDED.assignee_id,
349
- assignee_name = EXCLUDED.assignee_name,
350
- project_id = EXCLUDED.project_id,
351
- project_name = EXCLUDED.project_name,
352
- state_id = EXCLUDED.state_id,
353
- state_name = EXCLUDED.state_name,
354
- state_position = EXCLUDED.state_position,
355
- labels = EXCLUDED.labels,
356
- comment_count = CASE
357
- WHEN ${hasCommentCount} THEN EXCLUDED.comment_count
358
- ELSE linear_issues.comment_count
359
- END,
360
- url = EXCLUDED.url,
361
- created_at = EXCLUDED.created_at,
362
- updated_at = EXCLUDED.updated_at
345
+ await this.sql.begin(async (tx) => {
346
+ await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-linear:issues'))`
347
+ const ids = rows.map((issue) => issue.id)
348
+ const priorRows = await tx<{ id: string; description: string }[]>`
349
+ SELECT id, description FROM linear_issues WHERE id = ANY(${ids})
363
350
  `
364
- }
351
+ const priorDescriptions = new Map(priorRows.map((row) => [row.id, row.description]))
352
+ await this.insertActivityRows(
353
+ tx,
354
+ rows.flatMap((issue) => {
355
+ const prior = priorDescriptions.get(issue.id)
356
+ if (prior === undefined || prior === issue.description) return []
357
+ return [
358
+ {
359
+ issue_id: issue.id,
360
+ history_id: `desc:${issue.updated_at}`,
361
+ item_field: 'description',
362
+ from_value: clampActivityValue(prior),
363
+ to_value: clampActivityValue(issue.description),
364
+ created_at: issue.updated_at,
365
+ },
366
+ ]
367
+ }),
368
+ )
369
+
370
+ await this.upsertIssueRows(
371
+ tx,
372
+ rows.filter((issue) => issue.comment_count_provided),
373
+ true,
374
+ )
375
+ await this.upsertIssueRows(
376
+ tx,
377
+ rows.filter((issue) => !issue.comment_count_provided),
378
+ false,
379
+ )
380
+ })
365
381
  }
366
382
 
367
- async deleteIssue(idOrIdentifier: string): Promise<void> {
368
- await this.sql`
369
- DELETE FROM linear_activity
370
- WHERE issue_id = ${idOrIdentifier}
371
- OR issue_id IN (SELECT id FROM linear_issues WHERE identifier = ${idOrIdentifier})
383
+ private async upsertIssueRows(
384
+ sql: Exec,
385
+ rows: Array<{
386
+ id: string
387
+ identifier: string
388
+ title: string
389
+ description: string
390
+ priority: number
391
+ assignee_id: string | null
392
+ assignee_name: string
393
+ project_id: string | null
394
+ project_name: string
395
+ state_id: string
396
+ state_name: string
397
+ state_position: number
398
+ labels: string
399
+ comment_count: number
400
+ url: string | null
401
+ created_at: string
402
+ updated_at: string
403
+ }>,
404
+ updateCommentCount: boolean,
405
+ ): Promise<void> {
406
+ if (rows.length === 0) return
407
+ const commentCountAssignment = updateCommentCount
408
+ ? sql`, comment_count = EXCLUDED.comment_count`
409
+ : sql``
410
+ await sql`
411
+ INSERT INTO linear_issues (
412
+ id, identifier, title, description, priority, assignee_id, assignee_name,
413
+ project_id, project_name, state_id, state_name, state_position, labels, comment_count,
414
+ url, created_at, updated_at
415
+ )
416
+ SELECT
417
+ id, identifier, title, description, priority, assignee_id, assignee_name,
418
+ project_id, project_name, state_id, state_name, state_position, labels, comment_count,
419
+ url, created_at, updated_at
420
+ FROM jsonb_to_recordset(${recordsetJson(sql, rows)}) AS data(
421
+ id text,
422
+ identifier text,
423
+ title text,
424
+ description text,
425
+ priority integer,
426
+ assignee_id text,
427
+ assignee_name text,
428
+ project_id text,
429
+ project_name text,
430
+ state_id text,
431
+ state_name text,
432
+ state_position integer,
433
+ labels text,
434
+ comment_count integer,
435
+ url text,
436
+ created_at text,
437
+ updated_at text
438
+ )
439
+ ON CONFLICT(id) DO UPDATE SET
440
+ identifier = EXCLUDED.identifier,
441
+ title = EXCLUDED.title,
442
+ description = EXCLUDED.description,
443
+ priority = EXCLUDED.priority,
444
+ assignee_id = EXCLUDED.assignee_id,
445
+ assignee_name = EXCLUDED.assignee_name,
446
+ project_id = EXCLUDED.project_id,
447
+ project_name = EXCLUDED.project_name,
448
+ state_id = EXCLUDED.state_id,
449
+ state_name = EXCLUDED.state_name,
450
+ state_position = EXCLUDED.state_position,
451
+ labels = EXCLUDED.labels${commentCountAssignment},
452
+ url = EXCLUDED.url,
453
+ created_at = EXCLUDED.created_at,
454
+ updated_at = EXCLUDED.updated_at
372
455
  `
373
- await this
374
- .sql`DELETE FROM linear_issues WHERE id = ${idOrIdentifier} OR identifier = ${idOrIdentifier}`
456
+ }
457
+
458
+ async deleteIssue(idOrIdentifier: string): Promise<void> {
459
+ await this.sql.begin(async (tx) => {
460
+ await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-linear:issues'))`
461
+ await tx`
462
+ DELETE FROM linear_activity
463
+ WHERE issue_id = ${idOrIdentifier}
464
+ OR issue_id IN (SELECT id FROM linear_issues WHERE identifier = ${idOrIdentifier})
465
+ `
466
+ await tx`DELETE FROM linear_issues WHERE id = ${idOrIdentifier} OR identifier = ${idOrIdentifier}`
467
+ })
375
468
  }
376
469
 
377
470
  async pruneIssues(liveIssueIds: string[]): Promise<void> {
378
- if (liveIssueIds.length === 0) {
379
- await this.sql`DELETE FROM linear_activity`
380
- await this.sql`DELETE FROM linear_issues`
381
- return
382
- }
383
- await this.sql`
384
- DELETE FROM linear_activity
385
- WHERE issue_id IN (
386
- SELECT id FROM linear_issues WHERE NOT (id = ANY(${liveIssueIds}))
387
- )
388
- `
389
- await this.sql`DELETE FROM linear_issues WHERE NOT (id = ANY(${liveIssueIds}))`
471
+ await this.sql.begin(async (tx) => {
472
+ await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-linear:issues'))`
473
+ if (liveIssueIds.length === 0) {
474
+ await tx`DELETE FROM linear_activity`
475
+ await tx`DELETE FROM linear_issues`
476
+ return
477
+ }
478
+ await tx`
479
+ DELETE FROM linear_activity
480
+ WHERE issue_id IN (
481
+ SELECT id FROM linear_issues WHERE NOT (id = ANY(${liveIssueIds}))
482
+ )
483
+ `
484
+ await tx`DELETE FROM linear_issues WHERE NOT (id = ANY(${liveIssueIds}))`
485
+ })
390
486
  }
391
487
 
392
488
  async adjustIssueCommentCount(idOrIdentifier: string, delta: number): Promise<void> {
@@ -411,7 +507,7 @@ export class PostgresLinearCache implements LinearCachePort {
411
507
  WHERE state_id = ${column.id}
412
508
  ORDER BY updated_at DESC, title ASC
413
509
  `
414
- ).map(taskFromRow)
510
+ ).map(linearTaskFromRow)
415
511
  boardColumns.push({ ...column, tasks })
416
512
  }
417
513
  return { columns: boardColumns }
@@ -424,7 +520,7 @@ export class PostgresLinearCache implements LinearCachePort {
424
520
  WHERE id = ${normalized} OR identifier = ${normalized}
425
521
  LIMIT 1
426
522
  `
427
- return row ? taskFromRow(row) : null
523
+ return row ? linearTaskFromRow(row) : null
428
524
  }
429
525
 
430
526
  async getCachedTasks(): Promise<Task[]> {
@@ -432,7 +528,7 @@ export class PostgresLinearCache implements LinearCachePort {
432
528
  await this.sql<LinearIssueRow[]>`
433
529
  SELECT * FROM linear_issues ORDER BY updated_at DESC, title ASC
434
530
  `
435
- ).map(taskFromRow)
531
+ ).map(linearTaskFromRow)
436
532
  }
437
533
 
438
534
  async getCachedConfig(): Promise<BoardConfig> {
@@ -2,7 +2,7 @@ import type { Sql } from 'postgres'
2
2
 
3
3
  import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
4
4
  import type { WebhookRequest, WebhookResult } from '../webhooks'
5
- import { extractWebhookMeta, recordWebhookEvent, webhookEventStatus } from '../webhook-events'
5
+ import { withWebhookRecording } from '../webhook-events'
6
6
  import { LinearClient } from './linear-client'
7
7
  import { LinearProviderCore } from './linear-core'
8
8
  import { PostgresLinearCache } from './postgres-linear-cache'
@@ -30,24 +30,6 @@ export class PostgresLinearProvider extends LinearProviderCore {
30
30
  }
31
31
 
32
32
  override async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
33
- const meta = extractWebhookMeta('linear', payload.rawBody)
34
- let result: WebhookResult
35
- try {
36
- result = await this.handleWebhookCore(payload)
37
- } catch (err) {
38
- void recordWebhookEvent(this.sql, {
39
- provider: 'linear',
40
- ...meta,
41
- status: 'error',
42
- detail: { error: err instanceof Error ? err.message : String(err) },
43
- })
44
- throw err
45
- }
46
- void recordWebhookEvent(this.sql, {
47
- provider: 'linear',
48
- ...meta,
49
- status: webhookEventStatus(result),
50
- })
51
- return result
33
+ return withWebhookRecording(this.sql, 'linear', payload, () => this.handleWebhookCore(payload))
52
34
  }
53
35
  }