@andypai/agent-kanban 0.2.0 → 0.3.1

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 (72) hide show
  1. package/README.md +120 -24
  2. package/package.json +4 -2
  3. package/src/__tests__/activity.test.ts +16 -10
  4. package/src/__tests__/api.test.ts +99 -3
  5. package/src/__tests__/board-utils.test.ts +100 -0
  6. package/src/__tests__/commands/board.test.ts +7 -14
  7. package/src/__tests__/commands/bulk.test.ts +3 -3
  8. package/src/__tests__/commands/column.test.ts +4 -4
  9. package/src/__tests__/conflict.test.ts +64 -0
  10. package/src/__tests__/db.test.ts +2 -2
  11. package/src/__tests__/id.test.ts +1 -1
  12. package/src/__tests__/index.test.ts +233 -56
  13. package/src/__tests__/jira-adf.test.ts +180 -0
  14. package/src/__tests__/jira-cache.test.ts +304 -0
  15. package/src/__tests__/jira-client.test.ts +169 -0
  16. package/src/__tests__/jira-provider-comment.test.ts +281 -0
  17. package/src/__tests__/jira-provider-mutations.test.ts +771 -0
  18. package/src/__tests__/jira-provider-read.test.ts +594 -0
  19. package/src/__tests__/jira-wiring.test.ts +187 -0
  20. package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
  21. package/src/__tests__/linear-provider-comment.test.ts +243 -0
  22. package/src/__tests__/linear-provider-sync.test.ts +488 -0
  23. package/src/__tests__/local-provider-comment.test.ts +60 -0
  24. package/src/__tests__/mcp-core.test.ts +164 -0
  25. package/src/__tests__/mcp-server.test.ts +252 -0
  26. package/src/__tests__/metrics.test.ts +2 -2
  27. package/src/__tests__/output.test.ts +1 -1
  28. package/src/__tests__/provider-capabilities.test.ts +40 -0
  29. package/src/__tests__/server.test.ts +291 -0
  30. package/src/__tests__/webhooks.test.ts +604 -0
  31. package/src/activity.ts +2 -12
  32. package/src/api.ts +156 -21
  33. package/src/commands/board.ts +4 -14
  34. package/src/commands/bulk.ts +4 -4
  35. package/src/commands/column.ts +4 -4
  36. package/src/commands/mcp.ts +87 -0
  37. package/src/config.ts +1 -1
  38. package/src/db.ts +118 -6
  39. package/src/errors.ts +2 -0
  40. package/src/id.ts +1 -1
  41. package/src/index.ts +83 -35
  42. package/src/mcp/core.ts +193 -0
  43. package/src/mcp/errors.ts +109 -0
  44. package/src/mcp/index.ts +13 -0
  45. package/src/mcp/server.ts +512 -0
  46. package/src/mcp/types.ts +72 -0
  47. package/src/metrics.ts +1 -1
  48. package/src/output.ts +1 -1
  49. package/src/providers/capabilities.ts +22 -17
  50. package/src/providers/errors.ts +1 -1
  51. package/src/providers/index.ts +36 -6
  52. package/src/providers/jira-adf.ts +275 -0
  53. package/src/providers/jira-cache.ts +625 -0
  54. package/src/providers/jira-client.ts +390 -0
  55. package/src/providers/jira.ts +773 -0
  56. package/src/providers/linear-cache.ts +250 -71
  57. package/src/providers/linear-client.ts +255 -15
  58. package/src/providers/linear.ts +338 -20
  59. package/src/providers/local.ts +74 -23
  60. package/src/providers/types.ts +19 -3
  61. package/src/server.ts +141 -13
  62. package/src/tunnel.ts +79 -0
  63. package/src/types.ts +19 -2
  64. package/src/webhooks.ts +36 -0
  65. package/ui/dist/assets/index-DBnoKL_k.css +1 -0
  66. package/ui/dist/assets/index-qNVJ6clH.js +40 -0
  67. package/ui/dist/index.html +2 -2
  68. package/src/__tests__/commands/task.test.ts +0 -144
  69. package/src/commands/task.ts +0 -117
  70. package/src/fixtures.ts +0 -128
  71. package/ui/dist/assets/index-B8f9NB4z.css +0 -1
  72. package/ui/dist/assets/index-zWp-rB7b.js +0 -40
