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