@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
@@ -14,7 +14,9 @@ export interface LinearStateRow {
14
14
  export interface LinearSyncMeta {
15
15
  team: ProviderTeamInfo | null
16
16
  lastSyncAt: string | null
17
+ lastFullSyncAt: string | null
17
18
  lastIssueUpdatedAt: string | null
19
+ lastWebhookAt: string | null
18
20
  }
19
21
 
20
22
  export function initLinearCacheSchema(db: Database): void {
@@ -66,6 +68,8 @@ export function initLinearCacheSchema(db: Database): void {
66
68
  state_id TEXT NOT NULL,
67
69
  state_name TEXT NOT NULL,
68
70
  state_position INTEGER NOT NULL DEFAULT 0,
71
+ labels TEXT NOT NULL DEFAULT '[]',
72
+ comment_count INTEGER NOT NULL DEFAULT 0,
69
73
  url TEXT,
70
74
  created_at TEXT NOT NULL,
71
75
  updated_at TEXT NOT NULL
@@ -73,6 +77,81 @@ export function initLinearCacheSchema(db: Database): void {
73
77
  `)
74
78
  db.run('CREATE INDEX IF NOT EXISTS idx_linear_issues_state_id ON linear_issues(state_id)')
75
79
  db.run('CREATE INDEX IF NOT EXISTS idx_linear_issues_updated_at ON linear_issues(updated_at)')
80
+ db.run(`
81
+ CREATE TABLE IF NOT EXISTS linear_activity (
82
+ issue_id TEXT NOT NULL,
83
+ history_id TEXT NOT NULL,
84
+ item_field TEXT NOT NULL,
85
+ from_value TEXT,
86
+ to_value TEXT,
87
+ created_at TEXT NOT NULL,
88
+ PRIMARY KEY (issue_id, history_id, item_field)
89
+ )
90
+ `)
91
+ db.run(
92
+ 'CREATE INDEX IF NOT EXISTS linear_activity_created_at_idx ON linear_activity(created_at DESC)',
93
+ )
94
+ migrateLinearCacheSchema(db)
95
+ }
96
+
97
+ export interface LinearActivityRow {
98
+ issue_id: string
99
+ history_id: string
100
+ item_field: string
101
+ from_value: string | null
102
+ to_value: string | null
103
+ created_at: string
104
+ }
105
+
106
+ export function saveLinearActivity(db: Database, rows: LinearActivityRow[]): void {
107
+ if (rows.length === 0) return
108
+ const stmt = db.prepare(
109
+ `INSERT OR IGNORE INTO linear_activity
110
+ (issue_id, history_id, item_field, from_value, to_value, created_at)
111
+ VALUES (?, ?, ?, ?, ?, ?)`,
112
+ )
113
+ const tx = db.transaction((items: LinearActivityRow[]) => {
114
+ for (const r of items) {
115
+ stmt.run(r.issue_id, r.history_id, r.item_field, r.from_value, r.to_value, r.created_at)
116
+ }
117
+ })
118
+ tx(rows)
119
+ }
120
+
121
+ export function getCachedLinearActivity(
122
+ db: Database,
123
+ params: { issueId?: string; limit?: number } = {},
124
+ ): LinearActivityRow[] {
125
+ const limit = params.limit ?? 100
126
+ if (params.issueId) {
127
+ return db
128
+ .query(
129
+ `SELECT issue_id, history_id, item_field, from_value, to_value, created_at
130
+ FROM linear_activity
131
+ WHERE issue_id = $issueId
132
+ ORDER BY created_at DESC
133
+ LIMIT $limit`,
134
+ )
135
+ .all({ $issueId: params.issueId, $limit: limit }) as LinearActivityRow[]
136
+ }
137
+ return db
138
+ .query(
139
+ `SELECT issue_id, history_id, item_field, from_value, to_value, created_at
140
+ FROM linear_activity
141
+ ORDER BY created_at DESC
142
+ LIMIT $limit`,
143
+ )
144
+ .all({ $limit: limit }) as LinearActivityRow[]
145
+ }
146
+
147
+ function migrateLinearCacheSchema(db: Database): void {
148
+ const cols = db.query('PRAGMA table_info(linear_issues)').all() as { name: string }[]
149
+ if (!cols.some((c) => c.name === 'labels')) {
150
+ db.run("ALTER TABLE linear_issues ADD COLUMN labels TEXT NOT NULL DEFAULT '[]'")
151
+ }
152
+ if (!cols.some((c) => c.name === 'comment_count')) {
153
+ db.run('ALTER TABLE linear_issues ADD COLUMN comment_count INTEGER NOT NULL DEFAULT 0')
154
+ }
76
155
  }
77
156
 
78
157
  function setMeta(db: Database, key: string, value: string): void {
@@ -82,6 +161,10 @@ function setMeta(db: Database, key: string, value: string): void {
82
161
  ).run({ $key: key, $value: value })
83
162
  }
84
163
 
164
+ function deleteMeta(db: Database, key: string): void {
165
+ db.query('DELETE FROM linear_sync_meta WHERE key = $key').run({ $key: key })
166
+ }
167
+
85
168
  function getMeta(db: Database, key: string): string | null {
86
169
  const row = db.query('SELECT value FROM linear_sync_meta WHERE key = $key').get({
87
170
  $key: key,
@@ -89,10 +172,31 @@ function getMeta(db: Database, key: string): string | null {
89
172
  return row?.value ?? null
90
173
  }
91
174
 
92
- export function saveSyncMeta(db: Database, meta: LinearSyncMeta): void {
93
- if (meta.team) setMeta(db, 'team', JSON.stringify(meta.team))
94
- if (meta.lastSyncAt) setMeta(db, 'lastSyncAt', meta.lastSyncAt)
95
- if (meta.lastIssueUpdatedAt) setMeta(db, 'lastIssueUpdatedAt', meta.lastIssueUpdatedAt)
175
+ const META_KEYS = [
176
+ 'team',
177
+ 'lastSyncAt',
178
+ 'lastFullSyncAt',
179
+ 'lastIssueUpdatedAt',
180
+ 'lastWebhookAt',
181
+ ] as const
182
+ type MetaKey = (typeof META_KEYS)[number]
183
+
184
+ export function saveSyncMeta(db: Database, meta: Partial<LinearSyncMeta>): void {
185
+ for (const key of META_KEYS) {
186
+ if (!Object.prototype.hasOwnProperty.call(meta, key)) continue
187
+ const value = (meta as Record<MetaKey, unknown>)[key]
188
+ if (value === null) {
189
+ deleteMeta(db, key)
190
+ continue
191
+ }
192
+ if (key === 'team') {
193
+ setMeta(db, key, JSON.stringify(value))
194
+ continue
195
+ }
196
+ if (typeof value === 'string') {
197
+ setMeta(db, key, value)
198
+ }
199
+ }
96
200
  }
97
201
 
98
202
  export function loadSyncMeta(db: Database): LinearSyncMeta {
@@ -100,7 +204,9 @@ export function loadSyncMeta(db: Database): LinearSyncMeta {
100
204
  return {
101
205
  team: teamRaw ? (JSON.parse(teamRaw) as ProviderTeamInfo) : null,
102
206
  lastSyncAt: getMeta(db, 'lastSyncAt'),
207
+ lastFullSyncAt: getMeta(db, 'lastFullSyncAt'),
103
208
  lastIssueUpdatedAt: getMeta(db, 'lastIssueUpdatedAt'),
209
+ lastWebhookAt: getMeta(db, 'lastWebhookAt'),
104
210
  }
105
211
  }
106
212
 
@@ -173,6 +279,16 @@ export function upsertProjects(
173
279
  }
174
280
  }
175
281
 
282
+ // Bound per-value storage so repeated edits to a long description can't balloon the cache.
283
+ const ACTIVITY_VALUE_MAX_CHARS = 4096
284
+ const ACTIVITY_TRUNCATION_SUFFIX = '…[truncated]'
285
+ const ACTIVITY_VALUE_BUDGET = ACTIVITY_VALUE_MAX_CHARS - ACTIVITY_TRUNCATION_SUFFIX.length
286
+
287
+ function clampActivityValue(value: string): string {
288
+ if (value.length <= ACTIVITY_VALUE_MAX_CHARS) return value
289
+ return value.slice(0, ACTIVITY_VALUE_BUDGET) + ACTIVITY_TRUNCATION_SUFFIX
290
+ }
291
+
176
292
  export function upsertIssues(
177
293
  db: Database,
178
294
  issues: Array<{
@@ -188,18 +304,24 @@ export function upsertIssues(
188
304
  stateId: string
189
305
  stateName: string
190
306
  statePosition: number
307
+ labels?: string[] | null
308
+ commentCount?: number | null
191
309
  url?: string | null
192
310
  createdAt: string
193
311
  updatedAt: string
194
312
  }>,
195
313
  ): void {
196
- const stmt = db.prepare(
314
+ if (issues.length === 0) return
315
+ const upsertStmt = db.prepare(
197
316
  `INSERT INTO linear_issues (
198
317
  id, identifier, title, description, priority, assignee_id, assignee_name,
199
- project_id, project_name, state_id, state_name, state_position, url, created_at, updated_at
318
+ project_id, project_name, state_id, state_name, state_position, labels, comment_count,
319
+ url, created_at, updated_at
200
320
  ) VALUES (
201
321
  $id, $identifier, $title, $description, $priority, $assignee_id, $assignee_name,
202
- $project_id, $project_name, $state_id, $state_name, $state_position, $url, $created_at, $updated_at
322
+ $project_id, $project_name, $state_id, $state_name, $state_position, $labels,
323
+ CASE WHEN $comment_count_provided = 1 THEN $comment_count ELSE 0 END,
324
+ $url, $created_at, $updated_at
203
325
  )
204
326
  ON CONFLICT(id) DO UPDATE SET
205
327
  identifier = excluded.identifier,
@@ -213,29 +335,105 @@ export function upsertIssues(
213
335
  state_id = excluded.state_id,
214
336
  state_name = excluded.state_name,
215
337
  state_position = excluded.state_position,
338
+ labels = excluded.labels,
339
+ comment_count = CASE
340
+ WHEN $comment_count_provided = 1 THEN excluded.comment_count
341
+ ELSE linear_issues.comment_count
342
+ END,
216
343
  url = excluded.url,
217
344
  created_at = excluded.created_at,
218
345
  updated_at = excluded.updated_at`,
219
346
  )
220
- for (const issue of issues) {
221
- stmt.run({
222
- $id: issue.id,
223
- $identifier: issue.identifier,
224
- $title: issue.title,
225
- $description: issue.description ?? '',
226
- $priority: issue.priority ?? 0,
227
- $assignee_id: issue.assigneeId ?? null,
228
- $assignee_name: issue.assigneeName ?? '',
229
- $project_id: issue.projectId ?? null,
230
- $project_name: issue.projectName ?? '',
231
- $state_id: issue.stateId,
232
- $state_name: issue.stateName,
233
- $state_position: issue.statePosition,
234
- $url: issue.url ?? null,
235
- $created_at: issue.createdAt,
236
- $updated_at: issue.updatedAt,
237
- })
238
- }
347
+ const existingDescStmt = db.prepare(
348
+ 'SELECT description FROM linear_issues WHERE id = $id LIMIT 1',
349
+ )
350
+ const activityStmt = db.prepare(
351
+ `INSERT OR IGNORE INTO linear_activity
352
+ (issue_id, history_id, item_field, from_value, to_value, created_at)
353
+ VALUES ($issue_id, $history_id, $item_field, $from_value, $to_value, $created_at)`,
354
+ )
355
+ const run = db.transaction(() => {
356
+ for (const issue of issues) {
357
+ const nextDescription = issue.description ?? ''
358
+ const hasCommentCount = issue.commentCount !== undefined && issue.commentCount !== null
359
+ const prior = existingDescStmt.get({ $id: issue.id }) as { description: string } | null
360
+ if (prior && prior.description !== nextDescription) {
361
+ activityStmt.run({
362
+ $issue_id: issue.id,
363
+ $history_id: `desc:${issue.updatedAt}`,
364
+ $item_field: 'description',
365
+ $from_value: clampActivityValue(prior.description),
366
+ $to_value: clampActivityValue(nextDescription),
367
+ $created_at: issue.updatedAt,
368
+ })
369
+ }
370
+ upsertStmt.run({
371
+ $id: issue.id,
372
+ $identifier: issue.identifier,
373
+ $title: issue.title,
374
+ $description: nextDescription,
375
+ $priority: issue.priority ?? 0,
376
+ $assignee_id: issue.assigneeId ?? null,
377
+ $assignee_name: issue.assigneeName ?? '',
378
+ $project_id: issue.projectId ?? null,
379
+ $project_name: issue.projectName ?? '',
380
+ $state_id: issue.stateId,
381
+ $state_name: issue.stateName,
382
+ $state_position: issue.statePosition,
383
+ $labels: JSON.stringify(issue.labels ?? []),
384
+ $comment_count: issue.commentCount ?? 0,
385
+ $comment_count_provided: hasCommentCount ? 1 : 0,
386
+ $url: issue.url ?? null,
387
+ $created_at: issue.createdAt,
388
+ $updated_at: issue.updatedAt,
389
+ })
390
+ }
391
+ })
392
+ run()
393
+ }
394
+
395
+ export function deleteLinearIssue(db: Database, idOrIdentifier: string): void {
396
+ db.query(
397
+ `DELETE FROM linear_activity
398
+ WHERE issue_id = $value
399
+ OR issue_id IN (SELECT id FROM linear_issues WHERE identifier = $value)`,
400
+ ).run({
401
+ $value: idOrIdentifier,
402
+ })
403
+ db.query('DELETE FROM linear_issues WHERE id = $v OR identifier = $v').run({
404
+ $v: idOrIdentifier,
405
+ })
406
+ }
407
+
408
+ export function pruneLinearIssues(db: Database, liveIssueIds: string[]): void {
409
+ const keep = new Set(liveIssueIds)
410
+ const staleIssueIds = (db.query('SELECT id FROM linear_issues').all() as { id: string }[])
411
+ .map((row) => row.id)
412
+ .filter((issueId) => !keep.has(issueId))
413
+ if (staleIssueIds.length === 0) return
414
+
415
+ const run = db.transaction((issueIds: string[]) => {
416
+ for (const issueId of issueIds) {
417
+ deleteLinearIssue(db, issueId)
418
+ }
419
+ })
420
+
421
+ run(staleIssueIds)
422
+ }
423
+
424
+ export function adjustLinearIssueCommentCount(
425
+ db: Database,
426
+ idOrIdentifier: string,
427
+ delta: number,
428
+ ): void {
429
+ db.query(
430
+ `UPDATE linear_issues
431
+ SET comment_count = MAX(0, comment_count + $delta)
432
+ WHERE id = $value OR identifier = $value`,
433
+ ).run({
434
+ $delta: delta,
435
+ $value: idOrIdentifier,
436
+ })
239
437
  }
240
438
 
241
439
  export function getCachedColumns(db: Database): LinearStateRow[] {
@@ -257,7 +455,7 @@ function mapPriority(priority: number): Task['priority'] {
257
455
  }
258
456
  }
259
457
 
260
- function taskFromRow(row: {
458
+ interface LinearIssueRow {
261
459
  id: string
262
460
  identifier: string
263
461
  title: string
@@ -267,10 +465,23 @@ function taskFromRow(row: {
267
465
  priority: number
268
466
  assignee_name: string
269
467
  project_name: string
468
+ labels: string
469
+ comment_count: number
270
470
  url: string | null
271
471
  created_at: string
272
472
  updated_at: string
273
- }): Task {
473
+ }
474
+
475
+ function parseLabels(raw: string): string[] {
476
+ try {
477
+ const parsed: unknown = JSON.parse(raw)
478
+ return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
479
+ } catch {
480
+ return []
481
+ }
482
+ }
483
+
484
+ function taskFromRow(row: LinearIssueRow): Task {
274
485
  return {
275
486
  id: `linear:${row.id}`,
276
487
  providerId: row.id,
@@ -282,10 +493,15 @@ function taskFromRow(row: {
282
493
  position: row.state_position,
283
494
  priority: mapPriority(row.priority),
284
495
  assignee: row.assignee_name,
496
+ assignees: row.assignee_name ? [row.assignee_name] : [],
497
+ labels: parseLabels(row.labels),
498
+ comment_count: row.comment_count,
285
499
  project: row.project_name,
286
500
  metadata: '{}',
287
501
  created_at: row.created_at,
288
502
  updated_at: row.updated_at,
503
+ version: row.updated_at,
504
+ source_updated_at: row.updated_at,
289
505
  }
290
506
  }
291
507
 
@@ -301,20 +517,7 @@ export function getCachedBoard(db: Database): BoardView {
301
517
  WHERE state_id = $state_id
302
518
  ORDER BY updated_at DESC, title ASC`,
303
519
  )
304
- .all({ $state_id: column.id }) as Array<{
305
- id: string
306
- identifier: string
307
- title: string
308
- description: string
309
- state_id: string
310
- state_position: number
311
- priority: number
312
- assignee_name: string
313
- project_name: string
314
- url: string | null
315
- created_at: string
316
- updated_at: string
317
- }>
520
+ .all({ $state_id: column.id }) as LinearIssueRow[]
318
521
  ).map(taskFromRow),
319
522
  })),
