@andypai/agent-kanban 0.5.1 → 0.6.1

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 (52) hide show
  1. package/README.md +42 -19
  2. package/package.json +3 -2
  3. package/src/__tests__/activity.test.ts +12 -0
  4. package/src/__tests__/api.test.ts +4 -2
  5. package/src/__tests__/board-utils.test.ts +16 -3
  6. package/src/__tests__/column-roles.test.ts +34 -0
  7. package/src/__tests__/commands/board.test.ts +7 -0
  8. package/src/__tests__/conflict.test.ts +11 -1
  9. package/src/__tests__/db.test.ts +70 -0
  10. package/src/__tests__/index.test.ts +55 -0
  11. package/src/__tests__/jira-cache.test.ts +56 -0
  12. package/src/__tests__/jira-client.test.ts +98 -1
  13. package/src/__tests__/jira-jql.test.ts +51 -0
  14. package/src/__tests__/jira-provider-mutations.test.ts +84 -59
  15. package/src/__tests__/jira-provider-read.test.ts +227 -20
  16. package/src/__tests__/mcp-server.test.ts +2 -2
  17. package/src/__tests__/metrics.test.ts +37 -1
  18. package/src/__tests__/postgres-jira-provider.test.ts +155 -0
  19. package/src/__tests__/postgres-local-provider.test.ts +90 -1
  20. package/src/__tests__/server.test.ts +126 -0
  21. package/src/__tests__/use-cases.test.ts +77 -0
  22. package/src/__tests__/webhooks.test.ts +75 -22
  23. package/src/api.ts +58 -36
  24. package/src/column-roles.ts +52 -0
  25. package/src/commands/board.ts +4 -4
  26. package/src/db.ts +145 -114
  27. package/src/errors.ts +1 -0
  28. package/src/index.ts +84 -33
  29. package/src/mcp/core.ts +8 -7
  30. package/src/metrics.ts +48 -23
  31. package/src/provider-runtime.ts +9 -2
  32. package/src/providers/index.ts +4 -1
  33. package/src/providers/jira-cache.ts +23 -6
  34. package/src/providers/jira-client.ts +70 -4
  35. package/src/providers/jira-core.ts +921 -0
  36. package/src/providers/jira-jql.ts +48 -0
  37. package/src/providers/jira.ts +111 -677
  38. package/src/providers/linear-cache.ts +5 -3
  39. package/src/providers/linear-core.ts +688 -0
  40. package/src/providers/linear.ts +70 -554
  41. package/src/providers/postgres-jira-cache.ts +597 -0
  42. package/src/providers/postgres-jira.ts +15 -1188
  43. package/src/providers/postgres-linear-cache.ts +500 -0
  44. package/src/providers/postgres-linear.ts +22 -1088
  45. package/src/providers/postgres-local.ts +181 -74
  46. package/src/providers/types.ts +71 -2
  47. package/src/server.ts +118 -32
  48. package/src/use-cases.ts +139 -0
  49. package/src/webhooks.ts +26 -0
  50. package/ui/dist/assets/index-65LcV0R7.js +40 -0
  51. package/ui/dist/index.html +1 -1
  52. package/ui/dist/assets/index-CFhtfqCn.js +0 -40
@@ -1,1135 +1,27 @@
1
1
  import type { Sql } from 'postgres'
2
2
 
3
- import { ErrorCode, KanbanError } from '../errors'
4
- import type {
5
- ActivityEntry,
6
- BoardBootstrap,
7
- BoardConfig,
8
- BoardMetrics,
9
- BoardView,
10
- Column,
11
- Priority,
12
- ProviderTeamInfo,
13
- Task,
14
- TaskComment,
15
- } from '../types'
16
- import { JIRA_CAPABILITIES } from './capabilities'
17
- import {
18
- decodeColumnStatusIds,
19
- jiraBoardColumnRows,
20
- resolveJiraColumnId,
21
- type JiraActivityRow,
22
- type JiraColumnRow,
23
- } from './jira-cache'
24
- import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
25
- import { JiraClient, normalizeJiraLabels, type JiraComment, type JiraIssue } from './jira-client'
26
- import type { JiraProviderConfig } from './jira'
27
- import { providerUpstreamError, unsupportedOperation } from './errors'
28
- import type {
29
- CreateTaskInput,
30
- KanbanProvider,
31
- ProviderContext,
32
- ProviderSyncStatus,
33
- TaskListFilters,
34
- UpdateTaskInput,
35
- } from './types'
36
- import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
37
- import {
38
- headerLower,
39
- verifySha256HmacSignatureHeader,
40
- type WebhookRequest,
41
- type WebhookResult,
42
- } from '../webhooks'
43
- import {
44
- ensureWebhookEventsSchema,
45
- extractWebhookMeta,
46
- recordWebhookEvent,
47
- webhookEventStatus,
48
- } from '../webhook-events'
3
+ import type { WebhookRequest, WebhookResult } from '../webhooks'
4
+ import { extractWebhookMeta, recordWebhookEvent, webhookEventStatus } from '../webhook-events'
5
+ import type { JiraClient } from './jira-client'
6
+ import { JiraProviderCore, type JiraProviderConfig } from './jira-core'
7
+ import { PostgresJiraCache } from './postgres-jira-cache'
49
8
 
