@andypai/agent-kanban 0.6.3 → 0.6.5

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 (45) hide show
  1. package/package.json +11 -3
  2. package/src/__tests__/index.test.ts +27 -1
  3. package/src/__tests__/jira-cache.test.ts +68 -0
  4. package/src/__tests__/jira-provider-read.test.ts +130 -0
  5. package/src/__tests__/jira-wiring.test.ts +41 -0
  6. package/src/__tests__/linear-provider-comment.test.ts +81 -0
  7. package/src/__tests__/linear-provider-sync.test.ts +107 -0
  8. package/src/__tests__/linear-state-resolution.test.ts +60 -0
  9. package/src/__tests__/local-provider-core.test.ts +245 -0
  10. package/src/__tests__/postgres-jira-provider.test.ts +55 -0
  11. package/src/__tests__/postgres-linear-provider.test.ts +90 -0
  12. package/src/__tests__/postgres-local-provider.test.ts +26 -0
  13. package/src/__tests__/provider-sync-core.test.ts +150 -0
  14. package/src/__tests__/provider-team-info.test.ts +37 -0
  15. package/src/__tests__/ui-board-slice.test.ts +14 -0
  16. package/src/__tests__/use-cases.test.ts +21 -42
  17. package/src/api.ts +78 -132
  18. package/src/column-roles.ts +3 -2
  19. package/src/index.ts +135 -92
  20. package/src/mcp/core.ts +7 -8
  21. package/src/provider-runtime.ts +12 -56
  22. package/src/providers/cache-task-mappers.ts +123 -0
  23. package/src/providers/factory.ts +103 -0
  24. package/src/providers/index.ts +4 -25
  25. package/src/providers/jira-cache.ts +41 -99
  26. package/src/providers/jira-core.ts +81 -81
  27. package/src/providers/linear-cache.ts +42 -69
  28. package/src/providers/linear-core.ts +44 -56
  29. package/src/providers/local-core.ts +226 -0
  30. package/src/providers/local.ts +6 -182
  31. package/src/providers/postgres-batch.ts +10 -0
  32. package/src/providers/postgres-jira-cache.ts +255 -213
  33. package/src/providers/postgres-jira.ts +2 -20
  34. package/src/providers/postgres-linear-cache.ts +279 -183
  35. package/src/providers/postgres-linear.ts +2 -20
  36. package/src/providers/postgres-local.ts +22 -47
  37. package/src/providers/sqlite-local-store.ts +121 -0
  38. package/src/providers/sync-core.ts +91 -0
  39. package/src/providers/team-info.ts +24 -0
  40. package/src/providers/warn-once.ts +15 -0
  41. package/src/use-cases.ts +7 -131
  42. package/src/webhook-events.ts +29 -1
  43. package/ui/dist/assets/index-DcZH7fI3.js +40 -0
  44. package/ui/dist/index.html +1 -1
  45. package/ui/dist/assets/index-Cigv8a9S.js +0 -40