@@ -1,39 +1,62 @@
1
1
  import type { Database } from 'bun:sqlite'
2
- import { ErrorCode, KanbanError } from '../errors.ts'
2
+ import { ErrorCode, KanbanError } from '../errors'
3
3
  import type {
4
4
  ActivityEntry,
5
5
  BoardBootstrap,
6
6
  BoardConfig,
7
7
  BoardMetrics,
8
8
  Column,
9
+ TaskComment,
9
10
  Task,
10
- } from '../types.ts'
11
- import { LINEAR_CAPABILITIES } from './capabilities.ts'
11
+ } from '../types'
12
+ import { headerLower, verifyHmacSha256, type WebhookRequest, type WebhookResult } from '../webhooks'
13
+ import { LINEAR_CAPABILITIES } from './capabilities'
12
14
  import {
15
+ adjustLinearIssueCommentCount,
16
+ deleteLinearIssue,
13
17
  getCachedBoard,
14
18
  getCachedColumns,
15
19
  getCachedConfig,
20
+ getCachedLinearActivity,
16
21
  getCachedTask,
17
22
  getCachedTasks,
18
23
  initLinearCacheSchema,
19
24
  loadSyncMeta,
25
+ pruneLinearIssues,
20
26
  replaceStates,
27
+ saveLinearActivity,
21
28
  saveSyncMeta,
22
29
  upsertIssues,
23
30
  upsertProjects,
24
31
  upsertUsers,
25
- } from './linear-cache.ts'
26
- import { LinearClient } from './linear-client.ts'
27
- import { unsupportedOperation } from './errors.ts'
32
+ type LinearActivityRow,
33
+ } from './linear-cache'
34
+ import { LinearClient, type LinearComment } from './linear-client'
35
+ import { unsupportedOperation } from './errors'
28
36
  import type {
29
37
  CreateTaskInput,
30
38
  KanbanProvider,
31
39
  ProviderContext,
40
+ ProviderSyncStatus,
32
41
  TaskListFilters,
33
42
  UpdateTaskInput,
34
- } from './types.ts'
43
+ } from './types'
35
44
 
36
45
  const SYNC_INTERVAL_MS = 30_000
46
+ const FULL_RECONCILIATION_INTERVAL_MS = 5 * 60_000
47
+
48
+ function parseTimestamp(value: string | null | undefined): number {
49
+ if (!value) return 0
50
+ const parsed = Date.parse(value)
51
+ return Number.isFinite(parsed) ? parsed : 0
52
+ }
53
+
54
+ function maxTimestamp(a: string | null | undefined, b: string | null | undefined): string | null {
55
+ const aMs = parseTimestamp(a)
56
+ const bMs = parseTimestamp(b)
57
+ if (!aMs && !bMs) return null
58
+ return aMs >= bMs ? (a ?? null) : (b ?? null)
59
+ }
37
60
 