50
- const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
9
+ export class PostgresJiraProvider extends JiraProviderCore {
10
+ private readonly sql: Sql
51
11
 
52
- function shouldRunFullReconcile(lastFullSyncAt: string | null, now: number): boolean {
53
- if (!lastFullSyncAt) return true
54
- const lastFullSyncAtMs = Date.parse(lastFullSyncAt)
55
- if (!Number.isFinite(lastFullSyncAtMs)) return true
56
- return now - lastFullSyncAtMs >= FULL_RECONCILE_INTERVAL_MS
57
- }
58
-
59
- const CANONICAL_TO_JIRA_DEFAULT: Record<Priority, string> = {
60
- urgent: 'Highest',
61
- high: 'High',
62
- medium: 'Medium',
63
- low: 'Low',
64
- }
65
-
66
- interface JiraIssueRow {
67
- id: string
68
- key: string
69
- summary: string
70
- description_text: string
71
- status_id: string
72
- priority_name: string
73
- issue_type_name: string
74
- assignee_account_id: string | null
75
- assignee_name: string
76
- labels: string
77
- comment_count: number
78
- project_key: string
79
- url: string | null
80
- created_at: string
81
- updated_at: string
82
- }
83
-
84
- interface JiraSyncMeta {
85
- projectKey: string | null
86
- boardId: number | null
87
- lastSyncAt: string | null
88
- lastIssueUpdatedAt: string | null
89
- lastFullSyncAt: string | null
90
- lastWebhookAt: string | null
91
- }
92
-
93
- interface JiraCacheConfig {
94
- projectKey: string | null
95
- users: Array<{ accountId: string; displayName: string }>
96
- priorities: Array<{ id: string; name: string }>
97
- issueTypes: Array<{ id: string; name: string }>
98
- }
99
-
100
- function mapPriorityNameToCanonical(name: string): Task['priority'] {
101
- switch (name.trim().toLowerCase()) {
102
- case 'highest':
103
- return 'urgent'
104
- case 'high':
105
- return 'high'
106
- case 'medium':
107
- return 'medium'
108
- default:
109
- return 'low'
110
- }
111
- }
112
-
113
- function parseLabels(raw: string): string[] {
114
- try {
115
- const parsed: unknown = JSON.parse(raw)
116
- return Array.isArray(parsed)
117
- ? parsed.filter((value): value is string => typeof value === 'string')
118
- : []
119
- } catch {
120
- return []
121
- }
122
- }
123
-
124
- function taskFromRow(row: JiraIssueRow): Task {
125
- return {
126
- id: `jira:${row.id}`,
127
- providerId: row.id,
128
- externalRef: row.key,
129
- url: row.url,
130
- title: row.summary,
131
- description: row.description_text,
132
- column_id: row.status_id,
133
- position: 0,
134
- priority: mapPriorityNameToCanonical(row.priority_name),
135
- assignee: row.assignee_name,
136
- assignees: row.assignee_name ? [row.assignee_name] : [],
137
- labels: parseLabels(row.labels),
138
- comment_count: row.comment_count,
139
- project: row.project_key,
140
- metadata: '{}',
141
- created_at: row.created_at,
142
- updated_at: row.updated_at,
143
- version: row.updated_at,
144
- source_updated_at: row.updated_at,
145
- }
146
- }
147
-
148
- export class PostgresJiraProvider implements KanbanProvider {
149
- readonly type = 'jira' as const
150
- private readonly ready: Promise<void>
151
- private readonly client: JiraClient
152
- private readonly pollingSyncIntervalMs: number
153
-
154
- constructor(
155
- private readonly sql: Sql,
156
- private readonly config: JiraProviderConfig,
157
- client?: JiraClient,
158
- ) {
159
- this.ready = this.ensureSchema()
160
- this.pollingSyncIntervalMs = config.pollingSyncIntervalMs ?? DEFAULT_POLLING_SYNC_INTERVAL_MS
161
- this.client =
162
- client ??
163
- new JiraClient({
164
- baseUrl: config.baseUrl,
165
- email: config.email,
166
- apiToken: config.apiToken,
167
- })
168
- }
169
-
170
- async initialize(): Promise<void> {
171
- await this.ready
172
- }
173
-
174
- private async ensureSchema(): Promise<void> {
175
- await this.sql`
176
- CREATE TABLE IF NOT EXISTS jira_sync_meta (
177
- key TEXT PRIMARY KEY,
178
- value TEXT NOT NULL
179
- )
180
- `
181
- await this.sql`
182
- CREATE TABLE IF NOT EXISTS jira_columns (
183
- id TEXT PRIMARY KEY,
184
- name TEXT NOT NULL,
185
- position INTEGER NOT NULL,
186
- status_ids TEXT NOT NULL,
187
- source TEXT NOT NULL CHECK(source IN ('board','status'))
188
- )
189
- `
190
- await this.sql`
191
- CREATE TABLE IF NOT EXISTS jira_users (
192
- account_id TEXT PRIMARY KEY,
193
- display_name TEXT NOT NULL,
194
- active INTEGER NOT NULL DEFAULT 1,
195
- updated_at TEXT NOT NULL
196
- )
197
- `
198
- await this.sql`
199
- CREATE TABLE IF NOT EXISTS jira_priorities (
200
- id TEXT PRIMARY KEY,
201
- name TEXT NOT NULL
202
- )
203
- `
204
- await this.sql`
205
- CREATE TABLE IF NOT EXISTS jira_issue_types (
206
- id TEXT PRIMARY KEY,
207
- name TEXT NOT NULL
208
- )
209
- `
210
- await this.sql`
211
- CREATE TABLE IF NOT EXISTS jira_activity (
212
- issue_id TEXT NOT NULL,
213
- history_id TEXT NOT NULL,
214
- item_field TEXT NOT NULL,
215
- from_value TEXT,
216
- to_value TEXT,
217
- created_at TEXT NOT NULL,
218
- PRIMARY KEY (issue_id, history_id, item_field)
219
- )
220
- `
221
- await this.sql`
222
- CREATE TABLE IF NOT EXISTS jira_issues (
223
- id TEXT PRIMARY KEY,
224
- key TEXT NOT NULL UNIQUE,
225
- summary TEXT NOT NULL,
226
- description_text TEXT NOT NULL DEFAULT '',
227
- status_id TEXT NOT NULL,
228
- priority_name TEXT NOT NULL DEFAULT '',
229
- issue_type_name TEXT NOT NULL DEFAULT '',
230
- assignee_account_id TEXT,
231
- assignee_name TEXT NOT NULL DEFAULT '',
232
- labels TEXT NOT NULL DEFAULT '[]',
233
- comment_count INTEGER NOT NULL DEFAULT 0,
234
- project_key TEXT NOT NULL,
235
- url TEXT,
236
- created_at TEXT NOT NULL,
237
- updated_at TEXT NOT NULL
238
- )
239
- `
240
- await this.sql`CREATE INDEX IF NOT EXISTS idx_jira_issues_status_id ON jira_issues(status_id)`
241
- await this.sql`CREATE INDEX IF NOT EXISTS idx_jira_issues_updated_at ON jira_issues(updated_at)`
242
- await this.sql`
243
- CREATE INDEX IF NOT EXISTS jira_activity_created_at_idx ON jira_activity(created_at DESC)
244
- `
245
- await ensureWebhookEventsSchema(this.sql)
246
- }
247
-
248
- private async setMeta(key: string, value: string): Promise<void> {
249
- await this.sql`
250
- INSERT INTO jira_sync_meta (key, value)
251
- VALUES (${key}, ${value})
252
- ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value
253
- `
254
- }
255
-
256
- private async deleteMeta(key: string): Promise<void> {
257
- await this.sql`DELETE FROM jira_sync_meta WHERE key = ${key}`
258
- }
259
-
260
- private async getMeta(key: string): Promise<string | null> {
261
- const [row] = await this.sql<{ value: string }[]>`
262
- SELECT value FROM jira_sync_meta WHERE key = ${key}
263
- `
264
- return row?.value ?? null
265
- }
266
-
267
- private async saveSyncMeta(meta: Partial<JiraSyncMeta>): Promise<void> {
268
- const keys = [
269
- 'projectKey',
270
- 'boardId',
271
- 'lastSyncAt',
272
- 'lastIssueUpdatedAt',
273
- 'lastFullSyncAt',
274
- 'lastWebhookAt',
275
- ] as const
276
- for (const key of keys) {
277
- if (!Object.prototype.hasOwnProperty.call(meta, key)) continue
278
- const value = meta[key]
279
- if (value === null) {
280
- await this.deleteMeta(key)
281
- continue
282
- }
283
- if (key === 'boardId') {
284
- if (typeof value === 'number' && Number.isFinite(value))
285
- await this.setMeta(key, String(value))
286
- continue
287
- }
288
- if (typeof value === 'string') await this.setMeta(key, value)
289
- }
290
- }
291
-
292
- private async loadSyncMeta(): Promise<JiraSyncMeta> {
293
- const boardIdRaw = await this.getMeta('boardId')
294
- const boardId = boardIdRaw === null ? null : Number.parseInt(boardIdRaw, 10)
295
- return {
296
- projectKey: await this.getMeta('projectKey'),
297
- boardId: boardId === null || Number.isNaN(boardId) ? null : boardId,
298
- lastSyncAt: await this.getMeta('lastSyncAt'),
299
- lastIssueUpdatedAt: await this.getMeta('lastIssueUpdatedAt'),
300
- lastFullSyncAt: await this.getMeta('lastFullSyncAt'),
301
- lastWebhookAt: await this.getMeta('lastWebhookAt'),
302
- }
303
- }
304
-
305
- private async saveTeamInfo(team: ProviderTeamInfo | null): Promise<void> {
306
- if (team === null) {
307
- await this.deleteMeta('team')
308
- return
309
- }
310
- await this.setMeta('team', JSON.stringify(team))
311
- }
312
-
313
- private async loadTeamInfo(): Promise<ProviderTeamInfo | null> {
314
- const raw = await this.getMeta('team')
315
- if (raw === null) return null
316
- try {
317
- const parsed = JSON.parse(raw) as ProviderTeamInfo
318
- return typeof parsed.id === 'string' &&
319
- typeof parsed.key === 'string' &&
320
- typeof parsed.name === 'string'
321
- ? parsed
322
- : null
323
- } catch {
324
- return null
325
- }
326
- }
327
-
328
- private async replaceColumns(
329
- columns: Array<{
330
- id: string
331
- name: string
332
- position: number
333
- statusIds: string[]
334
- source: 'board' | 'status'
335
- }>,
336
- ): Promise<void> {
337
- await this.sql.begin(async (tx) => {
338
- await tx`DELETE FROM jira_columns`
339
- for (const column of columns) {
340
- await tx`
341
- INSERT INTO jira_columns (id, name, position, status_ids, source)
342
- VALUES (
343
- ${column.id},
344
- ${column.name},
345
- ${column.position},
346
- ${JSON.stringify(column.statusIds)},
347
- ${column.source}
348
- )
349
- `
350
- }
351
- })
352
- }
353
-
354
- private async upsertUsers(
355
- users: Array<{ accountId: string; displayName: string; active?: boolean }>,
356
- ): Promise<void> {
357
- for (const user of users) {
358
- await this.sql`
359
- INSERT INTO jira_users (account_id, display_name, active, updated_at)
360
- VALUES (${user.accountId}, ${user.displayName}, ${user.active === false ? 0 : 1}, ${new Date().toISOString()})
361
- ON CONFLICT(account_id) DO UPDATE SET
362
- display_name = EXCLUDED.display_name,
363
- active = EXCLUDED.active,
364
- updated_at = EXCLUDED.updated_at
365
- `
366
- }
367
- }
368
-
369
- private async replacePriorities(priorities: Array<{ id: string; name: string }>): Promise<void> {
370
- await this.sql.begin(async (tx) => {
371
- await tx`DELETE FROM jira_priorities`
372
- for (const priority of priorities) {
373
- await tx`
374
- INSERT INTO jira_priorities (id, name)
375
- VALUES (${priority.id}, ${priority.name})
376
- `
377
- }
378
- })
379
- }
380
-
381
- private async replaceIssueTypes(types: Array<{ id: string; name: string }>): Promise<void> {
382
- await this.sql.begin(async (tx) => {
383
- await tx`DELETE FROM jira_issue_types`
384
- for (const type of types) {
385
- await tx`
386
- INSERT INTO jira_issue_types (id, name)
387
- VALUES (${type.id}, ${type.name})
388
- `
389
- }
390
- })
391
- }
392
-
393
- private async upsertIssues(
394
- issues: Array<{
395
- id: string
396
- key: string
397
- summary: string
398
- descriptionText: string
399
- statusId: string
400
- priorityName?: string | null
401
- issueTypeName?: string | null
402
- assigneeAccountId?: string | null
403
- assigneeName?: string | null
404
- labels?: string[] | null
405
- commentCount?: number | null
406
- projectKey: string
407
- url?: string | null
408
- createdAt: string
409
- updatedAt: string
410
- }>,
411
- ): Promise<void> {
412
- for (const issue of issues) {
413
- await this.sql`
414
- INSERT INTO jira_issues (
415
- id, key, summary, description_text, status_id, priority_name, issue_type_name,
416
- assignee_account_id, assignee_name, labels, comment_count, project_key, url, created_at, updated_at
417
- ) VALUES (
418
- ${issue.id}, ${issue.key}, ${issue.summary}, ${issue.descriptionText}, ${issue.statusId},
419
- ${issue.priorityName ?? ''}, ${issue.issueTypeName ?? ''}, ${issue.assigneeAccountId ?? null},
420
- ${issue.assigneeName ?? ''}, ${JSON.stringify(issue.labels ?? [])}, ${issue.commentCount ?? 0},
421
- ${issue.projectKey}, ${issue.url ?? null}, ${issue.createdAt}, ${issue.updatedAt}
422
- )
423
- ON CONFLICT(id) DO UPDATE SET
424
- key = EXCLUDED.key,
425
- summary = EXCLUDED.summary,
426
- description_text = EXCLUDED.description_text,
427
- status_id = EXCLUDED.status_id,
428
- priority_name = EXCLUDED.priority_name,
429
- issue_type_name = EXCLUDED.issue_type_name,
430
- assignee_account_id = EXCLUDED.assignee_account_id,
431
- assignee_name = EXCLUDED.assignee_name,
432
- labels = EXCLUDED.labels,
433
- comment_count = EXCLUDED.comment_count,
434
- project_key = EXCLUDED.project_key,
435
- url = EXCLUDED.url,
436
- created_at = EXCLUDED.created_at,
437
- updated_at = EXCLUDED.updated_at
438
- `
439
- }
440
- }
441
-
442
- private async deleteIssue(idOrKey: string): Promise<void> {
443
- await this.sql`
444
- DELETE FROM jira_activity
445
- WHERE issue_id = ${idOrKey}
446
- OR issue_id IN (SELECT id FROM jira_issues WHERE key = ${idOrKey})
447
- `
448
- await this.sql`DELETE FROM jira_issues WHERE id = ${idOrKey} OR key = ${idOrKey}`
449
- }
450
-
451
- private async pruneIssuesMissingUpstream(
452
- projectKey: string,
453
- upstreamIssueIds: string[],
454
- ): Promise<void> {
455
- if (upstreamIssueIds.length === 0) {
456
- await this.sql`
457
- DELETE FROM jira_activity
458
- WHERE issue_id IN (SELECT id FROM jira_issues WHERE project_key = ${projectKey})
459
- `
460
- await this.sql`DELETE FROM jira_issues WHERE project_key = ${projectKey}`
461
- return
462
- }
463
-
464
- await this.sql`
465
- DELETE FROM jira_activity
466
- WHERE issue_id IN (
467
- SELECT id FROM jira_issues
468
- WHERE project_key = ${projectKey}
469
- AND NOT (id = ANY(${upstreamIssueIds}))
470
- )
471
- `
472
- await this.sql`
473
- DELETE FROM jira_issues
474
- WHERE project_key = ${projectKey}
475
- AND NOT (id = ANY(${upstreamIssueIds}))
476
- `
477
- }
478
-
479
- private async getColumns(): Promise<JiraColumnRow[]> {
480
- await this.ready
481
- return this.sql<JiraColumnRow[]>`SELECT * FROM jira_columns ORDER BY position, name`
482
- }
483
-
484
- private async selectIssuesByStatusIds(statusIds: string[]): Promise<JiraIssueRow[]> {
485
- if (statusIds.length === 0) return []
486
- return this.sql<JiraIssueRow[]>`
487
- SELECT * FROM jira_issues
488
- WHERE status_id = ANY(${statusIds})
489
- ORDER BY updated_at DESC, summary ASC
490
- `
491
- }
492
-
493
- private async getCachedBoard(): Promise<BoardView> {
494
- const columns = await this.getColumns()
495
- const boardColumns = []
496
- for (const column of columns) {
497
- const tasks = (await this.selectIssuesByStatusIds(decodeColumnStatusIds(column))).map(
498
- taskFromRow,
499
- )
500
- boardColumns.push({
501
- id: column.id,
502
- name: column.name,
503
- position: column.position,
504
- color: null,
505
- created_at: '',
506
- updated_at: '',
507
- tasks,
508
- })
509
- }
510
- return { columns: boardColumns }
511
- }
512
-
513
- private async getCachedTask(lookup: string): Promise<Task | null> {
514
- const normalized = lookup.startsWith('jira:') ? lookup.slice('jira:'.length) : lookup
515
- const [row] = await this.sql<JiraIssueRow[]>`
516
- SELECT * FROM jira_issues
517
- WHERE id = ${normalized} OR key = ${normalized}
518
- LIMIT 1
519
- `
520
- return row ? taskFromRow(row) : null
521
- }
522
-
523
- private async adjustIssueCommentCount(idOrKey: string, delta: number): Promise<void> {
524
- await this.sql`
525
- UPDATE jira_issues
526
- SET comment_count = GREATEST(0, comment_count + ${delta})
527
- WHERE id = ${idOrKey} OR key = ${idOrKey}
528
- `
529
- }
530
-
531
- private async getCachedTasks(params?: { columnId?: string }): Promise<Task[]> {
532
- if (params?.columnId !== undefined) {
533
- const [columnRow] = await this.sql<Pick<JiraColumnRow, 'status_ids'>[]>`
534
- SELECT status_ids FROM jira_columns WHERE id = ${params.columnId}
535
- `
536
- if (!columnRow) return []
537
- return (await this.selectIssuesByStatusIds(decodeColumnStatusIds(columnRow))).map(taskFromRow)
538
- }
539
- return (
540
- await this.sql<JiraIssueRow[]>`
541
- SELECT * FROM jira_issues ORDER BY updated_at DESC, summary ASC
542
- `
543
- ).map(taskFromRow)
544
- }
545
-
546
- private async getCachedConfig(): Promise<JiraCacheConfig> {
547
- const users = (
548
- await this.sql<{ account_id: string; display_name: string }[]>`
549
- SELECT account_id, display_name
550
- FROM jira_users
551
- WHERE active = 1
552
- ORDER BY display_name
553
- `
554
- ).map((row) => ({ accountId: row.account_id, displayName: row.display_name }))
555
- const priorities = await this.sql<Array<{ id: string; name: string }>>`
556
- SELECT id, name FROM jira_priorities ORDER BY name
557
- `
558
- const issueTypes = await this.sql<Array<{ id: string; name: string }>>`
559
- SELECT id, name FROM jira_issue_types ORDER BY name
560
- `
561
- return {
562
- projectKey: await this.getMeta('projectKey'),
563
- users,
564
- priorities,
565
- issueTypes,
566
- }
567
- }
568
-
569
- private async saveActivity(rows: JiraActivityRow[]): Promise<void> {
570
- for (const row of rows) {
571
- await this.sql`
572
- INSERT INTO jira_activity (issue_id, history_id, item_field, from_value, to_value, created_at)
573
- VALUES (${row.issue_id}, ${row.history_id}, ${row.item_field}, ${row.from_value}, ${row.to_value}, ${row.created_at})
574
- ON CONFLICT(issue_id, history_id, item_field) DO NOTHING
575
- `
576
- }
577
- }
578
-
579
- private async getCachedActivity(
580
- params: { issueId?: string; limit?: number } = {},
581
- ): Promise<JiraActivityRow[]> {
582
- const limit = params.limit ?? 100
583
- if (params.issueId) {
584
- return this.sql<JiraActivityRow[]>`
585
- SELECT issue_id, history_id, item_field, from_value, to_value, created_at
586
- FROM jira_activity
587
- WHERE issue_id = ${params.issueId}
588
- ORDER BY created_at DESC
589
- LIMIT ${limit}
590
- `
591
- }
592
- return this.sql<JiraActivityRow[]>`
593
- SELECT issue_id, history_id, item_field, from_value, to_value, created_at
594
- FROM jira_activity
595
- ORDER BY created_at DESC
596
- LIMIT ${limit}
597
- `
598
- }
599
-
600
- private async sync(force = false): Promise<void> {
601
- await this.ready
602
- const meta = await this.loadSyncMeta()
603
- const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
604
- const now = Date.now()
605
- if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
606
- const fullReconcile = force || shouldRunFullReconcile(meta.lastFullSyncAt, now)
607
-
608
- const project = await this.client.getProject(this.config.projectKey)
609
- await this.saveTeamInfo({ id: project.id, key: project.key, name: project.name })
610
-
611
- if (this.config.boardId !== undefined) {
612
- const boardCfg = await this.client.getBoardColumns(this.config.boardId)
613
- const boardId = this.config.boardId
614
- await this.replaceColumns(jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns))
615
- } else {
616
- const statusCats = await this.client.getProjectStatuses(project.key)
617
- const seen = new Set<string>()
618
- const uniqueStatuses: Array<{ id: string; name: string }> = []
619
- for (const category of statusCats) {
620
- for (const status of category.statuses) {
621
- if (seen.has(status.id)) continue
622
- seen.add(status.id)
623
- uniqueStatuses.push({ id: status.id, name: status.name })
624
- }
625
- }
626
- await this.replaceColumns(
627
- uniqueStatuses.map((status, index) => ({
628
- id: `status:${status.id}`,
629
- name: status.name,
630
- position: index,
631
- statusIds: [status.id],
632
- source: 'status' as const,
633
- })),
634
- )
635
- }
636
-
637
- const [users, priorities, issueTypes] = await Promise.all([
638
- this.client.listAssignableUsers({
639
- projectKey: project.key,
640
- startAt: 0,
641
- maxResults: 100,
642
- }),
643
- this.client.listPriorities(),
644
- this.client.listIssueTypes({ projectId: project.id }),
645
- ])
646
- await this.upsertUsers(
647
- users.map((user) => ({
648
- accountId: user.accountId,
649
- displayName: user.displayName,
650
- active: user.active ?? true,
651
- })),
652
- )
653
- await this.replacePriorities(
654
- priorities.map((priority) => ({ id: priority.id, name: priority.name })),
655
- )
656
- await this.replaceIssueTypes(
657
- issueTypes.map((issueType) => ({ id: issueType.id, name: issueType.name })),
658
- )
659
-
660
- const since = fullReconcile ? null : meta.lastIssueUpdatedAt
661
- const sinceClause = since ?? '1970-01-01 00:00'
662
- const jql = `project = ${project.key} AND updated >= "${sinceClause}" ORDER BY updated ASC`
663
- let startAt = 0
664
- const maxResults = 100
665
- let accumulated = 0
666
- let total = Infinity
667
- let newestUpdatedAt: string | null = meta.lastIssueUpdatedAt
668
- const seenIssueIds = new Set<string>()
669
- const issueFields = [
670
- 'summary',
671
- 'description',
672
- 'status',
673
- 'issuetype',
674
- 'priority',
675
- 'assignee',
676
- 'labels',
677
- 'comment',
678
- 'created',
679
- 'updated',
680
- 'project',
681
- ]
682
-
683
- while (accumulated < total) {
684
- const page = await this.client.listIssues({ jql, startAt, maxResults, fields: issueFields })
685
- total = page.total
686
- if (page.issues.length === 0) break
687
-
688
- await this.upsertIssues(
689
- page.issues.map((issue) => ({
690
- id: issue.id,
691
- key: issue.key,
692
- summary: issue.fields.summary,
693
- descriptionText: issue.fields.description
694
- ? adfToPlainText(issue.fields.description as AdfDocument)
695
- : '',
696
- statusId: issue.fields.status.id,
697
- priorityName: issue.fields.priority?.name ?? null,
698
- issueTypeName: issue.fields.issuetype?.name ?? '',
699
- assigneeAccountId: issue.fields.assignee?.accountId ?? null,
700
- assigneeName: issue.fields.assignee?.displayName ?? null,
701
- labels: issue.fields.labels ?? [],
702
- commentCount: issue.fields.comment?.total ?? 0,
703
- projectKey: issue.fields.project?.key ?? project.key,
704
- url: `${this.config.baseUrl}/browse/${issue.key}`,
705
- createdAt: issue.fields.created,
706
- updatedAt: issue.fields.updated,
707
- })),
708
- )
709
-
710
- for (const issue of page.issues) {
711
- if (fullReconcile) seenIssueIds.add(issue.id)
712
- if (newestUpdatedAt === null || issue.fields.updated > newestUpdatedAt) {
713
- newestUpdatedAt = issue.fields.updated
714
- }
715
- }
716
-
717
- for (const issue of page.issues) {
718
- await this.ingestIssueActivity(issue.id).catch((err) => {
719
- console.warn(`[jira] activity fetch for ${issue.key} failed:`, err)
720
- })
721
- }
722
-
723
- accumulated += page.issues.length
724
- startAt += page.issues.length
725
- }
726
-
727
- if (fullReconcile) {
728
- await this.pruneIssuesMissingUpstream(project.key, [...seenIssueIds])
729
- }
730
-
731
- const nextMeta: Partial<JiraSyncMeta> = {
732
- projectKey: project.key,
733
- boardId: this.config.boardId ?? null,
734
- lastSyncAt: new Date().toISOString(),
735
- lastIssueUpdatedAt: newestUpdatedAt ?? new Date().toISOString(),
736
- }
737
- if (fullReconcile) nextMeta.lastFullSyncAt = nextMeta.lastSyncAt
738
- await this.saveSyncMeta(nextMeta)
739
- }
740
-
741
- private async resolveColumnId(input: string): Promise<string> {
742
- return resolveJiraColumnId(await this.getColumns(), input)
743
- }
744
-
745
- private async buildBoardConfig(): Promise<BoardConfig> {
746
- const cache = await this.getCachedConfig()
747
- const members = cache.users.map((user) => ({ name: user.displayName, role: 'human' as const }))
748
- const projects = cache.projectKey ? [cache.projectKey] : []
749
- const discoveredAssignees = (
750
- await this.sql<{ assignee_name: string }[]>`
751
- SELECT DISTINCT assignee_name FROM jira_issues WHERE assignee_name != '' ORDER BY assignee_name
752
- `
753
- ).map((row) => row.assignee_name)
754
- return {
755
- members,
756
- projects,
757
- provider: 'jira',
758
- discoveredAssignees,
759
- discoveredProjects: projects.slice(),
760
- }
761
- }
762
-
763
- async syncCache(): Promise<void> {
764
- await this.sync()
765
- }
766
-
767
- async getSyncStatus(): Promise<ProviderSyncStatus> {
768
- const meta = await this.loadSyncMeta()
769
- return {
770
- lastSyncAt: meta.lastSyncAt,
771
- lastFullSyncAt: meta.lastFullSyncAt,
772
- lastWebhookAt: meta.lastWebhookAt,
773
- }
774
- }
775
-
776
- async getContext(): Promise<ProviderContext> {
777
- await this.sync()
778
- return { provider: 'jira', capabilities: JIRA_CAPABILITIES, team: await this.loadTeamInfo() }
12
+ constructor(sql: Sql, config: JiraProviderConfig, client?: JiraClient) {
13
+ super(new PostgresJiraCache(sql), config, client)
14
+ this.sql = sql
779
15
  }
780
16
 
781
- async getBootstrap(): Promise<BoardBootstrap> {
782
- await this.sync()
783
- return {
784
- provider: 'jira',
785
- capabilities: JIRA_CAPABILITIES,
786
- board: await this.getCachedBoard(),
787
- config: await this.buildBoardConfig(),
788
- metrics: null,
789
- activity: [],
790
- team: await this.loadTeamInfo(),
791
- }
792
- }
793
-
794
- async getBoard(): Promise<BoardView> {
795
- await this.sync()
796
- return this.getCachedBoard()
797
- }
798
-
799
- async listColumns(): Promise<Column[]> {
800
- await this.sync()
801
- return (await this.getColumns()).map((row) => ({
802
- id: row.id,
803
- name: row.name,
804
- position: row.position,
805
- color: null,
806
- created_at: '',
807
- updated_at: '',
808
- }))
809
- }
810
-
811
- async listTasks(filters: TaskListFilters = {}): Promise<Task[]> {
812
- await this.sync()
813
- const columnId = filters.column ? await this.resolveColumnId(filters.column) : undefined
814
- let tasks = await this.getCachedTasks(columnId ? { columnId } : undefined)
815
- if (filters.priority) tasks = tasks.filter((task) => task.priority === filters.priority)
816
- if (filters.assignee) tasks = tasks.filter((task) => task.assignee === filters.assignee)
817
- if (filters.project) tasks = tasks.filter((task) => task.project === filters.project)
818
- if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
819
- if (filters.sort === 'updated')
820
- tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
821
- if (filters.limit) tasks = tasks.slice(0, filters.limit)
822
- return tasks
823
- }
824
-
825
- async getTask(idOrRef: string): Promise<Task> {
826
- await this.sync()
827
- const task = await this.getCachedTask(idOrRef)
828
- if (!task) throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${idOrRef}'`)
829
- return task
830
- }
831
-
832
- private async resolveTaskByIdOrKey(idOrRef: string): Promise<Task> {
833
- const task = await this.getCachedTask(idOrRef)
834
- if (!task) throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${idOrRef}'`)
835
- return task
836
- }
837
-
838
- private issueKeyFor(task: Task): string {
839
- return task.externalRef ?? task.providerId ?? task.id.replace(/^jira:/, '')
840
- }
841
-
842
- private async resolveJiraPriorityName(canonical: Priority): Promise<string> {
843
- const wanted = CANONICAL_TO_JIRA_DEFAULT[canonical]
844
- const [row] = await this.sql<{ name: string }[]>`
845
- SELECT name FROM jira_priorities WHERE LOWER(name) = LOWER(${wanted}) LIMIT 1
846
- `
847
- if (row) return row.name
848
- const available = (
849
- await this.sql<{ name: string }[]>`SELECT name FROM jira_priorities ORDER BY name`
850
- ).map((priority) => priority.name)
851
- providerUpstreamError(
852
- `Canonical priority '${canonical}' maps to Jira priority '${wanted}' which is not present in this tenant's priority catalog. Available Jira priorities: [${available
853
- .map((name) => `"${name}"`)
854
- .join(', ')}]`,
855
- )
856
- }
857
-
858
- private async resolveAssigneeAccountId(displayName: string): Promise<string> {
859
- const [row] = await this.sql<{ account_id: string }[]>`
860
- SELECT account_id
861
- FROM jira_users
862
- WHERE active = 1 AND LOWER(display_name) = LOWER(${displayName})
863
- LIMIT 1
864
- `
865
- if (row) return row.account_id
866
- providerUpstreamError(
867
- `Jira assignee '${displayName}' was not found in the cached active user list. Try 'kanban task list --assignee' to see cached names.`,
868
- )
869
- }
870
-
871
- private async resolveIssueTypeId(name: string): Promise<string> {
872
- const [row] = await this.sql<{ id: string }[]>`
873
- SELECT id FROM jira_issue_types WHERE LOWER(name) = LOWER(${name}) LIMIT 1
874
- `
875
- if (row) return row.id
876
- const available = (
877
- await this.sql<{ name: string }[]>`SELECT name FROM jira_issue_types ORDER BY name`
878
- ).map((issueType) => issueType.name)
879
- providerUpstreamError(
880
- `Jira issue type '${name}' is not present in this project's issue-type catalog. Available types: [${available
881
- .map((availableName) => `"${availableName}"`)
882
- .join(', ')}]`,
883
- )
884
- }
885
-
886
- private normalizeProjectField(input?: string): void {
887
- if (!input) return
888
- if (input === this.config.projectKey) return
889
- unsupportedOperation(
890
- `JiraProvider is pinned to project '${this.config.projectKey}'. A different project field ('${input}') is not supported.`,
891
- )
892
- }
893
-
894
- private toTaskComment(task: Task, comment: JiraComment): TaskComment {
895
- const timestamp = comment.updated ?? comment.created ?? task.updated_at
896
- return {
897
- id: comment.id,
898
- task_id: task.id,
899
- body: comment.body ? adfToPlainText(comment.body as AdfDocument) : '',
900
- author: comment.author?.displayName ?? null,
901
- created_at: comment.created ?? timestamp,
902
- updated_at: timestamp,
903
- }
904
- }
905
-
906
- private async ingestIssueActivity(issueId: string): Promise<void> {
907
- const page = await this.client.getChangelog(issueId, { maxResults: 100 })
908
- const rows: JiraActivityRow[] = []
909
- for (const entry of page.values) {
910
- for (const item of entry.items) {
911
- rows.push({
912
- issue_id: issueId,
913
- history_id: entry.id,
914
- item_field: item.field,
915
- from_value: item.from ?? null,
916
- to_value: item.to ?? null,
917
- created_at: entry.created,
918
- })
919
- }
920
- }
921
- await this.saveActivity(rows)
922
- }
923
-
924
- async createTask(input: CreateTaskInput): Promise<Task> {
925
- await this.sync()
926
- this.normalizeProjectField(input.project)
927
- const issueTypeName = this.config.defaultIssueType ?? 'Task'
928
- const issueTypeId = await this.resolveIssueTypeId(issueTypeName)
929
- const fields: Record<string, unknown> = {
930
- project: { key: this.config.projectKey },
931
- summary: input.title,
932
- issuetype: { id: issueTypeId },
933
- }
934
- if (input.description !== undefined) fields['description'] = plainTextToAdf(input.description)
935
- if (input.priority !== undefined) {
936
- fields['priority'] = { name: await this.resolveJiraPriorityName(input.priority) }
937
- }
938
- if (input.assignee) {
939
- fields['assignee'] = { accountId: await this.resolveAssigneeAccountId(input.assignee) }
940
- }
941
- const labels = normalizeJiraLabels(input.labels)
942
- if (labels.length > 0) fields['labels'] = labels
943
- const created = await this.client.createIssue({ fields })
944
- await this.sync(true)
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
952
- }
953
-
954
- async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
955
- await this.sync()
956
- this.normalizeProjectField(input.project)
957
- if (input.metadata !== undefined)
958
- unsupportedOperation('Jira mode does not support metadata updates')
959
- const task = await this.resolveTaskByIdOrKey(idOrRef)
960
- if (input.expectedVersion !== undefined && task.version !== input.expectedVersion) {
961
- throw new KanbanError(
962
- ErrorCode.CONFLICT,
963
- `Jira issue ${task.externalRef ?? idOrRef} was updated remotely (expected version ${input.expectedVersion}, current ${task.version ?? 'unknown'})`,
964
- )
965
- }
966
- const issueKey = this.issueKeyFor(task)
967
- const fields: Record<string, unknown> = {}
968
- if (input.title !== undefined) fields['summary'] = input.title
969
- if (input.description !== undefined) fields['description'] = plainTextToAdf(input.description)
970
- if (input.priority !== undefined) {
971
- fields['priority'] = { name: await this.resolveJiraPriorityName(input.priority) }
972
- }
973
- if (input.assignee !== undefined) {
974
- fields['assignee'] = input.assignee
975
- ? { accountId: await this.resolveAssigneeAccountId(input.assignee) }
976
- : null
977
- }
978
- if (Object.keys(fields).length > 0) await this.client.updateIssue(issueKey, { fields })
979
- await this.sync(true)
980
- const fresh = await this.getCachedTask(issueKey)
981
- if (!fresh) providerUpstreamError(`Jira issue ${issueKey} disappeared from cache after update.`)
982
- return fresh
983
- }
984
-
985
- async moveTask(idOrRef: string, column: string): Promise<Task> {
986
- await this.sync()
987
- const task = await this.resolveTaskByIdOrKey(idOrRef)
988
- return this.moveTaskByKey(this.issueKeyFor(task), column)
989
- }
990
-
991
- private async moveTaskByKey(issueKey: string, column: string): Promise<Task> {
992
- const columnId = await this.resolveColumnId(column)
993
- const columnRow = (await this.getColumns()).find((candidate) => candidate.id === columnId)
994
- if (!columnRow) {
995
- throw new KanbanError(
996
- ErrorCode.COLUMN_NOT_FOUND,
997
- `Resolved column '${column}' but cache row missing`,
998
- )
999
- }
1000
- const statusIds = decodeColumnStatusIds(columnRow)
1001
- if (statusIds.length === 0) {
1002
- providerUpstreamError(`Column '${columnRow.name}' has no mapped Jira statuses.`)
1003
- }
1004
- const targetStatusId = statusIds[0]!
1005
- const { transitions } = await this.client.getTransitions(issueKey)
1006
- const match = transitions.find((transition) => transition.to.id === targetStatusId)
1007
- if (!match) {
1008
- const currentStatusId = (await this.getCachedTask(issueKey))?.column_id ?? '<unknown>'
1009
- providerUpstreamError(
1010
- `Cannot transition Jira issue ${issueKey} (current status id ${currentStatusId}) to column '${columnRow.name}' (target status id ${targetStatusId}). Available transitions: [${transitions
1011
- .map((transition) => `"${transition.name}"`)
1012
- .join(', ')}]`,
1013
- )
1014
- }
1015
- await this.client.transitionIssue(issueKey, match.id)
1016
- await this.sync(true)
1017
- const fresh = await this.getCachedTask(issueKey)
1018
- if (!fresh) providerUpstreamError(`Jira issue ${issueKey} missing from cache after transition.`)
1019
- return fresh
1020
- }
1021
-
1022
- async deleteTask(_idOrRef: string): Promise<Task> {
1023
- unsupportedOperation('Task deletion is not supported in Jira mode')
1024
- }
1025
-
1026
- async listComments(idOrRef: string): Promise<TaskComment[]> {
1027
- await this.sync()
1028
- const task = await this.resolveTaskByIdOrKey(idOrRef)
1029
- const issueKey = this.issueKeyFor(task)
1030
- const comments: JiraComment[] = []
1031
- let startAt = 0
1032
-
1033
- while (true) {
1034
- const page = await this.client.getComments(issueKey, { startAt, maxResults: 100 })
1035
- comments.push(...page.comments)
1036
- startAt += page.comments.length
1037
- if (comments.length >= page.total || page.comments.length === 0) break
1038
- }
1039
-
1040
- return comments.map((comment) => this.toTaskComment(task, comment))
1041
- }
1042
-
1043
- async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
1044
- await this.sync()
1045
- const task = await this.resolveTaskByIdOrKey(idOrRef)
1046
- const comment = await this.client.getComment(this.issueKeyFor(task), commentId)
1047
- return this.toTaskComment(task, comment)
1048
- }
1049
-
1050
- async comment(idOrRef: string, body: string): Promise<TaskComment> {
1051
- await this.sync()
1052
- const task = await this.resolveTaskByIdOrKey(idOrRef)
1053
- const created = await this.client.addComment(this.issueKeyFor(task), {
1054
- body: plainTextToAdf(body),
1055
- })
1056
- await this.adjustIssueCommentCount(task.providerId || task.externalRef || task.id, 1)
1057
- return this.toTaskComment(task, created)
1058
- }
1059
-
1060
- async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
1061
- await this.sync()
1062
- const task = await this.resolveTaskByIdOrKey(idOrRef)
1063
- const updated = await this.client.updateComment(this.issueKeyFor(task), commentId, {
1064
- body: plainTextToAdf(body),
1065
- })
1066
- return this.toTaskComment(task, updated)
1067
- }
1068
-
1069
- async getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]> {
1070
- await this.sync()
1071
- const lookupIssueId = taskId ? await this.resolveIssueIdFromTaskId(taskId) : undefined
1072
- const rows = await this.getCachedActivity({
1073
- ...(lookupIssueId !== undefined ? { issueId: lookupIssueId } : {}),
1074
- limit: limit ?? 100,
1075
- })
1076
- return Promise.all(rows.map((row) => this.activityRowToEntry(row)))
1077
- }
1078
-
1079
- private async resolveIssueIdFromTaskId(taskId: string): Promise<string | undefined> {
1080
- const normalized = taskId.startsWith('jira:') ? taskId.slice('jira:'.length) : taskId
1081
- const [row] = await this.sql<{ id: string }[]>`
1082
- SELECT id FROM jira_issues WHERE id = ${normalized} OR key = ${normalized} LIMIT 1
1083
- `
1084
- return row?.id
1085
- }
1086
-
1087
- private async activityRowToEntry(row: JiraActivityRow): Promise<ActivityEntry> {
1088
- const action: ActivityEntry['action'] = row.item_field === 'status' ? 'moved' : 'updated'
1089
- let fromCol = row.from_value
1090
- let toCol = row.to_value
1091
- if (row.item_field === 'status') {
1092
- fromCol = row.from_value
1093
- ? ((await this.statusIdToColumnId(row.from_value)) ?? row.from_value)
1094
- : null
1095
- toCol = row.to_value ? ((await this.statusIdToColumnId(row.to_value)) ?? row.to_value) : null
1096
- }
1097
- return {
1098
- id: `jira-activity:${row.issue_id}:${row.history_id}:${row.item_field}`,
1099
- task_id: `jira:${row.issue_id}`,
1100
- action,
1101
- field_changed: row.item_field,
1102
- old_value: fromCol,
1103
- new_value: toCol,
1104
- timestamp: row.created_at,
1105
- }
1106
- }
1107
-
1108
- private async statusIdToColumnId(statusId: string): Promise<string | undefined> {
1109
- for (const column of await this.getColumns()) {
1110
- if (decodeColumnStatusIds(column).includes(statusId)) return column.id
1111
- }
1112
- return undefined
1113
- }
1114
-
1115
- async getMetrics(): Promise<BoardMetrics> {
1116
- unsupportedOperation('Metrics are not available in Jira mode')
1117
- }
1118
-
1119
- async getConfig(): Promise<BoardConfig> {
1120
- await this.sync()
1121
- return this.buildBoardConfig()
1122
- }
1123
-
1124
- async patchConfig(_input: Partial<BoardConfig>): Promise<BoardConfig> {
1125
- unsupportedOperation('Config mutation is not supported in Jira mode')
1126
- }
1127
-
1128
- async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
17
+ // Postgres records every received webhook to the webhook_events audit table.
18
+ // The shared dispatch lives in JiraProviderCore.handleWebhookCore; this wrapper
19
+ // only adds the audit persistence around it.
20
+ override async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
1129
21
  const meta = extractWebhookMeta('jira', payload.rawBody)