@@ -0,0 +1,123 @@
1
+ import type { Task } from '../types'
2
+
3
+ export interface JiraTaskRow {
4
+ id: string
5
+ key: string
6
+ summary: string
7
+ description_text: string
8
+ status_id: string
9
+ priority_name: string
10
+ issue_type_name: string
11
+ assignee_account_id: string | null
12
+ assignee_name: string
13
+ labels: string
14
+ comment_count: number
15
+ project_key: string
16
+ url: string | null
17
+ created_at: string
18
+ updated_at: string
19
+ }
20
+
21
+ export interface LinearTaskRow {
22
+ id: string
23
+ identifier: string
24
+ title: string
25
+ description: string
26
+ state_id: string
27
+ state_position: number
28
+ priority: number
29
+ assignee_name: string
30
+ project_name: string
31
+ labels: string
32
+ comment_count: number
33
+ url: string | null
34
+ created_at: string
35
+ updated_at: string
36
+ }
37
+
38
+ function parseTaskLabels(raw: string): string[] {
39
+ try {
40
+ const parsed: unknown = JSON.parse(raw)
41
+ return Array.isArray(parsed)
42
+ ? parsed.filter((value): value is string => typeof value === 'string')
43
+ : []
44
+ } catch {
45
+ return []
46
+ }
47
+ }
48
+
49
+ function mapJiraPriorityNameToCanonical(name: string): Task['priority'] {
50
+ switch (name.trim().toLowerCase()) {
51
+ case 'highest':
52
+ return 'urgent'
53
+ case 'high':
54
+ return 'high'
55
+ case 'medium':
56
+ return 'medium'
57
+ default:
58
+ return 'low'
59
+ }
60
+ }
61
+
62
+ function mapLinearPriority(priority: number): Task['priority'] {
63
+ switch (priority) {
64
+ case 1:
65
+ return 'urgent'
66
+ case 2:
67
+ return 'high'
68
+ case 3:
69
+ return 'medium'
70
+ case 0:
71
+ case 4:
72
+ default:
73
+ return 'low'
74
+ }
75
+ }
76
+
77
+ export function jiraTaskFromRow(row: JiraTaskRow): Task {
78
+ return {
79
+ id: `jira:${row.id}`,
80
+ providerId: row.id,
81
+ externalRef: row.key,
82
+ url: row.url,
83
+ title: row.summary,
84
+ description: row.description_text,
85
+ column_id: row.status_id,
86
+ position: 0,
87
+ priority: mapJiraPriorityNameToCanonical(row.priority_name),
88
+ assignee: row.assignee_name,
89
+ assignees: row.assignee_name ? [row.assignee_name] : [],
90
+ labels: parseTaskLabels(row.labels),
91
+ comment_count: row.comment_count,
92
+ project: row.project_key,
93
+ metadata: '{}',
94
+ created_at: row.created_at,
95
+ updated_at: row.updated_at,
96
+ version: row.updated_at,
97
+ source_updated_at: row.updated_at,
98
+ }
99
+ }
100
+
101
+ export function linearTaskFromRow(row: LinearTaskRow): Task {
102
+ return {
103
+ id: `linear:${row.id}`,
104
+ providerId: row.id,
105
+ externalRef: row.identifier,
106
+ url: row.url,
107
+ title: row.title,
108
+ description: row.description,
109
+ column_id: row.state_id,
110
+ position: row.state_position,
111
+ priority: mapLinearPriority(row.priority),
112
+ assignee: row.assignee_name,
113
+ assignees: row.assignee_name ? [row.assignee_name] : [],
114
+ labels: parseTaskLabels(row.labels),
115
+ comment_count: row.comment_count,
116
+ project: row.project_name,
117
+ metadata: '{}',
118
+ created_at: row.created_at,
119
+ updated_at: row.updated_at,
120
+ version: row.updated_at,
121
+ source_updated_at: row.updated_at,
122
+ }
123
+ }
@@ -0,0 +1,103 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import type { Sql } from 'postgres'
3
+
4
+ import { initSchema, seedDefaultColumns } from '../db'
5
+ import type { ProviderCapabilities } from '../types'
6
+ import type { TrackerConfig } from '../tracker-config'
7
+ import {
8
+ JIRA_CAPABILITIES,
9
+ LINEAR_CAPABILITIES,
10
+ LOCAL_CAPABILITIES,
11
+ POSTGRES_LOCAL_CAPABILITIES,
12
+ } from './capabilities'
13
+ import { JiraProvider, type JiraProviderConfig } from './jira'
14
+ import { LinearProvider } from './linear'
15
+ import { LocalProvider } from './local'
16
+ import { PostgresJiraProvider } from './postgres-jira'
17
+ import { PostgresLinearProvider } from './postgres-linear'
18
+ import { PostgresLocalProvider } from './postgres-local'
19
+ import type { KanbanProvider } from './types'
20
+
21
+ export interface InitializableKanbanProvider extends KanbanProvider {
22
+ initialize(): Promise<void>
23
+ }
24
+
25
+ export interface ProviderBundle<TProvider extends KanbanProvider = KanbanProvider> {
26
+ provider: TProvider
27
+ capabilities: ProviderCapabilities
28
+ }
29
+
30
+ export interface SqliteProviderOptions {
31
+ dbPath: string
32
+ seedLocalColumns?: boolean
33
+ }
34
+
35
+ function jiraProviderConfig(
36
+ config: Extract<TrackerConfig, { provider: 'jira' }>,
37
+ ): JiraProviderConfig {
38
+ return {
39
+ baseUrl: config.baseUrl,
40
+ email: config.email,
41
+ apiToken: config.apiToken,
42
+ projectKey: config.projectKey,
43
+ ...(config.boardId !== undefined ? { boardId: config.boardId } : {}),
44
+ defaultIssueType: config.defaultIssueType ?? 'Task',
45
+ pollingSyncIntervalMs: config.syncIntervalMs,
46
+ }
47
+ }
48
+
49
+ export function createSqliteProvider(
50
+ db: Database,
51
+ config: TrackerConfig,
52
+ options: SqliteProviderOptions,
53
+ ): ProviderBundle {
54
+ switch (config.provider) {
55
+ case 'linear':
56
+ return {
57
+ provider: new LinearProvider(db, config.teamId, config.apiKey, config.syncIntervalMs),
58
+ capabilities: LINEAR_CAPABILITIES,
59
+ }
60
+ case 'jira':
61
+ return {
62
+ provider: new JiraProvider(db, jiraProviderConfig(config)),
63
+ capabilities: JIRA_CAPABILITIES,
64
+ }
65
+ case 'local':
66
+ initSchema(db)
67
+ if (options.seedLocalColumns !== false) {
68
+ seedDefaultColumns(db, config.defaultColumns)
69
+ }
70
+ return {
71
+ provider: new LocalProvider(db, options.dbPath),
72
+ capabilities: LOCAL_CAPABILITIES,
73
+ }
74
+ }
75
+ }
76
+
77
+ export function createPostgresProvider(
78
+ sql: Sql,
79
+ config: TrackerConfig,
80
+ ): ProviderBundle<InitializableKanbanProvider> {
81
+ switch (config.provider) {
82
+ case 'linear':
83
+ return {
84
+ provider: new PostgresLinearProvider(
85
+ sql,
86
+ config.teamId,
87
+ config.apiKey,
88
+ config.syncIntervalMs,
89
+ ),
90
+ capabilities: LINEAR_CAPABILITIES,
91
+ }
92
+ case 'jira':
93
+ return {
94
+ provider: new PostgresJiraProvider(sql, jiraProviderConfig(config)),
95
+ capabilities: JIRA_CAPABILITIES,
96
+ }
97
+ case 'local':
98
+ return {
99
+ provider: new PostgresLocalProvider(sql, config),
100
+ capabilities: POSTGRES_LOCAL_CAPABILITIES,
101
+ }
102
+ }
103
+ }
@@ -1,10 +1,8 @@
1
1
  import type { Database } from 'bun:sqlite'
