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