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