2
- import { getDbPath, initSchema, seedDefaultColumns } from '../db'
3
- import { JiraProvider } from './jira'
4
- import { LinearProvider } from './linear'
5
- import { LocalProvider } from './local'
2
+ import { getDbPath } from '../db'
6
3
  import type { TrackerConfig } from '../tracker-config'
7
4
  import type { KanbanProvider } from './types'
5
+ import { createSqliteProvider } from './factory'
8
6
 
9
7
  export function createProvider(
10
8
  db: Database,
@@ -12,25 +10,6 @@ export function createProvider(
12
10
  dbPath = getDbPath(),
13
11
  options: { seedLocalColumns?: boolean } = {},
14
12
  ): KanbanProvider {
15
- if (config.provider === 'linear') {
16
- return new LinearProvider(db, config.teamId, config.apiKey, config.syncIntervalMs)
17
- }
18
-
19
- if (config.provider === 'jira') {
20
- return new JiraProvider(db, {
21
- baseUrl: config.baseUrl,
22
- email: config.email,
23
- apiToken: config.apiToken,
24
- projectKey: config.projectKey,
25
- ...(config.boardId !== undefined ? { boardId: config.boardId } : {}),
26
- defaultIssueType: config.defaultIssueType ?? 'Task',
27
- pollingSyncIntervalMs: config.syncIntervalMs,
28
- })
29
- }
30
-
31
- initSchema(db)
32
- if (options.seedLocalColumns !== false) {
33
- seedDefaultColumns(db, config.defaultColumns)
34
- }
35
- return new LocalProvider(db, dbPath)
13
+ return createSqliteProvider(db, config, { dbPath, seedLocalColumns: options.seedLocalColumns })
14
+ .provider
36
15
  }
@@ -1,6 +1,9 @@
1
1
  import type { Database } from 'bun:sqlite'
2
+ import { normalizeColumnName } from '../column-roles'
2
3
  import { ErrorCode, KanbanError } from '../errors'
3
4
  import type { BoardView, ProviderTeamInfo, Task } from '../types'
5
+ import { jiraTaskFromRow, type JiraTaskRow } from './cache-task-mappers'
6
+ import { parseProviderTeamInfo } from './team-info'
4
7
 
5
8
  // Column ids are prefixed to avoid collisions across sources:
6
9
  // - board-sourced columns: 'board:<boardId>:<columnName>' with an index suffix
@@ -70,46 +73,51 @@ export function jiraBoardColumnRows(
70
73
  })
