@andypai/agent-kanban 0.6.0 → 0.6.2

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 +91 -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 +926 -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 +693 -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 +19 -0
  46. package/ui/dist/assets/index-65LcV0R7.js +40 -0
  47. package/ui/dist/index.html +1 -1
  48. package/ui/dist/assets/index-CFhtfqCn.js +0 -40
@@ -0,0 +1,597 @@
1
+ import type { Sql } from 'postgres'
2
+
3
+ import type { BoardView, ProviderTeamInfo, Task } from '../types'
4
+ import {
5
+ decodeColumnStatusIds,
6
+ type JiraActivityRow,
7
+ type JiraCacheConfig,
8
+ type JiraColumnRow,
9
+ type JiraSyncMeta,
10
+ } from './jira-cache'
11
+ import type { JiraCachePort } from './jira-core'
12
+ import { ensureWebhookEventsSchema } from '../webhook-events'
13
+
14
+ export interface JiraIssueRow {
15
+ id: string
16
+ key: string
17
+ summary: string
18
+ description_text: string
19
+ status_id: string
20
+ priority_name: string
21
+ issue_type_name: string
22
+ assignee_account_id: string | null
23
+ assignee_name: string
24
+ labels: string
25
+ comment_count: number
26
+ project_key: string
27
+ url: string | null
28
+ created_at: string
29
+ updated_at: string
30
+ }
31
+
32
+ function mapPriorityNameToCanonical(name: string): Task['priority'] {
33
+ switch (name.trim().toLowerCase()) {
34
+ case 'highest':
35
+ return 'urgent'
36
+ case 'high':
37
+ return 'high'
38
+ case 'medium':
39
+ return 'medium'
40
+ default:
41
+ return 'low'
42
+ }
43
+ }
44
+
45
+ function parseLabels(raw: string): string[] {
46
+ try {
47
+ const parsed: unknown = JSON.parse(raw)
48
+ return Array.isArray(parsed)
49
+ ? parsed.filter((value): value is string => typeof value === 'string')
50
+ : []
51
+ } catch {
52
+ return []
53
+ }
54
+ }
55
+
56
+ function taskFromRow(row: JiraIssueRow): Task {
57
+ return {
58
+ id: `jira:${row.id}`,
59
+ providerId: row.id,
60
+ externalRef: row.key,
61
+ url: row.url,
62
+ title: row.summary,
63
+ description: row.description_text,
64
+ column_id: row.status_id,
65
+ position: 0,
66
+ priority: mapPriorityNameToCanonical(row.priority_name),
67
+ assignee: row.assignee_name,
68
+ assignees: row.assignee_name ? [row.assignee_name] : [],
69
+ labels: parseLabels(row.labels),
70
+ comment_count: row.comment_count,
71
+ project: row.project_key,
72
+ metadata: '{}',
73
+ created_at: row.created_at,
74
+ updated_at: row.updated_at,
75
+ version: row.updated_at,
76
+ source_updated_at: row.updated_at,
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Postgres-backed cache/repository for the Jira provider. Mirrors the role of the
82
+ * SQLite-side `jira-cache.ts` free functions, but as an instance that owns the
83
+ * async `postgres.js` client and its own schema-readiness promise. Holds only
84
+ * cache I/O (persistence + materialization); API sync and business logic stay in
85
+ * `PostgresJiraProvider`.
86
+ */
87
+ export class PostgresJiraCache implements JiraCachePort {
88
+ readonly ready: Promise<void>
89
+
90
+ constructor(private readonly sql: Sql) {
91
+ this.ready = this.ensureSchema()
92
+ }
93
+
94
+ private async ensureSchema(): Promise<void> {
95
+ await this.sql`
96
+ CREATE TABLE IF NOT EXISTS jira_sync_meta (
97
+ key TEXT PRIMARY KEY,
98
+ value TEXT NOT NULL
99
+ )
100
+ `
101
+ await this.sql`
102
+ CREATE TABLE IF NOT EXISTS jira_columns (
103
+ id TEXT PRIMARY KEY,
104
+ name TEXT NOT NULL,
105
+ position INTEGER NOT NULL,
106
+ status_ids TEXT NOT NULL,
107
+ source TEXT NOT NULL CHECK(source IN ('board','status'))
108
+ )
109
+ `
110
+ await this.sql`
111
+ CREATE TABLE IF NOT EXISTS jira_users (
112
+ account_id TEXT PRIMARY KEY,
113
+ display_name TEXT NOT NULL,
114
+ active INTEGER NOT NULL DEFAULT 1,
115
+ updated_at TEXT NOT NULL
116
+ )
117
+ `
118
+ await this.sql`
119
+ CREATE TABLE IF NOT EXISTS jira_priorities (
120
+ id TEXT PRIMARY KEY,
121
+ name TEXT NOT NULL
122
+ )
123
+ `
124
+ await this.sql`
125
+ CREATE TABLE IF NOT EXISTS jira_issue_types (
126
+ id TEXT PRIMARY KEY,
127
+ name TEXT NOT NULL
128
+ )
129
+ `
130
+ await this.sql`
131
+ CREATE TABLE IF NOT EXISTS jira_activity (
132
+ issue_id TEXT NOT NULL,
133
+ history_id TEXT NOT NULL,
134
+ item_field TEXT NOT NULL,
135
+ from_value TEXT,
136
+ to_value TEXT,
137
+ created_at TEXT NOT NULL,
138
+ PRIMARY KEY (issue_id, history_id, item_field)
139
+ )
140
+ `
141
+ await this.sql`
142
+ CREATE TABLE IF NOT EXISTS jira_issues (
143
+ id TEXT PRIMARY KEY,
144
+ key TEXT NOT NULL UNIQUE,
145
+ summary TEXT NOT NULL,
146
+ description_text TEXT NOT NULL DEFAULT '',
147
+ status_id TEXT NOT NULL,
148
+ priority_name TEXT NOT NULL DEFAULT '',
149
+ issue_type_name TEXT NOT NULL DEFAULT '',
150
+ assignee_account_id TEXT,
151
+ assignee_name TEXT NOT NULL DEFAULT '',
152
+ labels TEXT NOT NULL DEFAULT '[]',
153
+ comment_count INTEGER NOT NULL DEFAULT 0,
154
+ project_key TEXT NOT NULL,
155
+ url TEXT,
156
+ created_at TEXT NOT NULL,
157
+ updated_at TEXT NOT NULL
158
+ )
159
+ `
160
+ await this.sql`CREATE INDEX IF NOT EXISTS idx_jira_issues_status_id ON jira_issues(status_id)`
161
+ await this.sql`CREATE INDEX IF NOT EXISTS idx_jira_issues_updated_at ON jira_issues(updated_at)`
162
+ await this.sql`
163
+ CREATE INDEX IF NOT EXISTS jira_activity_created_at_idx ON jira_activity(created_at DESC)
164
+ `
165
+ await ensureWebhookEventsSchema(this.sql)
166
+ }
167
+
168
+ async setMeta(key: string, value: string): Promise<void> {
169
+ await this.sql`
170
+ INSERT INTO jira_sync_meta (key, value)
171
+ VALUES (${key}, ${value})
172
+ ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value
173
+ `
174
+ }
175
+
176
+ async deleteMeta(key: string): Promise<void> {
177
+ await this.sql`DELETE FROM jira_sync_meta WHERE key = ${key}`
178
+ }
179
+
180
+ async getMeta(key: string): Promise<string | null> {
181
+ const [row] = await this.sql<{ value: string }[]>`
182
+ SELECT value FROM jira_sync_meta WHERE key = ${key}
183
+ `
184
+ return row?.value ?? null
185
+ }
186
+
187
+ async saveSyncMeta(meta: Partial<JiraSyncMeta>): Promise<void> {
188
+ const keys = [
189
+ 'projectKey',
190
+ 'boardId',
191
+ 'lastSyncAt',
192
+ 'lastIssueUpdatedAt',
193
+ 'lastFullSyncAt',
194
+ 'lastWebhookAt',
195
+ ] as const
196
+ for (const key of keys) {
197
+ if (!Object.prototype.hasOwnProperty.call(meta, key)) continue
198
+ const value = meta[key]
199
+ if (value === null) {
200
+ await this.deleteMeta(key)
201
+ continue
202
+ }
203
+ if (key === 'boardId') {
204
+ if (typeof value === 'number' && Number.isFinite(value))
205
+ await this.setMeta(key, String(value))
206
+ continue
207
+ }
208
+ if (typeof value === 'string') await this.setMeta(key, value)
209
+ }
210
+ }
211
+
212
+ async loadSyncMeta(): Promise<JiraSyncMeta> {
213
+ const boardIdRaw = await this.getMeta('boardId')
214
+ const boardId = boardIdRaw === null ? null : Number.parseInt(boardIdRaw, 10)
215
+ return {
216
+ projectKey: await this.getMeta('projectKey'),
217
+ boardId: boardId === null || Number.isNaN(boardId) ? null : boardId,
218
+ lastSyncAt: await this.getMeta('lastSyncAt'),
219
+ lastIssueUpdatedAt: await this.getMeta('lastIssueUpdatedAt'),
220
+ lastFullSyncAt: await this.getMeta('lastFullSyncAt'),
221
+ lastWebhookAt: await this.getMeta('lastWebhookAt'),
222
+ }
223
+ }
224
+
225
+ async saveTeamInfo(team: ProviderTeamInfo | null): Promise<void> {
226
+ if (team === null) {
227
+ await this.deleteMeta('team')
228
+ return
229
+ }
230
+ await this.setMeta('team', JSON.stringify(team))
231
+ }
232
+
233
+ async loadTeamInfo(): Promise<ProviderTeamInfo | null> {
234
+ const raw = await this.getMeta('team')
235
+ if (raw === null) return null
236
+ try {
237
+ const parsed = JSON.parse(raw) as ProviderTeamInfo
238
+ return typeof parsed.id === 'string' &&
239
+ typeof parsed.key === 'string' &&
240
+ typeof parsed.name === 'string'
241
+ ? parsed
242
+ : null
243
+ } catch {
244
+ return null
245
+ }
246
+ }
247
+
248
+ // Catalog refreshes (columns, priorities, issue types) UPSERT the current rows
249
+ // on every sync — UPSERT serializes per row and never collides on the primary
250
+ // key when multiple Dispatch replicas refresh concurrently, so a newly-created
251
+ // status/column/priority is reflected on the next sync. The obsolete-row DELETE
252
+ // (`prune`) runs ONLY on a full reconcile, mirroring how upstream-missing issues
253
+ // are pruned (see pruneIssuesMissingUpstream): a delta sync's snapshot can be
254
+ // stale, and a stale snapshot's DELETE would drop a row another replica just
255
+ // added with a fresher snapshot. Confining the delete to the periodic full
256
+ // reconcile (and self-healing via the every-sync UPSERT) keeps catalog pruning
257
+ // consistent with issue pruning and out of the common delta path.
258
+ async replaceColumns(
259
+ columns: Array<{
260
+ id: string
261
+ name: string
262
+ position: number
263
+ statusIds: string[]
264
+ source: 'board' | 'status'
265
+ }>,
266
+ prune: boolean,
267
+ ): Promise<void> {
268
+ for (const column of columns) {
269
+ await this.sql`
270
+ INSERT INTO jira_columns (id, name, position, status_ids, source)
271
+ VALUES (
272
+ ${column.id},
273
+ ${column.name},
274
+ ${column.position},
275
+ ${JSON.stringify(column.statusIds)},
276
+ ${column.source}
277
+ )
278
+ ON CONFLICT(id) DO UPDATE SET
279
+ name = EXCLUDED.name,
280
+ position = EXCLUDED.position,
281
+ status_ids = EXCLUDED.status_ids,
282
+ source = EXCLUDED.source
283
+ `
284
+ }
285
+ if (prune) {
286
+ await this.sql`DELETE FROM jira_columns WHERE NOT (id = ANY(${columns.map((c) => c.id)}))`
287
+ }
288
+ }
289
+
290
+ async upsertUsers(
291
+ users: Array<{ accountId: string; displayName: string; active?: boolean }>,
292
+ ): Promise<void> {
293
+ for (const user of users) {
294
+ await this.sql`
295
+ INSERT INTO jira_users (account_id, display_name, active, updated_at)
296
+ VALUES (${user.accountId}, ${user.displayName}, ${user.active === false ? 0 : 1}, ${new Date().toISOString()})
297
+ ON CONFLICT(account_id) DO UPDATE SET
298
+ display_name = EXCLUDED.display_name,
299
+ active = EXCLUDED.active,
300
+ updated_at = EXCLUDED.updated_at
301
+ `
302
+ }
303
+ }
304
+
305
+ async replacePriorities(
306
+ priorities: Array<{ id: string; name: string }>,
307
+ prune: boolean,
308
+ ): Promise<void> {
309
+ for (const priority of priorities) {
310
+ await this.sql`
311
+ INSERT INTO jira_priorities (id, name)
312
+ VALUES (${priority.id}, ${priority.name})
313
+ ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name
314
+ `
315
+ }
316
+ if (prune) {
317
+ await this
318
+ .sql`DELETE FROM jira_priorities WHERE NOT (id = ANY(${priorities.map((p) => p.id)}))`
319
+ }
320
+ }
321
+
322
+ async replaceIssueTypes(
323
+ types: Array<{ id: string; name: string }>,
324
+ prune: boolean,
325
+ ): Promise<void> {
326
+ for (const type of types) {
327
+ await this.sql`
328
+ INSERT INTO jira_issue_types (id, name)
329
+ VALUES (${type.id}, ${type.name})
330
+ ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name
331
+ `
332
+ }
333
+ if (prune) {
334
+ await this.sql`DELETE FROM jira_issue_types WHERE NOT (id = ANY(${types.map((t) => t.id)}))`
335
+ }
336
+ }
337
+
338
+ async upsertIssues(
339
+ issues: Array<{
340
+ id: string
341
+ key: string
342
+ summary: string
343
+ descriptionText: string
344
+ statusId: string
345
+ priorityName?: string | null
346
+ issueTypeName?: string | null
347
+ assigneeAccountId?: string | null
348
+ assigneeName?: string | null
349
+ labels?: string[] | null
350
+ commentCount?: number | null
351
+ projectKey: string
352
+ url?: string | null
353
+ createdAt: string
354
+ updatedAt: string
355
+ }>,
356
+ ): Promise<void> {
357
+ if (issues.length === 0) return
358
+ await this.sql.begin(async (tx) => {
359
+ await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-jira:issues'))`
360
+ for (const issue of issues) {
361
+ await tx`
362
+ INSERT INTO jira_issues (
363
+ id, key, summary, description_text, status_id, priority_name, issue_type_name,
364
+ assignee_account_id, assignee_name, labels, comment_count, project_key, url, created_at, updated_at
365
+ ) VALUES (
366
+ ${issue.id}, ${issue.key}, ${issue.summary}, ${issue.descriptionText}, ${issue.statusId},
367
+ ${issue.priorityName ?? ''}, ${issue.issueTypeName ?? ''}, ${issue.assigneeAccountId ?? null},
368
+ ${issue.assigneeName ?? ''}, ${JSON.stringify(issue.labels ?? [])}, ${issue.commentCount ?? 0},
369
+ ${issue.projectKey}, ${issue.url ?? null}, ${issue.createdAt}, ${issue.updatedAt}
370
+ )
371
+ ON CONFLICT(id) DO UPDATE SET
372
+ key = EXCLUDED.key,
373
+ summary = EXCLUDED.summary,
374
+ description_text = EXCLUDED.description_text,
375
+ status_id = EXCLUDED.status_id,
376
+ priority_name = EXCLUDED.priority_name,
377
+ issue_type_name = EXCLUDED.issue_type_name,
378
+ assignee_account_id = EXCLUDED.assignee_account_id,
379
+ assignee_name = EXCLUDED.assignee_name,
380
+ labels = EXCLUDED.labels,
381
+ comment_count = EXCLUDED.comment_count,
382
+ project_key = EXCLUDED.project_key,
383
+ url = EXCLUDED.url,
384
+ created_at = EXCLUDED.created_at,
385
+ updated_at = EXCLUDED.updated_at
386
+ `
387
+ }
388
+ })
389
+ }
390
+
391
+ async deleteIssue(idOrKey: string): Promise<void> {
392
+ await this.sql`
393
+ DELETE FROM jira_activity
394
+ WHERE issue_id = ${idOrKey}
395
+ OR issue_id IN (SELECT id FROM jira_issues WHERE key = ${idOrKey})
396
+ `
397
+ await this.sql`DELETE FROM jira_issues WHERE id = ${idOrKey} OR key = ${idOrKey}`
398
+ }
399
+
400
+ async pruneIssuesMissingUpstream(projectKey: string, upstreamIssueIds: string[]): Promise<void> {
401
+ if (upstreamIssueIds.length === 0) {
402
+ await this.sql`
403
+ DELETE FROM jira_activity
404
+ WHERE issue_id IN (SELECT id FROM jira_issues WHERE project_key = ${projectKey})
405
+ `
406
+ await this.sql`DELETE FROM jira_issues WHERE project_key = ${projectKey}`
407
+ return
408
+ }
409
+
410
+ await this.sql`
411
+ DELETE FROM jira_activity
412
+ WHERE issue_id IN (
413
+ SELECT id FROM jira_issues
414
+ WHERE project_key = ${projectKey}
415
+ AND NOT (id = ANY(${upstreamIssueIds}))
416
+ )
417
+ `
418
+ await this.sql`
419
+ DELETE FROM jira_issues
420
+ WHERE project_key = ${projectKey}
421
+ AND NOT (id = ANY(${upstreamIssueIds}))
422
+ `
423
+ }
424
+
425
+ async getColumns(): Promise<JiraColumnRow[]> {
426
+ await this.ready
427
+ return this.sql<JiraColumnRow[]>`SELECT * FROM jira_columns ORDER BY position, name`
428
+ }
429
+
430
+ private async selectIssuesByStatusIds(statusIds: string[]): Promise<JiraIssueRow[]> {
431
+ if (statusIds.length === 0) return []
432
+ return this.sql<JiraIssueRow[]>`
433
+ SELECT * FROM jira_issues
434
+ WHERE status_id = ANY(${statusIds})
435
+ ORDER BY updated_at DESC, summary ASC
436
+ `
437
+ }
438
+
439
+ async getCachedBoard(): Promise<BoardView> {
440
+ const columns = await this.getColumns()
441
+ const boardColumns = []
442
+ for (const column of columns) {
443
+ const tasks = (await this.selectIssuesByStatusIds(decodeColumnStatusIds(column))).map(
444
+ taskFromRow,
445
+ )
446
+ boardColumns.push({
447
+ id: column.id,
448
+ name: column.name,
449
+ position: column.position,
450
+ color: null,
451
+ created_at: '',
452
+ updated_at: '',
453
+ tasks,
454
+ })
455
+ }
456
+ return { columns: boardColumns }
457
+ }
458
+
459
+ async getCachedTask(lookup: string): Promise<Task | null> {
460
+ const normalized = lookup.startsWith('jira:') ? lookup.slice('jira:'.length) : lookup
461
+ const [row] = await this.sql<JiraIssueRow[]>`
462
+ SELECT * FROM jira_issues
463
+ WHERE id = ${normalized} OR key = ${normalized}
464
+ LIMIT 1
465
+ `
466
+ return row ? taskFromRow(row) : null
467
+ }
468
+
469
+ async adjustIssueCommentCount(idOrKey: string, delta: number): Promise<void> {
470
+ await this.sql`
471
+ UPDATE jira_issues
472
+ SET comment_count = GREATEST(0, comment_count + ${delta})
473
+ WHERE id = ${idOrKey} OR key = ${idOrKey}
474
+ `
475
+ }
476
+
477
+ async getCachedTasks(params?: { columnId?: string }): Promise<Task[]> {
478
+ if (params?.columnId !== undefined) {
479
+ const [columnRow] = await this.sql<Pick<JiraColumnRow, 'status_ids'>[]>`
480
+ SELECT status_ids FROM jira_columns WHERE id = ${params.columnId}
481
+ `
482
+ if (!columnRow) return []
483
+ return (await this.selectIssuesByStatusIds(decodeColumnStatusIds(columnRow))).map(taskFromRow)
484
+ }
485
+ return (
486
+ await this.sql<JiraIssueRow[]>`
487
+ SELECT * FROM jira_issues ORDER BY updated_at DESC, summary ASC
488
+ `
489
+ ).map(taskFromRow)
490
+ }
491
+
492
+ async getCachedConfig(): Promise<JiraCacheConfig> {
493
+ const users = (
494
+ await this.sql<{ account_id: string; display_name: string }[]>`
495
+ SELECT account_id, display_name
496
+ FROM jira_users
497
+ WHERE active = 1
498
+ ORDER BY display_name
499
+ `
500
+ ).map((row) => ({ accountId: row.account_id, displayName: row.display_name }))
501
+ const priorities = await this.sql<Array<{ id: string; name: string }>>`
502
+ SELECT id, name FROM jira_priorities ORDER BY name
503
+ `
504
+ const issueTypes = await this.sql<Array<{ id: string; name: string }>>`
505
+ SELECT id, name FROM jira_issue_types ORDER BY name
506
+ `
507
+ return {
508
+ projectKey: await this.getMeta('projectKey'),
509
+ users,
510
+ priorities,
511
+ issueTypes,
512
+ }
513
+ }
514
+
515
+ async saveActivity(rows: JiraActivityRow[]): Promise<void> {
516
+ for (const row of rows) {
517
+ await this.sql`
518
+ INSERT INTO jira_activity (issue_id, history_id, item_field, from_value, to_value, created_at)
519
+ VALUES (${row.issue_id}, ${row.history_id}, ${row.item_field}, ${row.from_value}, ${row.to_value}, ${row.created_at})
520
+ ON CONFLICT(issue_id, history_id, item_field) DO NOTHING
521
+ `
522
+ }
523
+ }
524
+
525
+ async getCachedActivity(
526
+ params: { issueId?: string; limit?: number } = {},
527
+ ): Promise<JiraActivityRow[]> {
528
+ const limit = params.limit ?? 100
529
+ if (params.issueId) {
530
+ return this.sql<JiraActivityRow[]>`
531
+ SELECT issue_id, history_id, item_field, from_value, to_value, created_at
532
+ FROM jira_activity
533
+ WHERE issue_id = ${params.issueId}
534
+ ORDER BY created_at DESC
535
+ LIMIT ${limit}
536
+ `
537
+ }
538
+ return this.sql<JiraActivityRow[]>`
539
+ SELECT issue_id, history_id, item_field, from_value, to_value, created_at
540
+ FROM jira_activity
541
+ ORDER BY created_at DESC
542
+ LIMIT ${limit}
543
+ `
544
+ }
545
+
546
+ async getDiscoveredAssignees(): Promise<string[]> {
547
+ return (
548
+ await this.sql<{ assignee_name: string }[]>`
549
+ SELECT DISTINCT assignee_name FROM jira_issues WHERE assignee_name != '' ORDER BY assignee_name
550
+ `
551
+ ).map((row) => row.assignee_name)
552
+ }
553
+
554
+ async findPriorityName(wanted: string): Promise<string | null> {
555
+ const [row] = await this.sql<{ name: string }[]>`
556
+ SELECT name FROM jira_priorities WHERE LOWER(name) = LOWER(${wanted}) LIMIT 1
557
+ `
558
+ return row?.name ?? null
559
+ }
560
+
561
+ async getPriorityNames(): Promise<string[]> {
562
+ return (await this.sql<{ name: string }[]>`SELECT name FROM jira_priorities ORDER BY name`).map(
563
+ (row) => row.name,
564
+ )
565
+ }
566
+
567
+ async findActiveAssigneeAccountId(displayName: string): Promise<string | null> {
568
+ const [row] = await this.sql<{ account_id: string }[]>`
569
+ SELECT account_id
570
+ FROM jira_users
571
+ WHERE active = 1 AND LOWER(display_name) = LOWER(${displayName})
572
+ LIMIT 1
573
+ `
574
+ return row?.account_id ?? null
575
+ }
576
+
577
+ async findIssueTypeId(name: string): Promise<string | null> {
578
+ const [row] = await this.sql<{ id: string }[]>`
579
+ SELECT id FROM jira_issue_types WHERE LOWER(name) = LOWER(${name}) LIMIT 1
580
+ `
581
+ return row?.id ?? null
582
+ }
583
+
584
+ async getIssueTypeNames(): Promise<string[]> {
585
+ return (
586
+ await this.sql<{ name: string }[]>`SELECT name FROM jira_issue_types ORDER BY name`
587
+ ).map((row) => row.name)
588
+ }
589
+
590
+ async resolveIssueId(lookup: string): Promise<string | null> {
591
+ const normalized = lookup.startsWith('jira:') ? lookup.slice('jira:'.length) : lookup
592
+ const [row] = await this.sql<{ id: string }[]>`
593
+ SELECT id FROM jira_issues WHERE id = ${normalized} OR key = ${normalized} LIMIT 1
594
+ `
595
+ return row?.id ?? null
596
+ }
597
+ }