@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
@@ -0,0 +1,921 @@
1
+ import { ErrorCode, KanbanError } from '../errors'
2
+ import type {
3
+ ActivityEntry,
4
+ BoardBootstrap,
5
+ BoardConfig,
6
+ BoardMetrics,
7
+ BoardView,
8
+ Column,
9
+ Priority,
10
+ ProviderTeamInfo,
11
+ Task,
12
+ TaskComment,
13
+ } from '../types'
14
+ import {
15
+ authorizeWebhook,
16
+ headerLower,
17
+ verifySha256HmacSignatureHeader,
18
+ type WebhookRequest,
19
+ type WebhookResult,
20
+ } from '../webhooks'
21
+ import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
22
+ import { buildDeltaJql, safeDeltaSince } from './jira-jql'
23
+ import { JIRA_CAPABILITIES } from './capabilities'
24
+ import { providerUpstreamError, unsupportedOperation } from './errors'
25
+ import {
26
+ JiraClient,
27
+ decideJiraPagination,
28
+ normalizeJiraLabels,
29
+ type JiraComment,
30
+ type JiraIssue,
31
+ } from './jira-client'
32
+ import {
33
+ decodeColumnStatusIds,
34
+ jiraBoardColumnRows,
35
+ resolveJiraColumnId,
36
+ type JiraActivityRow,
37
+ type JiraCacheConfig,
38
+ type JiraColumnRow,
39
+ type JiraSyncMeta,
40
+ } from './jira-cache'
41
+ import type {
42
+ CreateTaskInput,
43
+ KanbanProvider,
44
+ ProviderContext,
45
+ ProviderSyncStatus,
46
+ TaskListFilters,
47
+ UpdateTaskInput,
48
+ } from './types'
49
+ import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
50
+
51
+ export const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
52
+
53
+ export function shouldRunFullReconcile(lastFullSyncAt: string | null, now: number): boolean {
54
+ if (!lastFullSyncAt) return true
55
+ const lastFullSyncAtMs = Date.parse(lastFullSyncAt)
56
+ if (!Number.isFinite(lastFullSyncAtMs)) return true
57
+ return now - lastFullSyncAtMs >= FULL_RECONCILE_INTERVAL_MS
58
+ }
59
+
60
+ // Default canonical->Jira priority name mapping. A Jira admin may rename
61
+ // priorities; the write path looks up the resolved name (case-insensitive)
62
+ // in the cached `jira_priorities` table, so renames that preserve the default
63
+ // casing still resolve.
64
+ export const CANONICAL_TO_JIRA_DEFAULT: Record<Priority, string> = {
65
+ urgent: 'Highest',
66
+ high: 'High',
67
+ medium: 'Medium',
68
+ low: 'Low',
69
+ }
70
+
71
+ export interface JiraProviderConfig {
72
+ baseUrl: string
73
+ email: string
74
+ apiToken: string
75
+ projectKey: string
76
+ boardId?: number
77
+ defaultIssueType?: string
78
+ pollingSyncIntervalMs?: number
79
+ }
80
+
81
+ /**
82
+ * Storage-agnostic cache/repository port the Jira core depends on. The SQLite
83
+ * (jira.ts) and Postgres (postgres-jira.ts) backends each provide an
84
+ * implementation; the core never touches a Database or Sql client directly.
85
+ * All methods are async so the Postgres backend is native and the SQLite backend
86
+ * wraps its synchronous bun:sqlite calls.
87
+ */
88
+ export interface JiraCachePort {
89
+ readonly ready: Promise<void>
90
+
91
+ // Sync metadata + team info
92
+ loadSyncMeta(): Promise<JiraSyncMeta>
93
+ saveSyncMeta(meta: Partial<JiraSyncMeta>): Promise<void>
94
+ loadTeamInfo(): Promise<ProviderTeamInfo | null>
95
+ saveTeamInfo(team: ProviderTeamInfo | null): Promise<void>
96
+
97
+ // Catalog writes. `prune` deletes obsolete rows (full reconcile only); a delta
98
+ // sync upserts the current rows and leaves the rest in place (self-healing).
99
+ replaceColumns(
100
+ columns: Array<{
101
+ id: string
102
+ name: string
103
+ position: number
104
+ statusIds: string[]
105
+ source: 'board' | 'status'
106
+ }>,
107
+ prune: boolean,
108
+ ): Promise<void>
109
+ upsertUsers(
110
+ users: Array<{ accountId: string; displayName: string; active?: boolean }>,
111
+ ): Promise<void>
112
+ replacePriorities(priorities: Array<{ id: string; name: string }>, prune: boolean): Promise<void>
113
+ replaceIssueTypes(types: Array<{ id: string; name: string }>, prune: boolean): Promise<void>
114
+
115
+ // Issue writes
116
+ upsertIssues(
117
+ issues: Array<{
118
+ id: string
119
+ key: string
120
+ summary: string
121
+ descriptionText: string
122
+ statusId: string
123
+ priorityName?: string | null
124
+ issueTypeName?: string | null
125
+ assigneeAccountId?: string | null
126
+ assigneeName?: string | null
127
+ labels?: string[] | null
128
+ commentCount?: number | null
129
+ projectKey: string
130
+ url?: string | null
131
+ createdAt: string
132
+ updatedAt: string
133
+ }>,
134
+ ): Promise<void>
135
+ deleteIssue(idOrKey: string): Promise<void>
136
+ pruneIssuesMissingUpstream(projectKey: string, upstreamIssueIds: string[]): Promise<void>
137
+ adjustIssueCommentCount(idOrKey: string, delta: number): Promise<void>
138
+
139
+ // Activity
140
+ saveActivity(rows: JiraActivityRow[]): Promise<void>
141
+ getCachedActivity(params?: { issueId?: string; limit?: number }): Promise<JiraActivityRow[]>
142
+
143
+ // Reads / materialization
144
+ getColumns(): Promise<JiraColumnRow[]>
145
+ getCachedBoard(): Promise<BoardView>
146
+ getCachedTask(lookup: string): Promise<Task | null>
147
+ getCachedTasks(params?: { columnId?: string }): Promise<Task[]>
148
+ getCachedConfig(): Promise<JiraCacheConfig>
149
+
150
+ // Catalog/issue lookups used by the write paths
151
+ getDiscoveredAssignees(): Promise<string[]>
152
+ findPriorityName(wanted: string): Promise<string | null>
153
+ getPriorityNames(): Promise<string[]>
154
+ findActiveAssigneeAccountId(displayName: string): Promise<string | null>
155
+ findIssueTypeId(name: string): Promise<string | null>
156
+ getIssueTypeNames(): Promise<string[]>
157
+ resolveIssueId(lookup: string): Promise<string | null>
158
+ }
159
+
160
+ /**
161
+ * Shared Jira provider business logic (sync orchestration, hydration, writes,
162
+ * transitions, comments, webhook dispatch, activity mapping). Concrete providers
163
+ * (JiraProvider over SQLite, PostgresJiraProvider over Postgres) subclass this
164
+ * and inject a JiraCachePort plus the API client.
165
+ */
166
+ export class JiraProviderCore implements KanbanProvider {
167
+ readonly type = 'jira' as const
168
+ protected readonly client: JiraClient
169
+ protected readonly pollingSyncIntervalMs: number
170
+ // When a server-side background warmer owns cache refresh, request-path syncs
171
+ // are suppressed once the cache is warm so reads/writes never block on Jira I/O.
172
+ private backgroundManaged = false
173
+
174
+ constructor(
175
+ protected readonly cache: JiraCachePort,
176
+ protected readonly config: JiraProviderConfig,
177
+ client?: JiraClient,
178
+ ) {
179
+ this.pollingSyncIntervalMs = config.pollingSyncIntervalMs ?? DEFAULT_POLLING_SYNC_INTERVAL_MS
180
+ this.client =
181
+ client ??
182
+ new JiraClient({
183
+ baseUrl: config.baseUrl,
184
+ email: config.email,
185
+ apiToken: config.apiToken,
186
+ })
187
+ }
188
+
189
+ async initialize(): Promise<void> {
190
+ await this.cache.ready
191
+ }
192
+
193
+ protected async sync(force = false, viaWarmer = false): Promise<void> {
194
+ await this.cache.ready
195
+ const meta = await this.cache.loadSyncMeta()
196
+ const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
197
+ // Server mode: a background warmer (syncCache(), viaWarmer=true) owns refresh.
198
+ // Once the cache has synced at least once (lastSyncAt persisted), implicit
199
+ // request-path reads serve the warm cache instead of blocking on a Jira
200
+ // round-trip — a foreground sync here can run a periodic full reconcile
201
+ // (~minutes) and would otherwise exceed the HTTP idle timeout. Forced syncs
202
+ // (writes' read-after-write) and the warmer still run; CLI mode and cold start
203
+ // (no prior sync) fall through and sync synchronously, preserving freshness.
204
+ if (this.backgroundManaged && !force && !viaWarmer && lastSyncAtMs) return
205
+ const now = Date.now()
206
+ if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
207
+ // `force` only bypasses the poll throttle; it must NOT imply a full
208
+ // 1970-based reconcile, which re-fetches every issue plus a per-issue
209
+ // changelog call (~minutes). Writes get read-after-write freshness from
210
+ // hydrateIssueByKey instead, so a forced sync stays a cheap delta.
211
+ const fullReconcile = shouldRunFullReconcile(meta.lastFullSyncAt, now)
212
+
213
+ // 1. Resolve project.
214
+ const project = await this.client.getProject(this.config.projectKey)
215
+ await this.cache.saveTeamInfo({ id: project.id, key: project.key, name: project.name })
216
+
217
+ // 2. Columns: board path OR status fallback path.
218
+ if (this.config.boardId !== undefined) {
219
+ const boardCfg = await this.client.getBoardColumns(this.config.boardId)
220
+ const boardId = this.config.boardId
221
+ const rows = jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns)
222
+ await this.cache.replaceColumns(rows, fullReconcile)
223
+ } else {
224
+ const statusCats = await this.client.getProjectStatuses(project.key)
225
+ const seen = new Set<string>()
226
+ const uniqueStatuses: Array<{ id: string; name: string }> = []
227
+ for (const cat of statusCats) {
228
+ for (const s of cat.statuses) {
229
+ if (seen.has(s.id)) continue
230
+ seen.add(s.id)
231
+ uniqueStatuses.push({ id: s.id, name: s.name })
232
+ }
233
+ }
234
+ const rows = uniqueStatuses.map((s, i) => ({
235
+ id: `status:${s.id}`,
236
+ name: s.name,
237
+ position: i,
238
+ statusIds: [s.id],
239
+ source: 'status' as const,
240
+ }))
241
+ await this.cache.replaceColumns(rows, fullReconcile)
242
+ }
243
+
244
+ // 3. Catalogs: users + priorities + issue types in parallel.
245
+ // NOTE: listAssignableUsers is capped at 100 in T04; tenants with more
246
+ // assignable users are truncated. Pagination is out of scope for this pass.
247
+ const [users, priorities, issueTypes] = await Promise.all([
248
+ this.client.listAssignableUsers({
249
+ projectKey: project.key,
250
+ startAt: 0,
251
+ maxResults: 100,
252
+ }),
253
+ this.client.listPriorities(),
254
+ this.client.listIssueTypes({ projectId: project.id }),
255
+ ])
256
+ await this.cache.upsertUsers(
257
+ users.map((u) => ({
258
+ accountId: u.accountId,
259
+ displayName: u.displayName,
260
+ active: u.active ?? true,
261
+ })),
262
+ )
263
+ await this.cache.replacePriorities(
264
+ priorities.map((p) => ({ id: p.id, name: p.name })),
265
+ fullReconcile,
266
+ )
267
+ await this.cache.replaceIssueTypes(
268
+ issueTypes.map((t) => ({ id: t.id, name: t.name })),
269
+ fullReconcile,
270
+ )
271
+
272
+ // 4. Delta issue fetch (paginated).
273
+ // Sanitize the stored cursor once: an unsafe value is treated as absent so it
274
+ // is never carried into the JQL or re-persisted as `newestUpdatedAt`.
275
+ const storedSince = safeDeltaSince(meta.lastIssueUpdatedAt)
276
+ const since = fullReconcile ? null : storedSince
277
+ const jql = buildDeltaJql(project.key, since)
278
+
279
+ const maxResults = 100
280
+ let newestUpdatedAt: string | null = storedSince
281
+ const seenIssueIds = new Set<string>()
282
+ const issueFields = [
283
+ 'summary',
284
+ 'description',
285
+ 'status',
286
+ 'issuetype',
287
+ 'priority',
288
+ 'assignee',
289
+ 'labels',
290
+ 'comment',
291
+ 'created',
292
+ 'updated',
293
+ 'project',
294
+ ]
295
+ // /rest/api/3/search/jql paginates by an opaque nextPageToken and omits
296
+ // `total`, so we follow the cursor until the server reports `isLast` or stops
297
+ // handing back a token. The previous total/startAt loop fetched only the first
298
+ // page (oldest 100 by updated ASC) once `total` came back undefined, so every
299
+ // issue beyond the first page — including newly-created tickets on a project
300
+ // with >100 issues — was never cached. seenPageTokens guards against a server
301
+ // that repeats a cursor, which would otherwise spin this loop forever and hang
302
+ // the poll cycle; an empty page is not treated as terminal because a non-last
303
+ // page may legitimately carry a token with zero issues.
304
+ const seenPageTokens = new Set<string>()
305
+ let nextPageToken: string | undefined
306
+ let firstPage = true
307
+ let paginationComplete = false
308
+ while (firstPage || nextPageToken !== undefined) {
309
+ firstPage = false
310
+ const page = await this.client.listIssues({
311
+ jql,
312
+ startAt: 0,
313
+ maxResults,
314
+ fields: issueFields,
315
+ nextPageToken,
316
+ })
317
+
318
+ await this.cache.upsertIssues(
319
+ page.issues.map((issue) => ({
320
+ id: issue.id,
321
+ key: issue.key,
322
+ summary: issue.fields.summary,
323
+ descriptionText: issue.fields.description
324
+ ? adfToPlainText(issue.fields.description as AdfDocument)
325
+ : '',
326
+ statusId: issue.fields.status.id,
327
+ priorityName: issue.fields.priority?.name ?? null,
328
+ issueTypeName: issue.fields.issuetype?.name ?? '',
329
+ assigneeAccountId: issue.fields.assignee?.accountId ?? null,
330
+ assigneeName: issue.fields.assignee?.displayName ?? null,
331
+ labels: issue.fields.labels ?? [],
332
+ commentCount: issue.fields.comment?.total ?? 0,
333
+ projectKey: issue.fields.project?.key ?? project.key,
334
+ url: `${this.config.baseUrl}/browse/${issue.key}`,
335
+ createdAt: issue.fields.created,
336
+ updatedAt: issue.fields.updated,
337
+ })),
338
+ )
339
+
340
+ for (const issue of page.issues) {
341
+ if (fullReconcile) seenIssueIds.add(issue.id)
342
+ if (newestUpdatedAt === null || issue.fields.updated > newestUpdatedAt) {
343
+ newestUpdatedAt = issue.fields.updated
344
+ }
345
+ }
346
+
347
+ // Fetch changelog per changed issue so the poll-based
348
+ // `moved` trigger in @garage/dispatch works. Server-side dedupe
349
+ // keyed on (issue_id, history_id, item_field) keeps this cheap
350
+ // even if the same issue is updated repeatedly.
351
+ for (const issue of page.issues) {
352
+ await this.ingestIssueActivity(issue.id).catch((err) => {
353
+ // Activity is best-effort; the main sync shouldn't fail if
354
+ // one changelog call 404s or rate-limits.
355
+ console.warn(`[jira] activity fetch for ${issue.key} failed:`, err)
356
+ })
357
+ }
358
+
359
+ const decision = decideJiraPagination(page, seenPageTokens)
360
+ if (decision.nextToken !== undefined) {
361
+ seenPageTokens.add(decision.nextToken)
362
+ nextPageToken = decision.nextToken
363
+ continue
364
+ }
365
+ paginationComplete = decision.complete
366
+ if (!paginationComplete) {
367
+ // The server stopped advancing the cursor before reporting a definitive
368
+ // end (stalled/contradictory). seenIssueIds is only a partial scan, so we
369
+ // must not treat it as authoritative for pruning below.
370
+ console.warn(
371
+ `[jira] search/jql scan ended without a definitive last page; treating as incomplete`,
372
+ )
373
+ }
374
+ break
375
+ }
376
+
377
+ if (!paginationComplete) {
378
+ // The scan ended early (stalled/contradictory cursor). Leave sync metadata
379
+ // unchanged so this partial result is not recorded as a clean sync: lastSyncAt
380
+ // is not advanced, so the next sync is not throttled and retries promptly, and
381
+ // the full-reconcile marker stays due. The issues we did fetch are already
382
+ // cached (additive); we only skip pruning — which would delete issues that
383
+ // exist upstream on pages we never fetched — and the metadata advance.
384
+ return
385
+ }
386
+
387
+ // Prune against seenIssueIds and advance lastFullSyncAt only on a full
388
+ // reconcile; a delta sync's seenIssueIds is intentionally partial. (The scan
389
+ // is known complete here — an incomplete scan returned above.)
390
+ if (fullReconcile) {
391
+ await this.cache.pruneIssuesMissingUpstream(project.key, [...seenIssueIds])
392
+ }
393
+
394
+ // 5. Save sync meta.
395
+ const nextMeta: Partial<JiraSyncMeta> = {
396
+ projectKey: project.key,
397
+ boardId: this.config.boardId ?? null,
398
+ lastSyncAt: new Date().toISOString(),
399
+ lastIssueUpdatedAt: newestUpdatedAt ?? new Date().toISOString(),
400
+ }
401
+ if (fullReconcile) {
402
+ nextMeta.lastFullSyncAt = nextMeta.lastSyncAt
403
+ }
404
+ await this.cache.saveSyncMeta(nextMeta)
405
+ }
406
+
407
+ private async resolveColumnId(input: string): Promise<string> {
408
+ return resolveJiraColumnId(await this.cache.getColumns(), input)
409
+ }
410
+
411
+ private async buildBoardConfig(): Promise<BoardConfig> {
412
+ const cache = await this.cache.getCachedConfig()
413
+ const members = cache.users.map((u) => ({
414
+ name: u.displayName,
415
+ role: 'human' as const,
416
+ }))
417
+ const projects = cache.projectKey ? [cache.projectKey] : []
418
+ const discoveredAssignees = await this.cache.getDiscoveredAssignees()
419
+ const discoveredProjects = projects.slice()
420
+ return {
421
+ members,
422
+ projects,
423
+ provider: 'jira',
424
+ discoveredAssignees,
425
+ discoveredProjects,
426
+ }
427
+ }
428
+
429
+ async syncCache(): Promise<void> {
430
+ // viaWarmer bypasses the backgroundManaged request-path suppression without
431
+ // forcing a full reconcile (force=false keeps the normal delta/full cadence).
432
+ await this.sync(false, true)
433
+ }
434
+
435
+ setBackgroundManaged(managed: boolean): void {
436
+ this.backgroundManaged = managed
437
+ }
438
+
439
+ async getSyncStatus(): Promise<ProviderSyncStatus> {
440
+ const meta = await this.cache.loadSyncMeta()
441
+ return {
442
+ lastSyncAt: meta.lastSyncAt,
443
+ lastFullSyncAt: meta.lastFullSyncAt,
444
+ lastWebhookAt: meta.lastWebhookAt,
445
+ }
446
+ }
447
+
448
+ async getContext(): Promise<ProviderContext> {
449
+ await this.sync()
450
+ return {
451
+ provider: 'jira',
452
+ capabilities: JIRA_CAPABILITIES,
453
+ team: await this.cache.loadTeamInfo(),
454
+ }
455
+ }
456
+
457
+ async getBootstrap(): Promise<BoardBootstrap> {
458
+ await this.sync()
459
+ return {
460
+ provider: 'jira',
461
+ capabilities: JIRA_CAPABILITIES,
462
+ board: await this.cache.getCachedBoard(),
463
+ config: await this.buildBoardConfig(),
464
+ metrics: null,
465
+ activity: [],
466
+ team: await this.cache.loadTeamInfo(),
467
+ }
468
+ }
469
+
470
+ async getBoard(): Promise<BoardView> {
471
+ await this.sync()
472
+ return this.cache.getCachedBoard()
473
+ }
474
+
475
+ async listColumns(): Promise<Column[]> {
476
+ await this.sync()
477
+ return (await this.cache.getColumns()).map((r) => ({
478
+ id: r.id,
479
+ name: r.name,
480
+ position: r.position,
481
+ color: null,
482
+ created_at: '',
483
+ updated_at: '',
484
+ }))
485
+ }
486
+
487
+ async listTasks(filters: TaskListFilters = {}): Promise<Task[]> {
488
+ await this.sync()
489
+ const columnId = filters.column ? await this.resolveColumnId(filters.column) : undefined
490
+ let tasks = await this.cache.getCachedTasks(columnId ? { columnId } : undefined)
491
+ if (filters.priority) tasks = tasks.filter((t) => t.priority === filters.priority)
492
+ if (filters.assignee) tasks = tasks.filter((t) => t.assignee === filters.assignee)
493
+ if (filters.project) tasks = tasks.filter((t) => t.project === filters.project)
494
+ if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
495
+ if (filters.sort === 'updated')
496
+ tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
497
+ if (filters.limit) tasks = tasks.slice(0, filters.limit)
498
+ return tasks
499
+ }
500
+
501
+ async getTask(idOrRef: string): Promise<Task> {
502
+ await this.sync()
503
+ const task = await this.cache.getCachedTask(idOrRef)
504
+ if (!task) {
505
+ throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${idOrRef}'`)
506
+ }
507
+ return task
508
+ }
509
+
510
+ private async resolveJiraPriorityName(canonical: Priority): Promise<string> {
511
+ const wanted = CANONICAL_TO_JIRA_DEFAULT[canonical]
512
+ const found = await this.cache.findPriorityName(wanted)
513
+ if (found) return found
514
+ const available = await this.cache.getPriorityNames()
515
+ providerUpstreamError(
516
+ `Canonical priority '${canonical}' maps to Jira priority '${wanted}' which is not present in this tenant's priority catalog. Available Jira priorities: [${available
517
+ .map((n) => `"${n}"`)
518
+ .join(', ')}]`,
519
+ )
520
+ }
521
+
522
+ // Empty-string / null assignee means "clear" — handled by callers; this
523
+ // resolver is only invoked for non-empty displayName values.
524
+ // Jira Cloud REST only accepts accountId for assignee writes; we never
525
+ // write `emailAddress`.
526
+ private async resolveAssigneeAccountId(displayName: string): Promise<string> {
527
+ const accountId = await this.cache.findActiveAssigneeAccountId(displayName)
528
+ if (accountId) return accountId
529
+ providerUpstreamError(
530
+ `Jira assignee '${displayName}' was not found in the cached active user list. Try 'kanban task list --assignee' to see cached names.`,
531
+ )
532
+ }
533
+
534
+ private async resolveIssueTypeId(name: string): Promise<string> {
535
+ const id = await this.cache.findIssueTypeId(name)
536
+ if (id) return id
537
+ const available = await this.cache.getIssueTypeNames()
538
+ providerUpstreamError(
539
+ `Jira issue type '${name}' is not present in this project's issue-type catalog. Available types: [${available
540
+ .map((n) => `"${n}"`)
541
+ .join(', ')}]`,
542
+ )
543
+ }
544
+
545
+ private normalizeProjectField(input?: string): void {
546
+ if (!input) return
547
+ if (input === this.config.projectKey) return
548
+ unsupportedOperation(
549
+ `JiraProvider is pinned to project '${this.config.projectKey}'. A different project field ('${input}') is not supported.`,
550
+ )
551
+ }
552
+
553
+ private async resolveTaskByIdOrKey(idOrRef: string): Promise<Task> {
554
+ const task = await this.cache.getCachedTask(idOrRef)
555
+ if (!task) {
556
+ throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${idOrRef}'`)
557
+ }
558
+ return task
559
+ }
560
+
561
+ private issueKeyFor(task: Task): string {
562
+ return task.externalRef ?? task.providerId ?? task.id.replace(/^jira:/, '')
563
+ }
564
+
565
+ private toTaskComment(task: Task, comment: JiraComment): TaskComment {
566
+ const timestamp = comment.updated ?? comment.created ?? task.updated_at
567
+ return {
568
+ id: comment.id,
569
+ task_id: task.id,
570
+ body: comment.body ? adfToPlainText(comment.body as AdfDocument) : '',
571
+ author: comment.author?.displayName ?? null,
572
+ created_at: comment.created ?? timestamp,
573
+ updated_at: timestamp,
574
+ }
575
+ }
576
+
577
+ // Read-after-write via the direct issue endpoint. Unlike JQL search (used by
578
+ // sync()), GET /issue/{key} has no search-index lag, so a just-created or
579
+ // just-transitioned issue is reflected immediately. This replaces the previous
580
+ // "create/transition then sync(true) then getCachedTask" pattern, which raced
581
+ // the search index (creates reported "not yet visible"; a move's new status
582
+ // sometimes didn't land) and forced a full whole-project reconcile per write.
583
+ private async hydrateIssueByKey(key: string): Promise<Task> {
584
+ // getIssue throws on a missing key (404), so reaching this method means the
585
+ // issue exists upstream. A null read-back would therefore be a genuine cache
586
+ // anomaly (the upsert above did not land), not an ordinary not-found — surface
587
+ // it rather than threading an unreachable null through every caller.
588
+ const issue = await this.client.getIssue(key)
589
+ await this.cache.upsertIssues([
590
+ {
591
+ id: issue.id,
592
+ key: issue.key,
593
+ summary: issue.fields.summary,
594
+ descriptionText: issue.fields.description
595
+ ? adfToPlainText(issue.fields.description as AdfDocument)
596
+ : '',
597
+ statusId: issue.fields.status.id,
598
+ priorityName: issue.fields.priority?.name ?? null,
599
+ issueTypeName: issue.fields.issuetype?.name ?? '',
600
+ assigneeAccountId: issue.fields.assignee?.accountId ?? null,
601
+ assigneeName: issue.fields.assignee?.displayName ?? null,
602
+ labels: issue.fields.labels ?? [],
603
+ commentCount: issue.fields.comment?.total ?? 0,
604
+ projectKey: issue.fields.project?.key ?? this.config.projectKey,
605
+ url: `${this.config.baseUrl}/browse/${issue.key}`,
606
+ createdAt: issue.fields.created,
607
+ updatedAt: issue.fields.updated,
608
+ },
609
+ ])
610
+ // Ingest the changelog like the sync loop does, so a just-applied transition
611
+ // is recorded in jira_activity immediately (backs getActivity and the
612
+ // poll-based `moved` trigger) rather than waiting for the next unthrottled
613
+ // sync. Best-effort: activity must not fail the mutation.
614
+ await this.ingestIssueActivity(issue.id).catch((err) => {
615
+ console.warn(`[jira] activity fetch for ${issue.key} failed:`, err)
616
+ })
617
+ const task = await this.cache.getCachedTask(key)
618
+ if (!task) {
619
+ providerUpstreamError(
620
+ `Jira issue ${key} was hydrated from GET /issue but is missing from the cache.`,
621
+ )
622
+ }
623
+ return task
624
+ }
625
+
626
+ async createTask(input: CreateTaskInput): Promise<Task> {
627
+ await this.sync()
628
+ this.normalizeProjectField(input.project)
629
+ const issueTypeName = this.config.defaultIssueType ?? 'Task'
630
+ const issueTypeId = await this.resolveIssueTypeId(issueTypeName)
631
+ const fields: Record<string, unknown> = {
632
+ project: { key: this.config.projectKey },
633
+ summary: input.title,
634
+ issuetype: { id: issueTypeId },
635
+ }
636
+ if (input.description !== undefined) {
637
+ fields['description'] = plainTextToAdf(input.description)
638
+ }
639
+ if (input.priority !== undefined) {
640
+ fields['priority'] = { name: await this.resolveJiraPriorityName(input.priority) }
641
+ }
642
+ if (input.assignee) {
643
+ fields['assignee'] = {
644
+ accountId: await this.resolveAssigneeAccountId(input.assignee),
645
+ }
646
+ }
647
+ const labels = normalizeJiraLabels(input.labels)
648
+ if (labels.length > 0) fields['labels'] = labels
649
+ // Column at create-time is intentionally unsupported in Jira mode: new
650
+ // issues land in the project workflow's default start state. Use
651
+ // `moveTask` after create to change status.
652
+ const created = await this.client.createIssue({ fields })
653
+ return this.hydrateIssueByKey(created.key)
654
+ }
655
+
656
+ async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
657
+ await this.sync()
658
+ this.normalizeProjectField(input.project)
659
+ if (input.metadata !== undefined) {
660
+ unsupportedOperation('Jira mode does not support metadata updates')
661
+ }
662
+ const task = await this.resolveTaskByIdOrKey(idOrRef)
663
+ if (input.expectedVersion !== undefined && task.version !== input.expectedVersion) {
664
+ throw new KanbanError(
665
+ ErrorCode.CONFLICT,
666
+ `Jira issue ${task.externalRef ?? idOrRef} was updated remotely (expected version ${input.expectedVersion}, current ${task.version ?? 'unknown'})`,
667
+ )
668
+ }
669
+ const issueKey = this.issueKeyFor(task)
670
+ const fields: Record<string, unknown> = {}
671
+ if (input.title !== undefined) fields['summary'] = input.title
672
+ if (input.description !== undefined) {
673
+ fields['description'] = plainTextToAdf(input.description)
674
+ }
675
+ if (input.priority !== undefined) {
676
+ fields['priority'] = { name: await this.resolveJiraPriorityName(input.priority) }
677
+ }
678
+ if (input.assignee !== undefined) {
679
+ // Empty-string sentinel (or null) clears the assignee. Jira PUT body
680
+ // explicitly sends null to unassign; undefined would be stripped.
681
+ fields['assignee'] = input.assignee
682
+ ? { accountId: await this.resolveAssigneeAccountId(input.assignee) }
683
+ : null
684
+ }
685
+ if (Object.keys(fields).length > 0) {
686
+ await this.client.updateIssue(issueKey, { fields })
687
+ }
688
+ return this.hydrateIssueByKey(issueKey)
689
+ }
690
+
691
+ async moveTask(idOrRef: string, column: string): Promise<Task> {
692
+ await this.sync()
693
+ const task = await this.resolveTaskByIdOrKey(idOrRef)
694
+ return this.moveTaskByKey(this.issueKeyFor(task), column)
695
+ }
696
+
697
+ private async moveTaskByKey(issueKey: string, column: string): Promise<Task> {
698
+ const columnId = await this.resolveColumnId(column)
699
+ const columnRow = (await this.cache.getColumns()).find((c) => c.id === columnId)
700
+ if (!columnRow) {
701
+ throw new KanbanError(
702
+ ErrorCode.COLUMN_NOT_FOUND,
703
+ `Resolved column '${column}' but cache row missing`,
704
+ )
705
+ }
706
+ const statusIds = decodeColumnStatusIds(columnRow)
707
+ if (statusIds.length === 0) {
708
+ providerUpstreamError(`Column '${columnRow.name}' has no mapped Jira statuses.`)
709
+ }
710
+ // First-mapped-status deterministic choice: board columns can map to
711
+ // multiple Jira statuses; we transition to statusIds[0]. Operators who
712
+ // want a different target must reorder the board column's statuses in Jira.
713
+ const targetStatusId = statusIds[0]!
714
+ const { transitions } = await this.client.getTransitions(issueKey)
715
+ const match = transitions.find((t) => t.to.id === targetStatusId)
716
+ if (!match) {
717
+ const currentStatusId = (await this.cache.getCachedTask(issueKey))?.column_id ?? '<unknown>'
718
+ providerUpstreamError(
719
+ `Cannot transition Jira issue ${issueKey} (current status id ${currentStatusId}) to column '${columnRow.name}' (target status id ${targetStatusId}). Available transitions: [${transitions
720
+ .map((t) => `"${t.name}"`)
721
+ .join(', ')}]`,
722
+ )
723
+ }
724
+ await this.client.transitionIssue(issueKey, match.id)
725
+ return this.hydrateIssueByKey(issueKey)
726
+ }
727
+
728
+ async deleteTask(_idOrRef: string): Promise<Task> {
729
+ unsupportedOperation('Task deletion is not supported in Jira mode')
730
+ }
731
+
732
+ async listComments(idOrRef: string): Promise<TaskComment[]> {
733
+ await this.sync()
734
+ const task = await this.resolveTaskByIdOrKey(idOrRef)
735
+ const issueKey = this.issueKeyFor(task)
736
+ const comments: JiraComment[] = []
737
+ let startAt = 0
738
+
739
+ while (true) {
740
+ const page = await this.client.getComments(issueKey, { startAt, maxResults: 100 })
741
+ comments.push(...page.comments)
742
+ startAt += page.comments.length
743
+ if (comments.length >= page.total || page.comments.length === 0) break
744
+ }
745
+
746
+ return comments.map((comment) => this.toTaskComment(task, comment))
747
+ }
748
+
749
+ async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
750
+ await this.sync()
751
+ const task = await this.resolveTaskByIdOrKey(idOrRef)
752
+ const comment = await this.client.getComment(this.issueKeyFor(task), commentId)
753
+ return this.toTaskComment(task, comment)
754
+ }
755
+
756
+ async comment(idOrRef: string, body: string): Promise<TaskComment> {
757
+ await this.sync()
758
+ const task = await this.resolveTaskByIdOrKey(idOrRef)
759
+ const created = await this.client.addComment(this.issueKeyFor(task), {
760
+ body: plainTextToAdf(body),
761
+ })
762
+ await this.cache.adjustIssueCommentCount(task.providerId || task.externalRef || task.id, 1)
763
+ return this.toTaskComment(task, created)
764
+ }
765
+
766
+ async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
767
+ await this.sync()
768
+ const task = await this.resolveTaskByIdOrKey(idOrRef)
769
+ const updated = await this.client.updateComment(this.issueKeyFor(task), commentId, {
770
+ body: plainTextToAdf(body),
771
+ })
772
+ return this.toTaskComment(task, updated)
773
+ }
774
+
775
+ async getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]> {
776
+ await this.sync()
777
+ const lookupIssueId = taskId
778
+ ? ((await this.cache.resolveIssueId(taskId)) ?? undefined)
779
+ : undefined
780
+ const rows = await this.cache.getCachedActivity({
781
+ ...(lookupIssueId !== undefined ? { issueId: lookupIssueId } : {}),
782
+ limit: limit ?? 100,
783
+ })
784
+ return Promise.all(rows.map((row) => this.activityRowToEntry(row)))
785
+ }
786
+
787
+ private async activityRowToEntry(row: JiraActivityRow): Promise<ActivityEntry> {
788
+ // Map status field items to the same 'moved' shape the local provider
789
+ // emits, so dispatch's collector can trigger uniformly. Translate status
790
+ // ids into column ids via the cached column mapping; fall back to the raw
791
+ // status name for unmapped rows so we never drop activity silently.
792
+ const action: ActivityEntry['action'] = row.item_field === 'status' ? 'moved' : 'updated'
793
+ let fromCol = row.from_value
794
+ let toCol = row.to_value
795
+ if (row.item_field === 'status') {
796
+ fromCol = row.from_value
797
+ ? ((await this.statusIdToColumnId(row.from_value)) ?? row.from_value)
798
+ : null
799
+ toCol = row.to_value ? ((await this.statusIdToColumnId(row.to_value)) ?? row.to_value) : null
800
+ }
801
+ return {
802
+ id: `jira-activity:${row.issue_id}:${row.history_id}:${row.item_field}`,
803
+ task_id: `jira:${row.issue_id}`,
804
+ action,
805
+ field_changed: row.item_field,
806
+ old_value: fromCol,
807
+ new_value: toCol,
808
+ timestamp: row.created_at,
809
+ }
810
+ }
811
+
812
+ private async statusIdToColumnId(statusId: string): Promise<string | undefined> {
813
+ const cols = await this.cache.getColumns()
814
+ for (const col of cols) {
815
+ if (decodeColumnStatusIds(col).includes(statusId)) return col.id
816
+ }
817
+ return undefined
818
+ }
819
+
820
+ protected async ingestIssueActivity(issueId: string): Promise<void> {
821
+ const page = await this.client.getChangelog(issueId, { maxResults: 100 })
822
+ const rows: JiraActivityRow[] = []
823
+ for (const entry of page.values) {
824
+ for (const item of entry.items) {
825
+ rows.push({
826
+ issue_id: issueId,
827
+ history_id: entry.id,
828
+ item_field: item.field,
829
+ from_value: item.from ?? null,
830
+ to_value: item.to ?? null,
831
+ created_at: entry.created,
832
+ })
833
+ }
834
+ }
835
+ await this.cache.saveActivity(rows)
836
+ }
837
+
838
+ async getMetrics(): Promise<BoardMetrics> {
839
+ unsupportedOperation('Metrics are not available in Jira mode')
840
+ }
841
+
842
+ async getConfig(): Promise<BoardConfig> {
843
+ await this.sync()
844
+ return this.buildBoardConfig()
845
+ }
846
+
847
+ async patchConfig(_input: Partial<BoardConfig>): Promise<BoardConfig> {
848
+ unsupportedOperation('Config mutation is not supported in Jira mode')
849
+ }
850
+
851
+ async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
852
+ return this.handleWebhookCore(payload)
853
+ }
854
+
855
+ // Shared webhook dispatch. Postgres wraps this with webhook-event auditing;
856
+ // SQLite calls it directly via the default handleWebhook above.
857
+ protected async handleWebhookCore(payload: WebhookRequest): Promise<WebhookResult> {
858
+ const auth = authorizeWebhook({
859
+ secret: process.env['JIRA_WEBHOOK_SECRET'],
860
+ rawBody: payload.rawBody,
861
+ signature: headerLower(payload.headers, 'x-hub-signature'),
862
+ verify: verifySha256HmacSignatureHeader,
863
+ })
864
+ if (auth) return auth
865
+ let body: { webhookEvent?: string; issue?: JiraIssue } = {}
866
+ try {
867
+ body = JSON.parse(payload.rawBody) as typeof body
868
+ } catch {
869
+ return { handled: false, message: 'Invalid JSON body' }
870
+ }
871
+ const event = body.webhookEvent ?? ''
872
+ const issue = body.issue
873
+ if (!issue) return { handled: false, message: `No issue in payload (${event})` }
874
+
875
+ if (event === 'jira:issue_deleted') {
876
+ await this.cache.deleteIssue(issue.id)
877
+ await this.cache.saveSyncMeta({ lastWebhookAt: new Date().toISOString() })
878
+ return { handled: true }
879
+ }
880
+
881
+ if (event === 'jira:issue_created' || event === 'jira:issue_updated') {
882
+ const projectKey = issue.fields.project?.key
883
+ if (projectKey !== this.config.projectKey) {
884
+ return {
885
+ handled: false,
886
+ message: `Ignoring issue from project '${projectKey ?? 'unknown'}'`,
887
+ }
888
+ }
889
+ await this.cache.upsertIssues([
890
+ {
891
+ id: issue.id,
892
+ key: issue.key,
893
+ summary: issue.fields.summary,
894
+ descriptionText: issue.fields.description
895
+ ? adfToPlainText(issue.fields.description as AdfDocument)
896
+ : '',
897
+ statusId: issue.fields.status.id,
898
+ priorityName: issue.fields.priority?.name ?? null,
899
+ issueTypeName: issue.fields.issuetype?.name ?? '',
900
+ assigneeAccountId: issue.fields.assignee?.accountId ?? null,
901
+ assigneeName: issue.fields.assignee?.displayName ?? null,
902
+ labels: issue.fields.labels ?? [],
903
+ commentCount: issue.fields.comment?.total ?? 0,
904
+ projectKey,
905
+ url: `${this.config.baseUrl}/browse/${issue.key}`,
906
+ createdAt: issue.fields.created,
907
+ updatedAt: issue.fields.updated,
908
+ },
909
+ ])
910
+ if (event === 'jira:issue_updated') {
911
+ await this.ingestIssueActivity(issue.id).catch((err) => {
912
+ console.warn(`[jira] activity fetch for webhook issue ${issue.key} failed:`, err)
913
+ })
914
+ }
915
+ await this.cache.saveSyncMeta({ lastWebhookAt: new Date().toISOString() })
916
+ return { handled: true }
917
+ }
918
+
919
+ return { handled: false, message: `Unsupported event: ${event}` }
920
+ }
921
+ }