@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,500 @@
1
+ import type { Sql } from 'postgres'
2
+
3
+ import type { BoardConfig, BoardView, ProviderTeamInfo, Task } from '../types'
4
+ import { ensureWebhookEventsSchema } from '../webhook-events'
5
+ import type { LinearCachePort } from './linear-core'
6
+ import {
7
+ clampActivityValue,
8
+ type LinearActivityRow,
9
+ type LinearStateRow,
10
+ type LinearSyncMeta,
11
+ } from './linear-cache'
12
+
13
+ export type { LinearActivityRow, LinearStateRow, LinearSyncMeta } from './linear-cache'
14
+
15
+ export interface LinearIssueRow {
16
+ id: string
17
+ identifier: string
18
+ title: string
19
+ description: string
20
+ state_id: string
21
+ state_position: number
22
+ priority: number
23
+ assignee_name: string
24
+ project_name: string
25
+ labels: string
26
+ comment_count: number
27
+ url: string | null
28
+ created_at: string
29
+ updated_at: string
30
+ }
31
+
32
+ function mapPriority(priority: number): Task['priority'] {
33
+ switch (priority) {
34
+ case 1:
35
+ return 'urgent'
36
+ case 2:
37
+ return 'high'
38
+ case 3:
39
+ return 'medium'
40
+ case 0:
41
+ case 4:
42
+ default:
43
+ return 'low'
44
+ }
45
+ }
46
+
47
+ function parseLabels(raw: string): string[] {
48
+ try {
49
+ const parsed: unknown = JSON.parse(raw)
50
+ return Array.isArray(parsed)
51
+ ? parsed.filter((value): value is string => typeof value === 'string')
52
+ : []
53
+ } catch {
54
+ return []
55
+ }
56
+ }
57
+
58
+ function taskFromRow(row: LinearIssueRow): Task {
59
+ return {
60
+ id: `linear:${row.id}`,
61
+ providerId: row.id,
62
+ externalRef: row.identifier,
63
+ url: row.url,
64
+ title: row.title,
65
+ description: row.description,
66
+ column_id: row.state_id,
67
+ position: row.state_position,
68
+ priority: mapPriority(row.priority),
69
+ assignee: row.assignee_name,
70
+ assignees: row.assignee_name ? [row.assignee_name] : [],
71
+ labels: parseLabels(row.labels),
72
+ comment_count: row.comment_count,
73
+ project: row.project_name,
74
+ metadata: '{}',
75
+ created_at: row.created_at,
76
+ updated_at: row.updated_at,
77
+ version: row.updated_at,
78
+ source_updated_at: row.updated_at,
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Postgres-backed cache/repository for the Linear provider. Mirrors the role of
84
+ * the SQLite-side `linear-cache.ts` free functions, but as an instance that owns
85
+ * the async `postgres.js` client and its own schema-readiness promise. Holds only
86
+ * cache I/O (persistence + materialization, including description-change activity
87
+ * synthesis on write); API sync and business logic stay in `PostgresLinearProvider`.
88
+ */
89
+ export class PostgresLinearCache implements LinearCachePort {
90
+ readonly ready: Promise<void>
91
+
92
+ constructor(private readonly sql: Sql) {
93
+ this.ready = this.ensureSchema()
94
+ }
95
+
96
+ private async ensureSchema(): Promise<void> {
97
+ await this.sql`
98
+ CREATE TABLE IF NOT EXISTS linear_sync_meta (
99
+ key TEXT PRIMARY KEY,
100
+ value TEXT NOT NULL
101
+ )
102
+ `
103
+ await this.sql`
104
+ CREATE TABLE IF NOT EXISTS linear_states (
105
+ id TEXT PRIMARY KEY,
106
+ name TEXT NOT NULL,
107
+ position INTEGER NOT NULL,
108
+ color TEXT,
109
+ type TEXT,
110
+ created_at TEXT NOT NULL,
111
+ updated_at TEXT NOT NULL
112
+ )
113
+ `
114
+ await this.sql`
115
+ CREATE TABLE IF NOT EXISTS linear_users (
116
+ id TEXT PRIMARY KEY,
117
+ name TEXT NOT NULL,
118
+ active INTEGER NOT NULL DEFAULT 1,
119
+ updated_at TEXT NOT NULL
120
+ )
121
+ `
122
+ await this.sql`
123
+ CREATE TABLE IF NOT EXISTS linear_projects (
124
+ id TEXT PRIMARY KEY,
125
+ name TEXT NOT NULL,
126
+ url TEXT,
127
+ state TEXT,
128
+ updated_at TEXT NOT NULL
129
+ )
130
+ `
131
+ await this.sql`
132
+ CREATE TABLE IF NOT EXISTS linear_issues (
133
+ id TEXT PRIMARY KEY,
134
+ identifier TEXT NOT NULL UNIQUE,
135
+ title TEXT NOT NULL,
136
+ description TEXT NOT NULL DEFAULT '',
137
+ priority INTEGER NOT NULL DEFAULT 0,
138
+ assignee_id TEXT,
139
+ assignee_name TEXT NOT NULL DEFAULT '',
140
+ project_id TEXT,
141
+ project_name TEXT NOT NULL DEFAULT '',
142
+ state_id TEXT NOT NULL,
143
+ state_name TEXT NOT NULL,
144
+ state_position INTEGER NOT NULL DEFAULT 0,
145
+ labels TEXT NOT NULL DEFAULT '[]',
146
+ comment_count INTEGER NOT NULL DEFAULT 0,
147
+ url TEXT,
148
+ created_at TEXT NOT NULL,
149
+ updated_at TEXT NOT NULL
150
+ )
151
+ `
152
+ await this
153
+ .sql`ALTER TABLE linear_issues ADD COLUMN IF NOT EXISTS labels TEXT NOT NULL DEFAULT '[]'`
154
+ await this
155
+ .sql`ALTER TABLE linear_issues ADD COLUMN IF NOT EXISTS comment_count INTEGER NOT NULL DEFAULT 0`
156
+ await this.sql`CREATE INDEX IF NOT EXISTS idx_linear_issues_state_id ON linear_issues(state_id)`
157
+ await this
158
+ .sql`CREATE INDEX IF NOT EXISTS idx_linear_issues_updated_at ON linear_issues(updated_at)`
159
+ await this.sql`
160
+ CREATE TABLE IF NOT EXISTS linear_activity (
161
+ issue_id TEXT NOT NULL,
162
+ history_id TEXT NOT NULL,
163
+ item_field TEXT NOT NULL,
164
+ from_value TEXT,
165
+ to_value TEXT,
166
+ created_at TEXT NOT NULL,
167
+ PRIMARY KEY (issue_id, history_id, item_field)
168
+ )
169
+ `
170
+ await this.sql`
171
+ CREATE INDEX IF NOT EXISTS linear_activity_created_at_idx ON linear_activity(created_at DESC)
172
+ `
173
+ await ensureWebhookEventsSchema(this.sql)
174
+ }
175
+
176
+ async setMeta(key: string, value: string): Promise<void> {
177
+ await this.sql`
178
+ INSERT INTO linear_sync_meta (key, value)
179
+ VALUES (${key}, ${value})
180
+ ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value
181
+ `
182
+ }
183
+
184
+ async deleteMeta(key: string): Promise<void> {
185
+ await this.sql`DELETE FROM linear_sync_meta WHERE key = ${key}`
186
+ }
187
+
188
+ async getMeta(key: string): Promise<string | null> {
189
+ const [row] = await this.sql<{ value: string }[]>`
190
+ SELECT value FROM linear_sync_meta WHERE key = ${key}
191
+ `
192
+ return row?.value ?? null
193
+ }
194
+
195
+ async saveSyncMeta(meta: Partial<LinearSyncMeta>): Promise<void> {
196
+ const keys = [
197
+ 'team',
198
+ 'lastSyncAt',
199
+ 'lastFullSyncAt',
200
+ 'lastIssueUpdatedAt',
201
+ 'lastWebhookAt',
202
+ ] as const
203
+ for (const key of keys) {
204
+ if (!Object.prototype.hasOwnProperty.call(meta, key)) continue
205
+ const value = meta[key]
206
+ if (value === null) {
207
+ await this.deleteMeta(key)
208
+ continue
209
+ }
210
+ if (key === 'team') {
211
+ await this.setMeta(key, JSON.stringify(value))
212
+ continue
213
+ }
214
+ if (typeof value === 'string') await this.setMeta(key, value)
215
+ }
216
+ }
217
+
218
+ async loadSyncMeta(): Promise<LinearSyncMeta> {
219
+ const teamRaw = await this.getMeta('team')
220
+ return {
221
+ team: teamRaw ? (JSON.parse(teamRaw) as ProviderTeamInfo) : null,
222
+ lastSyncAt: await this.getMeta('lastSyncAt'),
223
+ lastFullSyncAt: await this.getMeta('lastFullSyncAt'),
224
+ lastIssueUpdatedAt: await this.getMeta('lastIssueUpdatedAt'),
225
+ lastWebhookAt: await this.getMeta('lastWebhookAt'),
226
+ }
227
+ }
228
+
229
+ async replaceStates(
230
+ states: Array<{
231
+ id: string
232
+ name: string
233
+ position: number
234
+ color?: string | null
235
+ type?: string | null
236
+ }>,
237
+ ): Promise<void> {
238
+ const now = new Date().toISOString()
239
+ await this.sql.begin(async (tx) => {
240
+ await tx`DELETE FROM linear_states`
241
+ for (const state of states) {
242
+ await tx`
243
+ INSERT INTO linear_states (id, name, position, color, type, created_at, updated_at)
244
+ VALUES (${state.id}, ${state.name}, ${state.position}, ${state.color ?? null}, ${state.type ?? null}, ${now}, ${now})
245
+ `
246
+ }
247
+ })
248
+ }
249
+
250
+ async upsertUsers(users: Array<{ id: string; name: string; active?: boolean }>): Promise<void> {
251
+ const now = new Date().toISOString()
252
+ for (const user of users) {
253
+ await this.sql`
254
+ INSERT INTO linear_users (id, name, active, updated_at)
255
+ VALUES (${user.id}, ${user.name}, ${user.active === false ? 0 : 1}, ${now})
256
+ ON CONFLICT(id) DO UPDATE SET
257
+ name = EXCLUDED.name,
258
+ active = EXCLUDED.active,
259
+ updated_at = EXCLUDED.updated_at
260
+ `
261
+ }
262
+ }
263
+
264
+ async upsertProjects(
265
+ projects: Array<{ id: string; name: string; url?: string | null; state?: string | null }>,
266
+ ): Promise<void> {
267
+ const now = new Date().toISOString()
268
+ for (const project of projects) {
269
+ await this.sql`
270
+ INSERT INTO linear_projects (id, name, url, state, updated_at)
271
+ VALUES (${project.id}, ${project.name}, ${project.url ?? null}, ${project.state ?? null}, ${now})
272
+ ON CONFLICT(id) DO UPDATE SET
273
+ name = EXCLUDED.name,
274
+ url = EXCLUDED.url,
275
+ state = EXCLUDED.state,
276
+ updated_at = EXCLUDED.updated_at
277
+ `
278
+ }
279
+ }
280
+
281
+ async saveActivity(rows: LinearActivityRow[]): Promise<void> {
282
+ for (const row of rows) {
283
+ await this.sql`
284
+ INSERT INTO linear_activity (issue_id, history_id, item_field, from_value, to_value, created_at)
285
+ VALUES (${row.issue_id}, ${row.history_id}, ${row.item_field}, ${row.from_value}, ${row.to_value}, ${row.created_at})
286
+ ON CONFLICT(issue_id, history_id, item_field) DO NOTHING
287
+ `
288
+ }
289
+ }
290
+
291
+ async upsertIssues(
292
+ issues: Array<{
293
+ id: string
294
+ identifier: string
295
+ title: string
296
+ description?: string | null
297
+ priority?: number | null
298
+ assigneeId?: string | null
299
+ assigneeName?: string | null
300
+ projectId?: string | null
301
+ projectName?: string | null
302
+ stateId: string
303
+ stateName: string
304
+ statePosition: number
305
+ labels?: string[] | null
306
+ commentCount?: number | null
307
+ url?: string | null
308
+ createdAt: string
309
+ updatedAt: string
310
+ }>,
311
+ ): Promise<void> {
312
+ for (const issue of issues) {
313
+ const nextDescription = issue.description ?? ''
314
+ const [prior] = await this.sql<{ description: string }[]>`
315
+ SELECT description FROM linear_issues WHERE id = ${issue.id} LIMIT 1
316
+ `
317
+ if (prior && prior.description !== nextDescription) {
318
+ await this.saveActivity([
319
+ {
320
+ issue_id: issue.id,
321
+ history_id: `desc:${issue.updatedAt}`,
322
+ item_field: 'description',
323
+ from_value: clampActivityValue(prior.description),
324
+ to_value: clampActivityValue(nextDescription),
325
+ created_at: issue.updatedAt,
326
+ },
327
+ ])
328
+ }
329
+
330
+ const hasCommentCount = issue.commentCount !== undefined && issue.commentCount !== null
331
+ await this.sql`
332
+ INSERT INTO linear_issues (
333
+ id, identifier, title, description, priority, assignee_id, assignee_name,
334
+ project_id, project_name, state_id, state_name, state_position, labels, comment_count,
335
+ url, created_at, updated_at
336
+ ) VALUES (
337
+ ${issue.id}, ${issue.identifier}, ${issue.title}, ${nextDescription}, ${issue.priority ?? 0},
338
+ ${issue.assigneeId ?? null}, ${issue.assigneeName ?? ''}, ${issue.projectId ?? null},
339
+ ${issue.projectName ?? ''}, ${issue.stateId}, ${issue.stateName}, ${issue.statePosition},
340
+ ${JSON.stringify(issue.labels ?? [])}, ${hasCommentCount ? (issue.commentCount ?? 0) : 0},
341
+ ${issue.url ?? null}, ${issue.createdAt}, ${issue.updatedAt}
342
+ )
343
+ ON CONFLICT(id) DO UPDATE SET
344
+ identifier = EXCLUDED.identifier,
345
+ title = EXCLUDED.title,
346
+ description = EXCLUDED.description,
347
+ priority = EXCLUDED.priority,
348
+ assignee_id = EXCLUDED.assignee_id,
349
+ assignee_name = EXCLUDED.assignee_name,
350
+ project_id = EXCLUDED.project_id,
351
+ project_name = EXCLUDED.project_name,
352
+ state_id = EXCLUDED.state_id,
353
+ state_name = EXCLUDED.state_name,
354
+ state_position = EXCLUDED.state_position,
355
+ labels = EXCLUDED.labels,
356
+ comment_count = CASE
357
+ WHEN ${hasCommentCount} THEN EXCLUDED.comment_count
358
+ ELSE linear_issues.comment_count
359
+ END,
360
+ url = EXCLUDED.url,
361
+ created_at = EXCLUDED.created_at,
362
+ updated_at = EXCLUDED.updated_at
363
+ `
364
+ }
365
+ }
366
+
367
+ async deleteIssue(idOrIdentifier: string): Promise<void> {
368
+ await this.sql`
369
+ DELETE FROM linear_activity
370
+ WHERE issue_id = ${idOrIdentifier}
371
+ OR issue_id IN (SELECT id FROM linear_issues WHERE identifier = ${idOrIdentifier})
372
+ `
373
+ await this
374
+ .sql`DELETE FROM linear_issues WHERE id = ${idOrIdentifier} OR identifier = ${idOrIdentifier}`
375
+ }
376
+
377
+ async pruneIssues(liveIssueIds: string[]): Promise<void> {
378
+ if (liveIssueIds.length === 0) {
379
+ await this.sql`DELETE FROM linear_activity`
380
+ await this.sql`DELETE FROM linear_issues`
381
+ return
382
+ }
383
+ await this.sql`
384
+ DELETE FROM linear_activity
385
+ WHERE issue_id IN (
386
+ SELECT id FROM linear_issues WHERE NOT (id = ANY(${liveIssueIds}))
387
+ )
388
+ `
389
+ await this.sql`DELETE FROM linear_issues WHERE NOT (id = ANY(${liveIssueIds}))`
390
+ }
391
+
392
+ async adjustIssueCommentCount(idOrIdentifier: string, delta: number): Promise<void> {
393
+ await this.sql`
394
+ UPDATE linear_issues
395
+ SET comment_count = GREATEST(0, comment_count + ${delta})
396
+ WHERE id = ${idOrIdentifier} OR identifier = ${idOrIdentifier}
397
+ `
398
+ }
399
+
400
+ async getCachedColumns(): Promise<LinearStateRow[]> {
401
+ return this.sql<LinearStateRow[]>`SELECT * FROM linear_states ORDER BY position, name`
402
+ }
403
+
404
+ async getCachedBoard(): Promise<BoardView> {
405
+ const columns = await this.getCachedColumns()
406
+ const boardColumns = []
407
+ for (const column of columns) {
408
+ const tasks = (
409
+ await this.sql<LinearIssueRow[]>`
410
+ SELECT * FROM linear_issues
411
+ WHERE state_id = ${column.id}
412
+ ORDER BY updated_at DESC, title ASC
413
+ `
414
+ ).map(taskFromRow)
415
+ boardColumns.push({ ...column, tasks })
416
+ }
417
+ return { columns: boardColumns }
418
+ }
419
+
420
+ async getCachedTask(lookup: string): Promise<Task | null> {
421
+ const normalized = lookup.startsWith('linear:') ? lookup.slice('linear:'.length) : lookup
422
+ const [row] = await this.sql<LinearIssueRow[]>`
423
+ SELECT * FROM linear_issues
424
+ WHERE id = ${normalized} OR identifier = ${normalized}
425
+ LIMIT 1
426
+ `
427
+ return row ? taskFromRow(row) : null
428
+ }
429
+
430
+ async getCachedTasks(): Promise<Task[]> {
431
+ return (
432
+ await this.sql<LinearIssueRow[]>`
433
+ SELECT * FROM linear_issues ORDER BY updated_at DESC, title ASC
434
+ `
435
+ ).map(taskFromRow)
436
+ }
437
+
438
+ async getCachedConfig(): Promise<BoardConfig> {
439
+ const members = (
440
+ await this.sql<{ name: string }[]>`
441
+ SELECT name FROM linear_users WHERE active = 1 AND name != '' ORDER BY name
442
+ `
443
+ ).map((row) => ({ name: row.name, role: 'human' as const }))
444
+ const projects = (
445
+ await this.sql<{ name: string }[]>`
446
+ SELECT name FROM linear_projects WHERE name != '' ORDER BY name
447
+ `
448
+ ).map((row) => row.name)
449
+ return {
450
+ members,
451
+ projects,
452
+ provider: 'linear',
453
+ discoveredAssignees: members.map((member) => member.name),
454
+ discoveredProjects: projects,
455
+ }
456
+ }
457
+
458
+ async getCachedActivity(
459
+ params: { issueId?: string; limit?: number } = {},
460
+ ): Promise<LinearActivityRow[]> {
461
+ const limit = params.limit ?? 100
462
+ if (params.issueId) {
463
+ return this.sql<LinearActivityRow[]>`
464
+ SELECT issue_id, history_id, item_field, from_value, to_value, created_at
465
+ FROM linear_activity
466
+ WHERE issue_id = ${params.issueId}
467
+ ORDER BY created_at DESC
468
+ LIMIT ${limit}
469
+ `
470
+ }
471
+ return this.sql<LinearActivityRow[]>`
472
+ SELECT issue_id, history_id, item_field, from_value, to_value, created_at
473
+ FROM linear_activity
474
+ ORDER BY created_at DESC
475
+ LIMIT ${limit}
476
+ `
477
+ }
478
+
479
+ async findUserIdByName(name: string): Promise<string | null> {
480
+ const [row] = await this.sql<{ id: string }[]>`
481
+ SELECT id FROM linear_users WHERE LOWER(name) = LOWER(${name}) LIMIT 1
482
+ `
483
+ return row?.id ?? null
484
+ }
485
+
486
+ async findProjectIdByName(name: string): Promise<string | null> {
487
+ const [row] = await this.sql<{ id: string }[]>`
488
+ SELECT id FROM linear_projects WHERE LOWER(name) = LOWER(${name}) LIMIT 1
489
+ `
490
+ return row?.id ?? null
491
+ }
492
+
493
+ async resolveIssueId(lookup: string): Promise<string | null> {
494
+ const normalized = lookup.startsWith('linear:') ? lookup.slice('linear:'.length) : lookup
495
+ const [row] = await this.sql<{ id: string }[]>`
496
+ SELECT id FROM linear_issues WHERE id = ${normalized} OR identifier = ${normalized} LIMIT 1
497
+ `
498
+ return row?.id ?? null
499
+ }
500
+ }