71
74
  }
72
75
 
76
+ function ambiguousColumnError(input: string, matches: JiraColumnRow[]): KanbanError {
77
+ return new KanbanError(
78
+ ErrorCode.COLUMN_NOT_FOUND,
79
+ `Jira column name '${input}' is ambiguous; use one of these column ids: ${matches
80
+ .map((column) => column.id)
81
+ .join(', ')}`,
82
+ )
83
+ }
84
+
73
85
  // Resolves a user-supplied column reference to a cached column id, trying
74
- // (1) exact id, (2) case-insensitive name, (3) raw status id containment.
75
- // A name that matches multiple columns (possible when Jira returns duplicate
76
- // board column names) is rejected as ambiguous so the caller picks an id.
86
+ // (1) exact id, (2) case-insensitive name, (3) raw status id containment,
87
+ // (4) separator-insensitive name ('Todo' 'To Do'). Exact passes (id, name,
88
+ // status id) run before the fuzzy normalized pass so a precise reference is
89
+ // never overridden by a normalized-name collision. Name lookups that match
90
+ // multiple distinct columns (possible when Jira returns duplicate board column
91
+ // names, or when two columns collapse to the same normalized token) are
92
+ // rejected as ambiguous so the caller picks an id.
77
93
  export function resolveJiraColumnId(columns: JiraColumnRow[], input: string): string {
78
94
  const byId = columns.find((column) => column.id === input)
79
95
  if (byId) return byId.id
80
96
  const lower = input.toLowerCase()
81
97
  const byName = columns.filter((column) => column.name.toLowerCase() === lower)
82
98
  if (byName.length === 1) return byName[0]!.id
83
- if (byName.length > 1) {
84
- throw new KanbanError(
85
- ErrorCode.COLUMN_NOT_FOUND,
86
- `Jira column name '${input}' is ambiguous; use one of these column ids: ${byName
87
- .map((column) => column.id)
88
- .join(', ')}`,
89
- )
90
- }
99
+ if (byName.length > 1) throw ambiguousColumnError(input, byName)
100
+ // Exact status-id containment takes precedence over the fuzzy name pass: a raw
101
+ // status id is an unambiguous reference and must not be shadowed by a column
102
+ // whose name happens to normalize to the same token.
91
103
  const byStatus = columns.find((column) => decodeColumnStatusIds(column).includes(input))
92
104
  if (byStatus) return byStatus.id
105
+ // Status-fallback columns are named after Jira statuses ('To Do', 'In
106
+ // Progress'), but dispatch triggers pass collapsed strings like 'Todo'. Match
107
+ // separator-insensitively so the trigger column resolves instead of throwing
108
+ // COLUMN_NOT_FOUND (which silently strands every ticket in that column). Skip a
109
+ // token that collapses to empty so punctuation-only input never matches a
110
+ // separator-only column name.
111
+ const normalized = normalizeColumnName(input)
112
+ if (normalized !== '') {
113
+ const byNormalized = columns.filter((column) => normalizeColumnName(column.name) === normalized)
114
+ if (byNormalized.length === 1) return byNormalized[0]!.id
115
+ if (byNormalized.length > 1) throw ambiguousColumnError(input, byNormalized)
116
+ }
93
117
  throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No Jira column matching '${input}'`)
94
118
  }
95
119
 
96
- interface JiraIssueRow {
97
- id: string
98
- key: string
99
- summary: string
100
- description_text: string
101
- status_id: string
102
- priority_name: string
103
- issue_type_name: string
104
- assignee_account_id: string | null
105
- assignee_name: string
106
- labels: string
107
- comment_count: number
108
- project_key: string
109
- url: string | null
110
- created_at: string
111
- updated_at: string
112
- }
120
+ type JiraIssueRow = JiraTaskRow
113
121
 
114
122
  export function initJiraCacheSchema(db: Database): void {
115
123
  db.run(`
@@ -252,27 +260,7 @@ export function saveTeamInfo(db: Database, team: ProviderTeamInfo | null): void
252
260
  }
253
261
 