38
61
  function toLinearPriority(priority: Task['priority'] | undefined): number | undefined {
39
62
  switch (priority) {
@@ -63,18 +86,40 @@ export class LinearProvider implements KanbanProvider {
63
86
  this.client = new LinearClient(apiKey)
64
87
  }
65
88
 
89
+ private resolvedTeamId(): string {
90
+ return loadSyncMeta(this.db).team?.id ?? this.teamId
91
+ }
92
+
93
+ private async getConfiguredTeam(): Promise<{ id: string; key: string; name: string }> {
94
+ const metaTeam = loadSyncMeta(this.db).team
95
+ if (metaTeam) return metaTeam
96
+
97
+ const team = await this.client.getTeam(this.teamId)
98
+ const configuredTeam = { id: team.id, key: team.key, name: team.name }
99
+ saveSyncMeta(this.db, { team: configuredTeam })
100
+ return configuredTeam
101
+ }
102
+
66
103
  private async sync(force = false): Promise<void> {
67
104
  const meta = loadSyncMeta(this.db)
68
- const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
69
- if (!force && lastSyncAtMs && Date.now() - lastSyncAtMs < SYNC_INTERVAL_MS) return
105
+ const lastSyncAtMs = parseTimestamp(meta.lastSyncAt)
106
+ const lastFullSyncAtMs = parseTimestamp(meta.lastFullSyncAt)
107
+ const now = Date.now()
108
+ if (!force && lastSyncAtMs && now - lastSyncAtMs < SYNC_INTERVAL_MS) return
109
+
110
+ const shouldFullSync =
111
+ force ||
112
+ !lastFullSyncAtMs ||
113
+ !meta.lastIssueUpdatedAt ||
114
+ now - lastFullSyncAtMs >= FULL_RECONCILIATION_INTERVAL_MS
70
115
 
71
- const [team, users, projects, issues] = await Promise.all([
72
- this.client.getTeam(this.teamId),
116
+ const team = await this.client.getTeam(this.teamId)
117
+ const [users, projects, issues] = await Promise.all([
73
118
  this.client.listUsers(),
74
119
  this.client.listProjects(),
75
120
  this.client.listIssues(
76
- this.teamId,
77
- force ? undefined : (meta.lastIssueUpdatedAt ?? undefined),
121
+ team.id,
122
+ shouldFullSync ? undefined : (meta.lastIssueUpdatedAt ?? undefined),
78
123
  ),
79
124
  ])
80
125
 
@@ -96,27 +141,93 @@ export class LinearProvider implements KanbanProvider {
96
141
  stateId: issue.state.id,
97
142
  stateName: issue.state.name,
98
143
  statePosition: issue.state.position,
144
+ labels: issue.labels ?? [],
145
+ commentCount: issue.commentCount,
99
146
  url: issue.url ?? null,
100
147
  createdAt: issue.createdAt,
101
148
  updatedAt: issue.updatedAt,
102
149
  })),
103
150
  )
151
+ if (shouldFullSync) {
152
+ pruneLinearIssues(
153
+ this.db,
154
+ issues.map((issue) => issue.id),
155
+ )
156
+ }
104
157
 
105
- const newestIssueTimestamp =
158
+ const newestIssueTimestamp = maxTimestamp(
159
+ meta.lastIssueUpdatedAt,
106
160
  issues.length > 0
107
161
  ? issues.reduce(
108
162
  (latest, issue) => (issue.updatedAt > latest ? issue.updatedAt : latest),
109
163
  issues[0]!.updatedAt,
110
164
  )
111
- : meta.lastIssueUpdatedAt
165
+ : null,
166
+ )
167
+
168
+ // Best-effort changelog ingest; failures don't fail the main sync.
169
+ await this.ingestTeamHistory(
170
+ issues.map((issue) => issue.id),
171
+ meta.lastIssueUpdatedAt,
172
+ ).catch((err) => {
173
+ console.warn('[linear] issueHistory ingest failed:', err)
174
+ })
112
175
 
176
+ const syncedAt = new Date().toISOString()
113
177
  saveSyncMeta(this.db, {
114
178
  team: { id: team.id, key: team.key, name: team.name },
115
- lastSyncAt: new Date().toISOString(),
116
- lastIssueUpdatedAt: newestIssueTimestamp ?? new Date().toISOString(),
179
+ lastSyncAt: syncedAt,
180
+ lastFullSyncAt: shouldFullSync ? syncedAt : undefined,
181
+ lastIssueUpdatedAt: newestIssueTimestamp ?? syncedAt,
117
182
  })
118
183
  }
119
184
 
185
+ private async ingestTeamHistory(issueIds: string[], sinceIso: string | null): Promise<void> {
186
+ if (issueIds.length === 0) return
187
+ const concurrency = 5
188
+ for (let i = 0; i < issueIds.length; i += concurrency) {
189
+ const batch = issueIds.slice(i, i + concurrency)
190
+ const results = await Promise.all(
191
+ batch.map((issueId) => this.fetchIssueHistory(issueId, sinceIso)),
192
+ )
193
+ const rows = results.flat()
194
+ if (rows.length > 0) saveLinearActivity(this.db, rows)
195
+ }
196
+ }
197
+
198
+ private async fetchIssueHistory(
199
+ issueId: string,
200
+ sinceIso: string | null,
201
+ ): Promise<LinearActivityRow[]> {
202
+ const rows: LinearActivityRow[] = []
203
+ let cursor: string | null = null
204
+ for (let page = 0; page < 10; page++) {
205
+ const batch = await this.client.listIssueHistory({ issueId, first: 50, after: cursor })
206
+ let reachedKnown = false
207
+ for (const node of batch.nodes) {
208
+ // Linear returns history newest-first; once we hit an entry we've already ingested,
209
+ // every subsequent page is older still, so break out of pagination entirely.
210
+ if (sinceIso && node.createdAt <= sinceIso) {
211
+ reachedKnown = true
212
+ break
213
+ }
214
+ if (!node.fromState && !node.toState) continue
215
+ rows.push({
216
+ issue_id: issueId,
217
+ history_id: node.id,
218
+ item_field: 'state',
219
+ from_value: node.fromState?.id ?? null,
220
+ to_value: node.toState?.id ?? null,
221
+ created_at: node.createdAt,
222
+ })
223
+ }
224
+ if (reachedKnown) break
225
+ if (!batch.pageInfo.hasNextPage || !batch.pageInfo.endCursor) break
226
+ cursor = batch.pageInfo.endCursor
227
+ }
228
+ return rows
229
+ }
230
+
120
231
  private resolveTask(idOrRef: string): Task {
121
232
  const task = getCachedTask(this.db, idOrRef)
122
233
  if (!task) {
@@ -155,6 +266,30 @@ export class LinearProvider implements KanbanProvider {
155
266
  return row?.id
156
267
  }
157
268
 
269
+ private toTaskComment(task: Task, comment: LinearComment): TaskComment {
270
+ return {
271
+ id: comment.id,
272
+ task_id: task.id,
273
+ body: comment.body,
274
+ author: comment.user?.displayName || comment.user?.name || null,
275
+ created_at: comment.createdAt,
276
+ updated_at: comment.updatedAt,
277
+ }
278
+ }
279
+
280
+ async syncCache(): Promise<void> {
281
+ await this.sync()
282
+ }
283
+
284
+ async getSyncStatus(): Promise<ProviderSyncStatus> {
285
+ const meta = loadSyncMeta(this.db)
286
+ return {
287
+ lastSyncAt: meta.lastSyncAt,
288
+ lastFullSyncAt: meta.lastFullSyncAt,
289
+ lastWebhookAt: meta.lastWebhookAt,
290
+ }
291
+ }
292
+
158
293
  async getContext(): Promise<ProviderContext> {
159
294
  await this.sync()
160
295
  const meta = loadSyncMeta(this.db)
@@ -214,7 +349,7 @@ export class LinearProvider implements KanbanProvider {
214
349
  await this.sync()
215
350
  const state = input.column ? this.resolveState(input.column) : undefined
216
351
  const result = await this.client.createIssue({
217
- teamId: this.teamId,
352
+ teamId: this.resolvedTeamId(),
218
353
  stateId: state?.id,
219
354
  title: input.title,
220
355
  description: input.description,
@@ -240,6 +375,8 @@ export class LinearProvider implements KanbanProvider {
240
375
  stateId: issue.state.id,
241
376
  stateName: issue.state.name,
242
377
  statePosition: issue.state.position,
378
+ labels: issue.labels ?? [],
379
+ commentCount: issue.commentCount,
243
380
  url: issue.url ?? null,
244
381
  createdAt: issue.createdAt,
245
382
  updatedAt: issue.updatedAt,
@@ -251,6 +388,12 @@ export class LinearProvider implements KanbanProvider {
251
388
  async updateTask(idOrRef: string, input: UpdateTaskInput) {
252
389
  await this.sync()
253
390
  const task = this.resolveTask(idOrRef)
391
+ if (input.expectedVersion !== undefined && task.version !== input.expectedVersion) {
392
+ throw new KanbanError(
393
+ ErrorCode.CONFLICT,
394
+ `Linear issue ${task.externalRef ?? idOrRef} was updated remotely (expected version ${input.expectedVersion}, current ${task.version ?? 'unknown'})`,
395
+ )
396
+ }
254
397
  const updateInput: Record<string, unknown> = {}
255
398
  if (input.title !== undefined) updateInput['title'] = input.title
256
399
  if (input.description !== undefined) updateInput['description'] = input.description
@@ -286,8 +429,75 @@ export class LinearProvider implements KanbanProvider {
286
429
  unsupportedOperation('Task deletion is not supported in Linear mode')
287
430
  }
288
431
 
289
- async getActivity(_limit?: number, _taskId?: string): Promise<ActivityEntry[]> {
290
- unsupportedOperation('Activity is not available in Linear mode')
432
+ async listComments(idOrRef: string): Promise<TaskComment[]> {
433
+ await this.sync()
434
+ const task = this.resolveTask(idOrRef)
435
+ const comments = await this.client.listComments(task.providerId || task.id)
436
+ return comments.map((comment) => this.toTaskComment(task, comment))
437
+ }
438
+
439
+ async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
440
+ await this.sync()
441
+ const task = this.resolveTask(idOrRef)
442
+ const comment = await this.client.getComment(commentId)
443
+ return this.toTaskComment(task, comment)
444
+ }
445
+
446
+ async comment(idOrRef: string, body: string): Promise<TaskComment> {
447
+ await this.sync()
448
+ const task = this.resolveTask(idOrRef)
449
+ const result = await this.client.commentCreate(task.providerId || task.id, body)
450
+ if (!result.success || !result.comment) {
451
+ throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear comment creation failed')
452
+ }
453
+ adjustLinearIssueCommentCount(this.db, task.providerId || task.id, 1)
454
+ return this.toTaskComment(task, result.comment)
455
+ }
456
+
457
+ async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
458
+ await this.sync()
459
+ const task = this.resolveTask(idOrRef)
460
+ const result = await this.client.commentUpdate(commentId, body)
461
+ if (!result.success || !result.comment) {
462
+ throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear comment update failed')
463
+ }
464
+ return this.toTaskComment(task, result.comment)
465
+ }
466
+
467
+ async getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]> {
468
+ await this.sync()
469
+ const issueId = taskId ? this.resolveIssueIdFromTaskId(taskId) : undefined
470
+ const rows = getCachedLinearActivity(this.db, {
471
+ ...(issueId !== undefined ? { issueId } : {}),
472
+ limit: limit ?? 100,
473
+ })
474
+ return rows.map((row) => this.activityRowToEntry(row))
475
+ }
476
+
477
+ private resolveIssueIdFromTaskId(taskId: string): string | undefined {
478
+ const normalized = taskId.startsWith('linear:') ? taskId.slice('linear:'.length) : taskId
479
+ const row = this.db
480
+ .query<
481
+ { id: string },
482
+ Record<string, string>
483
+ >(`SELECT id FROM linear_issues WHERE id = $lookup OR identifier = $lookup LIMIT 1`)
484
+ .get({ $lookup: normalized })
485
+ return row?.id
486
+ }
487
+
488
+ private activityRowToEntry(row: LinearActivityRow): ActivityEntry {
489
+ // fromState/toState already reference state ids which agent-kanban
490
+ // surfaces 1:1 as column ids (see linear_states/getCachedColumns),
491
+ // so no lookup is needed here.
492
+ return {
493
+ id: `linear-activity:${row.issue_id}:${row.history_id}:${row.item_field}`,
494
+ task_id: `linear:${row.issue_id}`,
495
+ action: row.item_field === 'state' ? 'moved' : 'updated',
496
+ field_changed: row.item_field,
497
+ old_value: row.from_value,
498
+ new_value: row.to_value,
499
+ timestamp: row.created_at,
500
+ }
291
501
  }
292
502
 
293
503
  async getMetrics(): Promise<BoardMetrics> {
@@ -302,4 +512,112 @@ export class LinearProvider implements KanbanProvider {
302
512
  async patchConfig(_input: Partial<BoardConfig>): Promise<BoardConfig> {
303
513
  unsupportedOperation('Config mutation is not supported in Linear mode')
304
514
  }
515
+
516
+ async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
517
+ const secret = process.env['LINEAR_WEBHOOK_SECRET']
518
+ if (secret) {
519
+ const sig = headerLower(payload.headers, 'linear-signature')
520
+ if (!verifyHmacSha256(secret, payload.rawBody, sig)) {
521
+ return { handled: false, unauthorized: true, message: 'Invalid signature' }
522
+ }
523
+ }
524
+ let body: {
525
+ action?: 'create' | 'update' | 'remove'
526
+ type?: string
527
+ data?: {
528
+ id: string
529
+ identifier?: string
530
+ title?: string
531
+ description?: string | null
532
+ priority?: number | null
533
+ url?: string | null
534
+ createdAt?: string
535
+ updatedAt?: string
536
+ assignee?: { id: string; name?: string | null } | null
537
+ assigneeId?: string | null
538
+ project?: { id: string; name: string } | null
539
+ projectId?: string | null
540
+ state?: { id: string; name: string; position?: number } | null
541
+ stateId?: string | null
542
+ team?: { id?: string | null; key?: string | null } | null
543
+ teamId?: string | null
544
+ labels?: Array<{ id: string; name: string }> | null
545
+ commentCount?: number | null
546
+ }
547
+ } = {}
548
+ try {
549
+ body = JSON.parse(payload.rawBody) as typeof body
550
+ } catch {
551
+ return { handled: false, message: 'Invalid JSON body' }
552
+ }
553
+ if (body.type !== 'Issue') {
554
+ return { handled: false, message: `Ignoring ${body.type ?? 'unknown'} event` }
555
+ }
556
+ const data = body.data
557
+ if (!data) return { handled: false, message: 'No data in payload' }
558
+
559
+ if (body.action === 'remove') {
560
+ deleteLinearIssue(this.db, data.id)
561
+ saveSyncMeta(this.db, { lastWebhookAt: new Date().toISOString() })
562
+ return { handled: true }
563
+ }
564
+
565
+ if (body.action === 'create' || body.action === 'update') {
566
+ const configuredTeam = await this.getConfiguredTeam()
567
+ const payloadTeamId = data.team?.id ?? data.teamId ?? null
568
+ if (payloadTeamId && payloadTeamId !== configuredTeam.id) {
569
+ return {
570
+ handled: false,
571
+ message: `Ignoring issue from team '${payloadTeamId}'`,
572
+ }
573
+ }
574
+
575
+ if (!payloadTeamId) {
576
+ const issueTeam = await this.client.getIssueTeam(data.id)
577
+ if (!issueTeam) {
578
+ return {
579
+ handled: false,
580
+ message: `Ignoring issue '${data.id}' because its team could not be verified`,
581
+ }
582
+ }
583
+ if (issueTeam.id !== configuredTeam.id) {
584
+ return {
585
+ handled: false,
586
+ message: `Ignoring issue from team '${issueTeam.key}'`,
587
+ }
588
+ }
589
+ }
590
+
591
+ if (!data.identifier || !data.title || !data.createdAt || !data.updatedAt) {
592
+ return { handled: false, message: 'Missing required issue fields' }
593
+ }
594
+ const stateId = data.state?.id ?? data.stateId ?? null
595
+ if (!stateId) return { handled: false, message: 'Missing state id' }
596
+ upsertIssues(this.db, [
597
+ {
598
+ id: data.id,
599
+ identifier: data.identifier,
600
+ title: data.title,
601
+ description: data.description ?? '',
602
+ priority: data.priority ?? 0,
603
+ assigneeId: data.assignee?.id ?? data.assigneeId ?? null,
604
+ assigneeName: data.assignee?.name ?? null,
605
+ projectId: data.project?.id ?? data.projectId ?? null,
606
+ projectName: data.project?.name ?? null,
607
+ stateId,
608
+ stateName: data.state?.name ?? '',
609
+ statePosition: data.state?.position ?? 0,
610
+ labels: (data.labels ?? []).map((l) => l.name),
611
+ commentCount: data.commentCount,
612
+ url: data.url ?? null,
613
+ createdAt: data.createdAt,
614
+ updatedAt: data.updatedAt,
615
+ },
616
+ ])
617
+ saveSyncMeta(this.db, { lastWebhookAt: new Date().toISOString() })
618
+ return { handled: true }
619
+ }
620
+
621
+ return { handled: false, message: `Unsupported action: ${body.action}` }
622
+ }
305
623
  }
@@ -1,27 +1,35 @@
1
1
  import type { Database } from 'bun:sqlite'
2
- import { listActivity } from '../activity.ts'
3
- import { getConfigPath, loadConfig, saveConfig } from '../config.ts'
2
+ import { listActivity } from '../activity'
3
+ import { getConfigPath, loadConfig, saveConfig } from '../config'
4
4
  import {
5
+ addComment,
6
+ countComments,
7
+ countCommentsByTask,
5
8
  addTask,
6
9
  deleteTask,
7
10
  getBoardView,
11
+ getComment as getTaskComment,
8
12
  getDbPath,
9
13
  getTask,
14
+ listComments as listTaskComments,
10
15
  listColumns,
11
16
  listTasks,
12
17
  moveTask,
18
+ updateComment as updateTaskComment,
13
19
  updateTask,
14
- } from '../db.ts'
15
- import { getBoardMetrics, getDiscoveredAssignees, getDiscoveredProjects } from '../metrics.ts'
16
- import type { BoardBootstrap, BoardConfig, Task } from '../types.ts'
17
- import { LOCAL_CAPABILITIES } from './capabilities.ts'
20
+ } from '../db'
21
+ import { getBoardMetrics, getDiscoveredAssignees, getDiscoveredProjects } from '../metrics'
22
+ import type { BoardBootstrap, BoardConfig, Task, TaskComment } from '../types'
23
+ import { ErrorCode, KanbanError } from '../errors'
24
+ import { LOCAL_CAPABILITIES } from './capabilities'
18
25
  import type {
19
26
  CreateTaskInput,
20
27
  KanbanProvider,
21
28
  ProviderContext,
29
+ ProviderSyncStatus,
22
30
  TaskListFilters,
23
31
  UpdateTaskInput,
24
- } from './types.ts'
32
+ } from './types'
25
33
 
26
34
  function buildLocalConfig(
27
35
  db: Database,
@@ -37,15 +45,6 @@ function buildLocalConfig(
37
45
  }
38
46
  }
39
47
 
40
- function enrichTask(task: Task): Task {
41
- return {
42
- ...task,
43
- providerId: task.id,
44
- externalRef: task.id,
45
- url: null,
46
- }
47
- }
48
-
49
48
  export class LocalProvider implements KanbanProvider {
50
49
  readonly type = 'local' as const
51
50
 
@@ -54,6 +53,22 @@ export class LocalProvider implements KanbanProvider {
54
53
  private readonly dbPath = getDbPath(),
55
54
  ) {}
56
55
 
56
+ private enrichTask(task: Task, commentCount?: number): Task {
57
+ const revision = task.revision ?? 0
58
+ const assignees = task.assignee ? [task.assignee] : []
59
+ return {
60
+ ...task,
61
+ providerId: task.id,
62
+ externalRef: task.id,
63
+ url: null,
64
+ assignees,
65
+ labels: [],
66
+ comment_count: commentCount ?? countComments(this.db, task.id),
67
+ version: String(revision),
68
+ source_updated_at: null,
69
+ }
70
+ }
71
+
57
72
  async getContext(): Promise<ProviderContext> {
58
73
  return {
59
74
  provider: this.type,
@@ -77,10 +92,11 @@ export class LocalProvider implements KanbanProvider {
77
92
 
78
93
  async getBoard() {
79
94
  const board = getBoardView(this.db)
95
+ const counts = countCommentsByTask(this.db)
80
96
  return {
81
97
  columns: board.columns.map((column) => ({
82
98
  ...column,
83
- tasks: column.tasks.map(enrichTask),
99
+ tasks: column.tasks.map((task) => this.enrichTask(task, counts.get(task.id) ?? 0)),
84
100
  })),
85
101
  }
86
102
  }
@@ -90,27 +106,58 @@ export class LocalProvider implements KanbanProvider {
90
106
  }
91
107
 
92
108
  async listTasks(filters: TaskListFilters = {}) {
93
- return listTasks(this.db, filters).map(enrichTask)
109
+ const counts = countCommentsByTask(this.db)
110
+ return listTasks(this.db, filters).map((task) =>
111
+ this.enrichTask(task, counts.get(task.id) ?? 0),
112
+ )
94
113
  }
95
114
 
96
115
  async getTask(idOrRef: string) {
97
- return enrichTask(getTask(this.db, idOrRef))
116
+ return this.enrichTask(getTask(this.db, idOrRef))
98
117
  }
99
118
 
100
119
  async createTask(input: CreateTaskInput) {
101
- return enrichTask(addTask(this.db, input.title, input))
120
+ return this.enrichTask(addTask(this.db, input.title, input))
102
121
  }
103
122
 
104
123
  async updateTask(idOrRef: string, input: UpdateTaskInput) {
105
- return enrichTask(updateTask(this.db, idOrRef, input))
124
+ if (input.expectedVersion !== undefined) {
125
+ const current = getTask(this.db, idOrRef)
126
+ const currentVersion = String(current.revision ?? 0)
127
+ if (currentVersion !== input.expectedVersion) {
128
+ throw new KanbanError(
129
+ ErrorCode.CONFLICT,
130
+ `Task ${idOrRef} was modified since you loaded it (expected version ${input.expectedVersion}, current ${currentVersion})`,
131
+ )
132
+ }
133
+ }
134
+ const updates: Omit<UpdateTaskInput, 'expectedVersion'> = { ...input }
135
+ delete (updates as UpdateTaskInput).expectedVersion
136
+ return this.enrichTask(updateTask(this.db, idOrRef, updates))
106
137
  }
107
138
 
108
139
  async moveTask(idOrRef: string, column: string) {
109
- return enrichTask(moveTask(this.db, idOrRef, column))
140
+ return this.enrichTask(moveTask(this.db, idOrRef, column))
110
141
  }
111
142
 
112
143
  async deleteTask(idOrRef: string) {
113
- return enrichTask(deleteTask(this.db, idOrRef))
144
+ return this.enrichTask(deleteTask(this.db, idOrRef))
145
+ }
146
+
147
+ async listComments(idOrRef: string): Promise<TaskComment[]> {
148
+ return listTaskComments(this.db, idOrRef)
149
+ }
150
+
151
+ async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
152
+ return getTaskComment(this.db, idOrRef, commentId)
153
+ }
154
+
155
+ async comment(idOrRef: string, body: string): Promise<TaskComment> {
156
+ return addComment(this.db, idOrRef, body)
157
+ }
158
+
159
+ async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
160
+ return updateTaskComment(this.db, idOrRef, commentId, body)
114
161
  }
115
162
 
116
163
  async getActivity(limit?: number, taskId?: string) {
@@ -132,4 +179,8 @@ export class LocalProvider implements KanbanProvider {
132
179
  saveConfig(getConfigPath(this.dbPath), config)
133
180
  return this.getConfig()
134
181
  }
182
+
183
+ async getSyncStatus(): Promise<ProviderSyncStatus | null> {
184
+ return null
185
+ }
135
186
  }
@@ -1,3 +1,4 @@
1
+ import type { WebhookRequest, WebhookResult } from '../webhooks'
1
2
  import type {
2
3
  ActivityEntry,
3
4
  BoardBootstrap,
@@ -8,8 +9,9 @@ import type {
8
9
  Priority,
9
10
  ProviderCapabilities,
10
11
  ProviderTeamInfo,
12
+ TaskComment,
11
13
  Task,
12
- } from '../types.ts'
14
+ } from '../types'
13
15
 
14
16
  export interface TaskListFilters {
15
17
  column?: string
@@ -37,16 +39,23 @@ export interface UpdateTaskInput {
37
39
  assignee?: string
38
40
  project?: string
39
41
  metadata?: string
42
+ expectedVersion?: string
40
43
  }
41
44
 
42
45
  export interface ProviderContext {
43
- provider: 'local' | 'linear'
46
+ provider: 'local' | 'linear' | 'jira'
44
47
  capabilities: ProviderCapabilities
45
48
  team: ProviderTeamInfo | null
46
49
  }
47
50
 
51
+ export interface ProviderSyncStatus {
52
+ lastSyncAt: string | null
53
+ lastFullSyncAt: string | null
54
+ lastWebhookAt: string | null
55
+ }
56
+
48
57
  export interface KanbanProvider {
49
- readonly type: 'local' | 'linear'
58
+ readonly type: 'local' | 'linear' | 'jira'
50
59
 
51
60
  getContext(): Promise<ProviderContext>
52
61
  getBootstrap(): Promise<BoardBootstrap>
@@ -58,8 +67,15 @@ export interface KanbanProvider {
58
67
  updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task>
59
68
  moveTask(idOrRef: string, column: string): Promise<Task>
60
69
  deleteTask(idOrRef: string): Promise<Task>
70
+ listComments(idOrRef: string): Promise<TaskComment[]>
71
+ getComment(idOrRef: string, commentId: string): Promise<TaskComment>
72
+ comment(idOrRef: string, body: string): Promise<TaskComment>
73
+ updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment>
61
74
  getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]>
62
75
  getMetrics(): Promise<BoardMetrics>
63
76
  getConfig(): Promise<BoardConfig>
64
77
  patchConfig(input: Partial<BoardConfig>): Promise<BoardConfig>
78
+ syncCache?(): Promise<void>
79
+ getSyncStatus?(): Promise<ProviderSyncStatus | null>
80
+ handleWebhook?(payload: WebhookRequest): Promise<WebhookResult>
65
81
  }