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