@andypai/agent-kanban 0.2.0 → 0.3.0

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 (59) hide show
  1. package/README.md +89 -22
  2. package/package.json +4 -2
  3. package/src/__tests__/activity.test.ts +15 -9
  4. package/src/__tests__/api.test.ts +96 -0
  5. package/src/__tests__/board-utils.test.ts +100 -0
  6. package/src/__tests__/commands/board.test.ts +6 -13
  7. package/src/__tests__/conflict.test.ts +64 -0
  8. package/src/__tests__/index.test.ts +233 -56
  9. package/src/__tests__/jira-adf.test.ts +168 -0
  10. package/src/__tests__/jira-cache.test.ts +304 -0
  11. package/src/__tests__/jira-client.test.ts +169 -0
  12. package/src/__tests__/jira-provider-comment.test.ts +281 -0
  13. package/src/__tests__/jira-provider-mutations.test.ts +771 -0
  14. package/src/__tests__/jira-provider-read.test.ts +594 -0
  15. package/src/__tests__/jira-wiring.test.ts +187 -0
  16. package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
  17. package/src/__tests__/linear-provider-comment.test.ts +243 -0
  18. package/src/__tests__/linear-provider-sync.test.ts +493 -0
  19. package/src/__tests__/local-provider-comment.test.ts +60 -0
  20. package/src/__tests__/mcp-core.test.ts +164 -0
  21. package/src/__tests__/mcp-server.test.ts +252 -0
  22. package/src/__tests__/server.test.ts +298 -0
  23. package/src/__tests__/webhooks.test.ts +604 -0
  24. package/src/activity.ts +1 -11
  25. package/src/api.ts +154 -19
  26. package/src/commands/board.ts +1 -11
  27. package/src/commands/mcp.ts +87 -0
  28. package/src/db.ts +115 -3
  29. package/src/errors.ts +2 -0
  30. package/src/id.ts +1 -1
  31. package/src/index.ts +72 -18
  32. package/src/mcp/core.ts +193 -0
  33. package/src/mcp/errors.ts +109 -0
  34. package/src/mcp/index.ts +13 -0
  35. package/src/mcp/server.ts +512 -0
  36. package/src/mcp/types.ts +72 -0
  37. package/src/providers/capabilities.ts +15 -0
  38. package/src/providers/index.ts +31 -1
  39. package/src/providers/jira-adf.ts +275 -0
  40. package/src/providers/jira-cache.ts +625 -0
  41. package/src/providers/jira-client.ts +390 -0
  42. package/src/providers/jira.ts +778 -0
  43. package/src/providers/linear-cache.ts +249 -70
  44. package/src/providers/linear-client.ts +256 -13
  45. package/src/providers/linear.ts +337 -14
  46. package/src/providers/local.ts +68 -17
  47. package/src/providers/types.ts +18 -2
  48. package/src/server.ts +139 -11
  49. package/src/tunnel.ts +79 -0
  50. package/src/types.ts +18 -2
  51. package/src/webhooks.ts +36 -0
  52. package/ui/dist/assets/index-DBnoKL_k.css +1 -0
  53. package/ui/dist/assets/index-qNVJ6clH.js +40 -0
  54. package/ui/dist/index.html +2 -2
  55. package/src/__tests__/commands/task.test.ts +0 -144
  56. package/src/commands/task.ts +0 -117
  57. package/src/fixtures.ts +0 -128
  58. package/ui/dist/assets/index-B8f9NB4z.css +0 -1
  59. package/ui/dist/assets/index-zWp-rB7b.js +0 -40
