@andypai/agent-kanban 0.3.4 → 0.3.6

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