320
523
  }
@@ -328,39 +531,15 @@ export function getCachedTask(db: Database, lookup: string): Task | null {
328
531
  WHERE id = $lookup OR identifier = $lookup
329
532
  LIMIT 1`,
330
533
  )
331
- .get({ $lookup: normalized }) as {
332
- id: string
333
- identifier: string
334
- title: string
335
- description: string
336
- state_id: string
337
- state_position: number
338
- priority: number
339
- assignee_name: string
340
- project_name: string
341
- url: string | null
342
- created_at: string
343
- updated_at: string
344
- } | null
534
+ .get({ $lookup: normalized }) as LinearIssueRow | null
345
535
  return row ? taskFromRow(row) : null
346
536
  }
347
537
 
348
538
  export function getCachedTasks(db: Database): Task[] {
349
539
  return (
350
- db.query('SELECT * FROM linear_issues ORDER BY updated_at DESC, title ASC').all() as Array<{
351
- id: string
352
- identifier: string
353
- title: string
354
- description: string
355
- state_id: string
356
- state_position: number
357
- priority: number
358
- assignee_name: string
359
- project_name: string
360
- url: string | null
361
- created_at: string
362
- updated_at: string
363
- }>
540
+ db
541
+ .query('SELECT * FROM linear_issues ORDER BY updated_at DESC, title ASC')
542
+ .all() as LinearIssueRow[]
364
543
  ).map(taskFromRow)
365
544
  }
366
545