@@ -0,0 +1,625 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import type { BoardView, ProviderTeamInfo, Task } from '../types.ts'
3
+
4
+ // Column ids are prefixed to avoid collisions across sources:
5
+ // - board-sourced columns: 'board:<boardId>:<columnName>'
6
+ // - status-fallback columns: 'status:<statusId>'
7
+ // The provider (T04) picks ONE source per sync, so mixed-source boards
8
+ // do not occur in practice.
9
+ export interface JiraColumnRow {
10
+ id: string
11
+ name: string
12
+ position: number
13
+ status_ids: string
14
+ source: 'board' | 'status'
15
+ }
16
+
17
+ export interface JiraSyncMeta {
18
+ projectKey: string | null
19
+ boardId: number | null
20
+ lastSyncAt: string | null
21
+ lastIssueUpdatedAt: string | null
22
+ lastFullSyncAt: string | null
23
+ lastWebhookAt: string | null
24
+ }
25
+
26
+ export interface JiraCacheConfig {
27
+ projectKey: string | null
28
+ users: Array<{ accountId: string; displayName: string }>
29
+ priorities: Array<{ id: string; name: string }>
30
+ issueTypes: Array<{ id: string; name: string }>
31
+ }
32
+
33
+ interface JiraIssueRow {
34
+ id: string
35
+ key: string
36
+ summary: string
37
+ description_text: string
38
+ status_id: string
39
+ priority_name: string
40
+ issue_type_name: string
41
+ assignee_account_id: string | null
42
+ assignee_name: string
43
+ labels: string
44
+ comment_count: number
45
+ project_key: string
46
+ url: string | null
47
+ created_at: string
48
+ updated_at: string
49
+ }
50
+
51
+ export function initJiraCacheSchema(db: Database): void {
52
+ db.run(`
53
+ CREATE TABLE IF NOT EXISTS jira_sync_meta (
54
+ key TEXT PRIMARY KEY,
55
+ value TEXT NOT NULL
56
+ )
57
+ `)
58
+ db.run(`
59
+ CREATE TABLE IF NOT EXISTS jira_columns (
60
+ id TEXT PRIMARY KEY,
61
+ name TEXT NOT NULL,
62
+ position INTEGER NOT NULL,
63
+ status_ids TEXT NOT NULL,
64
+ source TEXT NOT NULL CHECK(source IN ('board','status'))
65
+ )
66
+ `)
67
+ db.run(`
68
+ CREATE TABLE IF NOT EXISTS jira_users (
69
+ account_id TEXT PRIMARY KEY,
70
+ display_name TEXT NOT NULL,
71
+ active INTEGER NOT NULL DEFAULT 1,
72
+ updated_at TEXT NOT NULL
73
+ )
74
+ `)
75
+ db.run(`
76
+ CREATE TABLE IF NOT EXISTS jira_priorities (
77
+ id TEXT PRIMARY KEY,
78
+ name TEXT NOT NULL
79
+ )
80
+ `)
81
+ db.run(`
82
+ CREATE TABLE IF NOT EXISTS jira_issue_types (
83
+ id TEXT PRIMARY KEY,
84
+ name TEXT NOT NULL
85
+ )
86
+ `)
87
+ db.run(`
88
+ CREATE TABLE IF NOT EXISTS jira_activity (
89
+ issue_id TEXT NOT NULL,
90
+ history_id TEXT NOT NULL,
91
+ item_field TEXT NOT NULL,
92
+ from_value TEXT,
93
+ to_value TEXT,
94
+ created_at TEXT NOT NULL,
95
+ PRIMARY KEY (issue_id, history_id, item_field)
96
+ )
97
+ `)
98
+ db.run(`
99
+ CREATE INDEX IF NOT EXISTS jira_activity_created_at_idx ON jira_activity(created_at DESC)
100
+ `)
101
+ db.run(`
102
+ CREATE TABLE IF NOT EXISTS jira_issues (
103
+ id TEXT PRIMARY KEY,
104
+ key TEXT NOT NULL UNIQUE,
105
+ summary TEXT NOT NULL,
106
+ description_text TEXT NOT NULL DEFAULT '',
107
+ status_id TEXT NOT NULL,
108
+ priority_name TEXT NOT NULL DEFAULT '',
109
+ issue_type_name TEXT NOT NULL DEFAULT '',
110
+ assignee_account_id TEXT,
111
+ assignee_name TEXT NOT NULL DEFAULT '',
112
+ labels TEXT NOT NULL DEFAULT '[]',
113
+ comment_count INTEGER NOT NULL DEFAULT 0,
114
+ project_key TEXT NOT NULL,
115
+ url TEXT,
116
+ created_at TEXT NOT NULL,
117
+ updated_at TEXT NOT NULL
118
+ )
119
+ `)
120
+ db.run('CREATE INDEX IF NOT EXISTS idx_jira_issues_status_id ON jira_issues(status_id)')
121
+ db.run('CREATE INDEX IF NOT EXISTS idx_jira_issues_updated_at ON jira_issues(updated_at)')
122
+ migrateJiraCacheSchema(db)
123
+ }
124
+
125
+ function migrateJiraCacheSchema(db: Database): void {
126
+ const cols = db.query('PRAGMA table_info(jira_issues)').all() as { name: string }[]
127
+ if (!cols.some((c) => c.name === 'labels')) {
128
+ db.run("ALTER TABLE jira_issues ADD COLUMN labels TEXT NOT NULL DEFAULT '[]'")
129
+ }
130
+ if (!cols.some((c) => c.name === 'comment_count')) {
131
+ db.run('ALTER TABLE jira_issues ADD COLUMN comment_count INTEGER NOT NULL DEFAULT 0')
132
+ }
133
+ }
134
+
135
+ function setMeta(db: Database, key: string, value: string): void {
136
+ db.query(
137
+ `INSERT INTO jira_sync_meta (key, value) VALUES ($key, $value)
138
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
139
+ ).run({ $key: key, $value: value })
140
+ }
141
+
142
+ function deleteMeta(db: Database, key: string): void {
143
+ db.query('DELETE FROM jira_sync_meta WHERE key = $key').run({ $key: key })
144
+ }
145
+
146
+ function getMeta(db: Database, key: string): string | null {
147
+ const row = db.query('SELECT value FROM jira_sync_meta WHERE key = $key').get({
148
+ $key: key,
149
+ }) as { value: string } | null
150
+ return row?.value ?? null
151
+ }
152
+
153
+ const META_KEYS = [
154
+ 'projectKey',
155
+ 'boardId',
156
+ 'lastSyncAt',
157
+ 'lastIssueUpdatedAt',
158
+ 'lastFullSyncAt',
159
+ 'lastWebhookAt',
160
+ ] as const
161
+ type MetaKey = (typeof META_KEYS)[number]
162
+
163
+ export function saveJiraSyncMeta(db: Database, meta: Partial<JiraSyncMeta>): void {
164
+ for (const key of META_KEYS) {
165
+ if (!Object.prototype.hasOwnProperty.call(meta, key)) continue
166
+ const value = (meta as Record<MetaKey, unknown>)[key]
167
+ if (value === null) {
168
+ deleteMeta(db, key)
169
+ continue
170
+ }
171
+ if (key === 'boardId') {
172
+ if (typeof value === 'number' && Number.isFinite(value)) {
173
+ setMeta(db, key, String(value))
174
+ }
175
+ continue
176
+ }
177
+ if (typeof value === 'string') {
178
+ setMeta(db, key, value)
179
+ }
180
+ }
181
+ }
182
+
183
+ export function saveTeamInfo(db: Database, team: ProviderTeamInfo | null): void {
184
+ if (team === null) {
185
+ deleteMeta(db, 'team')
186
+ return
187
+ }
188
+ setMeta(db, 'team', JSON.stringify(team))
189
+ }
190
+
191
+ export function loadTeamInfo(db: Database): ProviderTeamInfo | null {
192
+ const raw = getMeta(db, 'team')
193
+ if (raw === null) return null
194
+ try {
195
+ const parsed = JSON.parse(raw) as unknown
196
+ if (
197
+ parsed &&
198
+ typeof parsed === 'object' &&
199
+ 'id' in parsed &&
200
+ 'key' in parsed &&
201
+ 'name' in parsed &&
202
+ typeof (parsed as { id: unknown }).id === 'string' &&
203
+ typeof (parsed as { key: unknown }).key === 'string' &&
204
+ typeof (parsed as { name: unknown }).name === 'string'
205
+ ) {
206
+ const t = parsed as { id: string; key: string; name: string }
207
+ return { id: t.id, key: t.key, name: t.name }
208
+ }
209
+ return null
210
+ } catch {
211
+ return null
212
+ }
213
+ }
214
+
215
+ export function loadJiraSyncMeta(db: Database): JiraSyncMeta {
216
+ const boardIdRaw = getMeta(db, 'boardId')
217
+ const boardId = boardIdRaw === null ? null : Number.parseInt(boardIdRaw, 10)
218
+ return {
219
+ projectKey: getMeta(db, 'projectKey'),
220
+ boardId: boardId === null || Number.isNaN(boardId) ? null : boardId,
221
+ lastSyncAt: getMeta(db, 'lastSyncAt'),
222
+ lastIssueUpdatedAt: getMeta(db, 'lastIssueUpdatedAt'),
223
+ lastFullSyncAt: getMeta(db, 'lastFullSyncAt'),
224
+ lastWebhookAt: getMeta(db, 'lastWebhookAt'),
225
+ }
226
+ }
227
+
228
+ export function replaceJiraColumns(
229
+ db: Database,
230
+ columns: Array<{
231
+ id: string
232
+ name: string
233
+ position: number
234
+ statusIds: string[]
235
+ source: 'board' | 'status'
236
+ }>,
237
+ ): void {
238
+ const run = db.transaction(() => {
239
+ db.run('DELETE FROM jira_columns')
240
+ const stmt = db.prepare(
241
+ `INSERT INTO jira_columns (id, name, position, status_ids, source)
242
+ VALUES ($id, $name, $position, $status_ids, $source)`,
243
+ )
244
+ for (const column of columns) {
245
+ stmt.run({
246
+ $id: column.id,
247
+ $name: column.name,
248
+ $position: column.position,
249
+ $status_ids: JSON.stringify(column.statusIds),
250
+ $source: column.source,
251
+ })
252
+ }
253
+ })
254
+ run()
255
+ }
256
+
257
+ export function upsertJiraUsers(
258
+ db: Database,
259
+ users: Array<{ accountId: string; displayName: string; active?: boolean }>,
260
+ ): void {
261
+ const stmt = db.prepare(
262
+ `INSERT INTO jira_users (account_id, display_name, active, updated_at)
263
+ VALUES ($account_id, $display_name, $active, datetime('now'))
264
+ ON CONFLICT(account_id) DO UPDATE SET
265
+ display_name = excluded.display_name,
266
+ active = excluded.active,
267
+ updated_at = excluded.updated_at`,
268
+ )
269
+ for (const user of users) {
270
+ stmt.run({
271
+ $account_id: user.accountId,
272
+ $display_name: user.displayName,
273
+ $active: user.active === false ? 0 : 1,
274
+ })
275
+ }
276
+ }
277
+
278
+ export function replaceJiraPriorities(
279
+ db: Database,
280
+ priorities: Array<{ id: string; name: string }>,
281
+ ): void {
282
+ const run = db.transaction(() => {
283
+ db.run('DELETE FROM jira_priorities')
284
+ const stmt = db.prepare('INSERT INTO jira_priorities (id, name) VALUES ($id, $name)')
285
+ for (const priority of priorities) {
286
+ stmt.run({ $id: priority.id, $name: priority.name })
287
+ }
288
+ })
289
+ run()
290
+ }
291
+
292
+ export function replaceJiraIssueTypes(
293
+ db: Database,
294
+ types: Array<{ id: string; name: string }>,
295
+ ): void {
296
+ const run = db.transaction(() => {
297
+ db.run('DELETE FROM jira_issue_types')
298
+ const stmt = db.prepare('INSERT INTO jira_issue_types (id, name) VALUES ($id, $name)')
299
+ for (const type of types) {
300
+ stmt.run({ $id: type.id, $name: type.name })
301
+ }
302
+ })
303
+ run()
304
+ }
305
+
306
+ export function upsertJiraIssues(
307
+ db: Database,
308
+ issues: Array<{
309
+ id: string
310
+ key: string
311
+ summary: string
312
+ descriptionText: string
313
+ statusId: string
314
+ priorityName?: string | null
315
+ issueTypeName?: string | null
316
+ assigneeAccountId?: string | null
317
+ assigneeName?: string | null
318
+ labels?: string[] | null
319
+ commentCount?: number | null
320
+ projectKey: string
321
+ url?: string | null
322
+ createdAt: string
323
+ updatedAt: string
324
+ }>,
325
+ ): void {
326
+ const stmt = db.prepare(
327
+ `INSERT INTO jira_issues (
328
+ id, key, summary, description_text, status_id, priority_name, issue_type_name,
329
+ assignee_account_id, assignee_name, labels, comment_count, project_key, url, created_at, updated_at
330
+ ) VALUES (
331
+ $id, $key, $summary, $description_text, $status_id, $priority_name, $issue_type_name,
332
+ $assignee_account_id, $assignee_name, $labels, $comment_count, $project_key, $url, $created_at, $updated_at
333
+ )
334
+ ON CONFLICT(id) DO UPDATE SET
335
+ key = excluded.key,
336
+ summary = excluded.summary,
337
+ description_text = excluded.description_text,
338
+ status_id = excluded.status_id,
339
+ priority_name = excluded.priority_name,
340
+ issue_type_name = excluded.issue_type_name,
341
+ assignee_account_id = excluded.assignee_account_id,
342
+ assignee_name = excluded.assignee_name,
343
+ labels = excluded.labels,
344
+ comment_count = excluded.comment_count,
345
+ project_key = excluded.project_key,
346
+ url = excluded.url,
347
+ created_at = excluded.created_at,
348
+ updated_at = excluded.updated_at`,
349
+ )
350
+ for (const issue of issues) {
351
+ stmt.run({
352
+ $id: issue.id,
353
+ $key: issue.key,
354
+ $summary: issue.summary,
355
+ $description_text: issue.descriptionText,
356
+ $status_id: issue.statusId,
357
+ $priority_name: issue.priorityName ?? '',
358
+ $issue_type_name: issue.issueTypeName ?? '',
359
+ $assignee_account_id: issue.assigneeAccountId ?? null,
360
+ $assignee_name: issue.assigneeName ?? '',
361
+ $labels: JSON.stringify(issue.labels ?? []),
362
+ $comment_count: issue.commentCount ?? 0,
363
+ $project_key: issue.projectKey,
364
+ $url: issue.url ?? null,
365
+ $created_at: issue.createdAt,
366
+ $updated_at: issue.updatedAt,
367
+ })
368
+ }
369
+ }
370
+
371
+ export function deleteJiraIssue(db: Database, idOrKey: string): void {
372
+ db.query(
373
+ `DELETE FROM jira_activity
374
+ WHERE issue_id = $value
375
+ OR issue_id IN (SELECT id FROM jira_issues WHERE key = $value)`,
376
+ ).run({ $value: idOrKey })
377
+ db.query('DELETE FROM jira_issues WHERE id = $v OR key = $v').run({ $v: idOrKey })
378
+ }
379
+
380
+ export function pruneJiraIssuesMissingUpstream(
381
+ db: Database,
382
+ projectKey: string,
383
+ upstreamIssueIds: string[],
384
+ ): void {
385
+ const run = db.transaction(() => {
386
+ if (upstreamIssueIds.length === 0) {
387
+ db.query(
388
+ `DELETE FROM jira_activity
389
+ WHERE issue_id IN (SELECT id FROM jira_issues WHERE project_key = $projectKey)`,
390
+ ).run({ $projectKey: projectKey })
391
+ db.query('DELETE FROM jira_issues WHERE project_key = $projectKey').run({
392
+ $projectKey: projectKey,
393
+ })
394
+ return
395
+ }
396
+
397
+ const placeholders = upstreamIssueIds.map((_, index) => `$id${index}`).join(', ')
398
+ const params: Record<string, string> = { $projectKey: projectKey }
399
+ upstreamIssueIds.forEach((issueId, index) => {
400
+ params[`$id${index}`] = issueId
401
+ })
402
+
403
+ db.query(
404
+ `DELETE FROM jira_activity
405
+ WHERE issue_id IN (
406
+ SELECT id
407
+ FROM jira_issues
408
+ WHERE project_key = $projectKey
409
+ AND id NOT IN (${placeholders})
410
+ )`,
411
+ ).run(params)
412
+ db.query(
413
+ `DELETE FROM jira_issues
414
+ WHERE project_key = $projectKey
415
+ AND id NOT IN (${placeholders})`,
416
+ ).run(params)
417
+ })
418
+ run()
419
+ }
420
+
421
+ export function adjustJiraIssueCommentCount(db: Database, idOrKey: string, delta: number): void {
422
+ db.query(
423
+ `UPDATE jira_issues
424
+ SET comment_count = MAX(0, comment_count + $delta)
425
+ WHERE id = $value OR key = $value`,
426
+ ).run({
427
+ $delta: delta,
428
+ $value: idOrKey,
429
+ })
430
+ }
431
+
432
+ export function decodeColumnStatusIds(row: Pick<JiraColumnRow, 'status_ids'>): string[] {
433
+ try {
434
+ const parsed: unknown = JSON.parse(row.status_ids)
435
+ return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
436
+ } catch {
437
+ return []
438
+ }
439
+ }
440
+
441
+ export function getCachedColumns(db: Database): JiraColumnRow[] {
442
+ return db.query('SELECT * FROM jira_columns ORDER BY position, name').all() as JiraColumnRow[]
443
+ }
444
+
445
+ function mapPriorityNameToCanonical(name: string): Task['priority'] {
446
+ switch (name.trim().toLowerCase()) {
447
+ case 'highest':
448
+ return 'urgent'
449
+ case 'high':
450
+ return 'high'
451
+ case 'medium':
452
+ return 'medium'
453
+ default:
454
+ return 'low'
455
+ }
456
+ }
457
+
458
+ function parseLabels(raw: string): string[] {
459
+ try {
460
+ const parsed: unknown = JSON.parse(raw)
461
+ return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
462
+ } catch {
463
+ return []
464
+ }
465
+ }
466
+
467
+ function taskFromRow(row: JiraIssueRow): Task {
468
+ return {
469
+ id: `jira:${row.id}`,
470
+ providerId: row.id,
471
+ externalRef: row.key,
472
+ url: row.url,
473
+ title: row.summary,
474
+ description: row.description_text,
475
+ column_id: row.status_id,
476
+ position: 0,
477
+ priority: mapPriorityNameToCanonical(row.priority_name),
478
+ assignee: row.assignee_name,
479
+ assignees: row.assignee_name ? [row.assignee_name] : [],
480
+ labels: parseLabels(row.labels),
481
+ comment_count: row.comment_count,
482
+ project: row.project_key,
483
+ metadata: '{}',
484
+ created_at: row.created_at,
485
+ updated_at: row.updated_at,
486
+ version: row.updated_at,
487
+ source_updated_at: row.updated_at,
488
+ }
489
+ }
490
+
491
+ function selectIssuesByStatusIds(db: Database, statusIds: string[]): JiraIssueRow[] {
492
+ if (statusIds.length === 0) return []
493
+ const placeholders = statusIds.map((_, i) => `$s${i}`).join(', ')
494
+ const params: Record<string, string> = {}
495
+ statusIds.forEach((id, i) => {
496
+ params[`$s${i}`] = id
497
+ })
498
+ return db
499
+ .query(
500
+ `SELECT * FROM jira_issues
501
+ WHERE status_id IN (${placeholders})
502
+ ORDER BY updated_at DESC, summary ASC`,
503
+ )
504
+ .all(params) as JiraIssueRow[]
505
+ }
506
+
507
+ export function getCachedBoard(db: Database): BoardView {
508
+ const columns = getCachedColumns(db)
509
+ return {
510
+ columns: columns.map((column) => {
511
+ const statusIds = decodeColumnStatusIds(column)
512
+ const tasks = selectIssuesByStatusIds(db, statusIds).map(taskFromRow)
513
+ return {
514
+ id: column.id,
515
+ name: column.name,
516
+ position: column.position,
517
+ color: null,
518
+ created_at: '',
519
+ updated_at: '',
520
+ tasks,
521
+ }
522
+ }),
523
+ }
524
+ }
525
+
526
+ export function getCachedTask(db: Database, lookup: string): Task | null {
527
+ const normalized = lookup.startsWith('jira:') ? lookup.slice('jira:'.length) : lookup
528
+ const row = db
529
+ .query(
530
+ `SELECT * FROM jira_issues
531
+ WHERE id = $lookup OR key = $lookup
532
+ LIMIT 1`,
533
+ )
534
+ .get({ $lookup: normalized }) as JiraIssueRow | null
535
+ return row ? taskFromRow(row) : null
536
+ }
537
+
538
+ export function getCachedTasks(db: Database, params?: { columnId?: string }): Task[] {
539
+ if (params?.columnId !== undefined) {
540
+ const columnRow = db
541
+ .query('SELECT status_ids FROM jira_columns WHERE id = $id')
542
+ .get({ $id: params.columnId }) as Pick<JiraColumnRow, 'status_ids'> | null
543
+ if (!columnRow) return []
544
+ const statusIds = decodeColumnStatusIds(columnRow)
545
+ return selectIssuesByStatusIds(db, statusIds).map(taskFromRow)
546
+ }
547
+ return (
548
+ db
549
+ .query('SELECT * FROM jira_issues ORDER BY updated_at DESC, summary ASC')
550
+ .all() as JiraIssueRow[]
551
+ ).map(taskFromRow)
552
+ }
553
+
554
+ export function getCachedConfig(db: Database): JiraCacheConfig {
555
+ const users = (
556
+ db
557
+ .query(
558
+ 'SELECT account_id, display_name FROM jira_users WHERE active = 1 ORDER BY display_name',
559
+ )
560
+ .all() as { account_id: string; display_name: string }[]
561
+ ).map((row) => ({ accountId: row.account_id, displayName: row.display_name }))
562
+ const priorities = db.query('SELECT id, name FROM jira_priorities ORDER BY name').all() as Array<{
563
+ id: string
564
+ name: string
565
+ }>
566
+ const issueTypes = db
567
+ .query('SELECT id, name FROM jira_issue_types ORDER BY name')
568
+ .all() as Array<{ id: string; name: string }>
569
+ return {
570
+ projectKey: getMeta(db, 'projectKey'),
571
+ users,
572
+ priorities,
573
+ issueTypes,
574
+ }
575
+ }
576
+
577
+ export interface JiraActivityRow {
578
+ issue_id: string
579
+ history_id: string
580
+ item_field: string
581
+ from_value: string | null
582
+ to_value: string | null
583
+ created_at: string
584
+ }
585
+
586
+ export function saveJiraActivity(db: Database, rows: JiraActivityRow[]): void {
587
+ if (rows.length === 0) return
588
+ const stmt = db.prepare(
589
+ `INSERT OR IGNORE INTO jira_activity
590
+ (issue_id, history_id, item_field, from_value, to_value, created_at)
591
+ VALUES (?, ?, ?, ?, ?, ?)`,
592
+ )
593
+ const tx = db.transaction((items: JiraActivityRow[]) => {
594
+ for (const r of items) {
595
+ stmt.run(r.issue_id, r.history_id, r.item_field, r.from_value, r.to_value, r.created_at)
596
+ }
597
+ })
598
+ tx(rows)
599
+ }
600
+
601
+ export function getCachedActivity(
602
+ db: Database,
603
+ params: { issueId?: string; limit?: number } = {},
604
+ ): JiraActivityRow[] {
605
+ const limit = params.limit ?? 100
606
+ if (params.issueId) {
607
+ return db
608
+ .query(
609
+ `SELECT issue_id, history_id, item_field, from_value, to_value, created_at
610
+ FROM jira_activity
611
+ WHERE issue_id = $issueId
612
+ ORDER BY created_at DESC
613
+ LIMIT $limit`,
614
+ )
615
+ .all({ $issueId: params.issueId, $limit: limit }) as JiraActivityRow[]
616
+ }
617
+ return db
618
+ .query(
619
+ `SELECT issue_id, history_id, item_field, from_value, to_value, created_at
620
+ FROM jira_activity
621
+ ORDER BY created_at DESC
622
+ LIMIT $limit`,
623
+ )
624
+ .all({ $limit: limit }) as JiraActivityRow[]
625
+ }