254
262
  export function loadTeamInfo(db: Database): ProviderTeamInfo | null {
255
- const raw = getMeta(db, 'team')
256
- if (raw === null) return null
257
- try {
258
- const parsed = JSON.parse(raw) as unknown
259
- if (
260
- parsed &&
261
- typeof parsed === 'object' &&
262
- 'id' in parsed &&
263
- 'key' in parsed &&
264
- 'name' in parsed &&
265
- typeof (parsed as { id: unknown }).id === 'string' &&
266
- typeof (parsed as { key: unknown }).key === 'string' &&
267
- typeof (parsed as { name: unknown }).name === 'string'
268
- ) {
269
- const t = parsed as { id: string; key: string; name: string }
270
- return { id: t.id, key: t.key, name: t.name }
271
- }
272
- return null
273
- } catch {
274
- return null
275
- }
263
+ return parseProviderTeamInfo(getMeta(db, 'team'))
276
264
  }
277
265
 
278
266
  export function loadJiraSyncMeta(db: Database): JiraSyncMeta {
@@ -522,52 +510,6 @@ export function getCachedColumns(db: Database): JiraColumnRow[] {
522
510
  return db.query('SELECT * FROM jira_columns ORDER BY position, name').all() as JiraColumnRow[]
523
511
  }
524
512
 
525
- function mapPriorityNameToCanonical(name: string): Task['priority'] {
526
- switch (name.trim().toLowerCase()) {
527
- case 'highest':
528
- return 'urgent'
529
- case 'high':
530
- return 'high'
531
- case 'medium':
532
- return 'medium'
533
- default:
534
- return 'low'
535
- }
536
- }
537
-
538
- function parseLabels(raw: string): string[] {
539
- try {
540
- const parsed: unknown = JSON.parse(raw)
541
- return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
542
- } catch {
543
- return []
544
- }
545
- }
546
-
547
- function taskFromRow(row: JiraIssueRow): Task {
548
- return {
549
- id: `jira:${row.id}`,
550
- providerId: row.id,
551
- externalRef: row.key,
552
- url: row.url,
553
- title: row.summary,
554
- description: row.description_text,
555
- column_id: row.status_id,
556
- position: 0,
557
- priority: mapPriorityNameToCanonical(row.priority_name),
558
- assignee: row.assignee_name,
559
- assignees: row.assignee_name ? [row.assignee_name] : [],
560
- labels: parseLabels(row.labels),
561
- comment_count: row.comment_count,
562
- project: row.project_key,
563
- metadata: '{}',
564
- created_at: row.created_at,
565
- updated_at: row.updated_at,
566
- version: row.updated_at,
567
- source_updated_at: row.updated_at,
568
- }
569
- }
570
-
571
513
  function selectIssuesByStatusIds(db: Database, statusIds: string[]): JiraIssueRow[] {
572
514
  if (statusIds.length === 0) return []
573
515
  const placeholders = statusIds.map((_, i) => `$s${i}`).join(', ')
@@ -589,7 +531,7 @@ export function getCachedBoard(db: Database): BoardView {
589
531
  return {
590
532
  columns: columns.map((column) => {
591
533
  const statusIds = decodeColumnStatusIds(column)
592
- const tasks = selectIssuesByStatusIds(db, statusIds).map(taskFromRow)
534
+ const tasks = selectIssuesByStatusIds(db, statusIds).map(jiraTaskFromRow)
593
535
  return {
594
536
  id: column.id,
595
537
  name: column.name,
@@ -612,7 +554,7 @@ export function getCachedTask(db: Database, lookup: string): Task | null {
612
554
  LIMIT 1`,
613
555
  )
614
556
  .get({ $lookup: normalized }) as JiraIssueRow | null
615
- return row ? taskFromRow(row) : null
557
+ return row ? jiraTaskFromRow(row) : null
616
558
  }
617
559
 
618
560
  export function getCachedTasks(db: Database, params?: { columnId?: string }): Task[] {
@@ -622,13 +564,13 @@ export function getCachedTasks(db: Database, params?: { columnId?: string }): Ta
622
564
  .get({ $id: params.columnId }) as Pick<JiraColumnRow, 'status_ids'> | null
623
565
  if (!columnRow) return []
624
566
  const statusIds = decodeColumnStatusIds(columnRow)
625
- return selectIssuesByStatusIds(db, statusIds).map(taskFromRow)
567
+ return selectIssuesByStatusIds(db, statusIds).map(jiraTaskFromRow)
626
568
  }
627
569
  return (
628
570
  db
629
571
  .query('SELECT * FROM jira_issues ORDER BY updated_at DESC, summary ASC')
630
572
  .all() as JiraIssueRow[]
631
- ).map(taskFromRow)
573
+ ).map(jiraTaskFromRow)
632
574
  }
633
575
 
634
576
  export function getCachedConfig(db: Database): JiraCacheConfig {