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