1130
22
  let result: WebhookResult
1131
23
  try {
1132
- result = await this.handleWebhookInner(payload)
24
+ result = await this.handleWebhookCore(payload)
1133
25
  } catch (err) {
1134
26
  void recordWebhookEvent(this.sql, {
1135
27
  provider: 'jira',
@@ -1146,69 +38,4 @@ export class PostgresJiraProvider implements KanbanProvider {
1146
38
  })
1147
39
  return result
1148
40
  }
1149
-
1150
- private async handleWebhookInner(payload: WebhookRequest): Promise<WebhookResult> {
1151
- const secret = process.env['JIRA_WEBHOOK_SECRET']
1152
- if (secret) {
1153
- const sig = headerLower(payload.headers, 'x-hub-signature')
1154
- if (!verifySha256HmacSignatureHeader(secret, payload.rawBody, sig)) {
1155
- return { handled: false, unauthorized: true, message: 'Invalid signature' }
1156
- }
1157
- }
1158
- let body: { webhookEvent?: string; issue?: JiraIssue } = {}
1159
- try {
1160
- body = JSON.parse(payload.rawBody) as typeof body
1161
- } catch {
1162
- return { handled: false, message: 'Invalid JSON body' }
1163
- }
1164
- const event = body.webhookEvent ?? ''
1165
- const issue = body.issue
1166
- if (!issue) return { handled: false, message: `No issue in payload (${event})` }
1167
-
1168
- if (event === 'jira:issue_deleted') {
1169
- await this.deleteIssue(issue.id)
1170
- await this.saveSyncMeta({ lastWebhookAt: new Date().toISOString() })
1171
- return { handled: true }
1172
- }
1173
-
1174
- if (event === 'jira:issue_created' || event === 'jira:issue_updated') {
1175
- const projectKey = issue.fields.project?.key
1176
- if (projectKey !== this.config.projectKey) {
1177
- return {
1178
- handled: false,
1179
- message: `Ignoring issue from project '${projectKey ?? 'unknown'}'`,
1180
- }
1181
- }
1182
- await this.upsertIssues([
1183
- {
1184
- id: issue.id,
1185
- key: issue.key,
1186
- summary: issue.fields.summary,
1187
- descriptionText: issue.fields.description
1188
- ? adfToPlainText(issue.fields.description as AdfDocument)
1189
- : '',
1190
- statusId: issue.fields.status.id,
1191
- priorityName: issue.fields.priority?.name ?? null,
1192
- issueTypeName: issue.fields.issuetype?.name ?? '',
1193
- assigneeAccountId: issue.fields.assignee?.accountId ?? null,
1194
- assigneeName: issue.fields.assignee?.displayName ?? null,
1195
- labels: issue.fields.labels ?? [],
1196
- commentCount: issue.fields.comment?.total ?? 0,
1197
- projectKey,
1198
- url: `${this.config.baseUrl}/browse/${issue.key}`,
1199
- createdAt: issue.fields.created,
1200
- updatedAt: issue.fields.updated,
1201
- },
1202
- ])
1203
- if (event === 'jira:issue_updated') {
1204
- await this.ingestIssueActivity(issue.id).catch((err) => {
1205
- console.warn(`[jira] activity fetch for webhook issue ${issue.key} failed:`, err)
1206
- })
1207
- }
1208
- await this.saveSyncMeta({ lastWebhookAt: new Date().toISOString() })
1209
- return { handled: true }
1210
- }
1211
-
1212
- return { handled: false, message: `Unsupported event: ${event}` }
1213
- }
1214
41
  }