@andypai/agent-kanban 0.6.0 → 0.6.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 (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 +75 -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 +921 -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 +688 -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 +26 -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
@@ -280,11 +280,13 @@ export function upsertProjects(
280
280
  }
281
281
 
282
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]'
283
+ // Exported and shared with the Postgres cache (postgres-linear-cache.ts) so both
284
+ // backends clamp identically and the truncation marker can't drift between them.
285
+ export const ACTIVITY_VALUE_MAX_CHARS = 4096
286
+ export const ACTIVITY_TRUNCATION_SUFFIX = '…[truncated]'
285
287
  const ACTIVITY_VALUE_BUDGET = ACTIVITY_VALUE_MAX_CHARS - ACTIVITY_TRUNCATION_SUFFIX.length
286
288
 
287
- function clampActivityValue(value: string): string {
289
+ export function clampActivityValue(value: string): string {
288
290
  if (value.length <= ACTIVITY_VALUE_MAX_CHARS) return value
289
291
  return value.slice(0, ACTIVITY_VALUE_BUDGET) + ACTIVITY_TRUNCATION_SUFFIX
290
292
  }
@@ -0,0 +1,688 @@
1
+ import { ErrorCode, KanbanError } from '../errors'
2
+ import type {
3
+ ActivityEntry,
4
+ BoardBootstrap,
5
+ BoardConfig,
6
+ BoardMetrics,
7
+ BoardView,
8
+ Column,
9
+ ProviderTeamInfo,
10
+ Task,
11
+ TaskComment,
12
+ } from '../types'
13
+ import {
14
+ authorizeWebhook,
15
+ headerLower,
16
+ verifyHmacSha256,
17
+ type WebhookRequest,
18
+ type WebhookResult,
19
+ } from '../webhooks'
20
+ import { LINEAR_CAPABILITIES } from './capabilities'
21
+ import { LinearClient, resolveLabelIdsForCreate, type LinearComment } from './linear-client'
22
+ import type { LinearActivityRow, LinearStateRow, LinearSyncMeta } from './linear-cache'
23
+ import { unsupportedOperation } from './errors'
24
+ import type {
25
+ CreateTaskInput,
26
+ KanbanProvider,
27
+ ProviderContext,
28
+ ProviderSyncStatus,
29
+ TaskListFilters,
30
+ UpdateTaskInput,
31
+ } from './types'
32
+ import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
33
+
34
+ const FULL_RECONCILIATION_INTERVAL_MS = 5 * 60_000
35
+
36
+ function parseTimestamp(value: string | null | undefined): number {
37
+ if (!value) return 0
38
+ const parsed = Date.parse(value)
39
+ return Number.isFinite(parsed) ? parsed : 0
40
+ }
41
+
42
+ function maxTimestamp(a: string | null | undefined, b: string | null | undefined): string | null {
43
+ const aMs = parseTimestamp(a)
44
+ const bMs = parseTimestamp(b)
45
+ if (!aMs && !bMs) return null
46
+ return aMs >= bMs ? (a ?? null) : (b ?? null)
47
+ }
48
+
49
+ function toLinearPriority(priority: Task['priority'] | undefined): number | undefined {
50
+ switch (priority) {
51
+ case 'urgent':
52
+ return 1
53
+ case 'high':
54
+ return 2
55
+ case 'medium':
56
+ return 3
57
+ case 'low':
58
+ return 4
59
+ default:
60
+ return undefined
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Storage-agnostic cache/repository port the Linear core depends on. The SQLite
66
+ * (linear.ts) and Postgres (postgres-linear.ts) backends each provide an
67
+ * implementation; the core never touches a Database or Sql client directly. All
68
+ * methods are async so the Postgres backend is native and the SQLite backend
69
+ * wraps its synchronous bun:sqlite calls.
70
+ */
71
+ export interface LinearCachePort {
72
+ readonly ready: Promise<void>
73
+
74
+ loadSyncMeta(): Promise<LinearSyncMeta>
75
+ saveSyncMeta(meta: Partial<LinearSyncMeta>): Promise<void>
76
+
77
+ replaceStates(
78
+ states: Array<{
79
+ id: string
80
+ name: string
81
+ position: number
82
+ color?: string | null
83
+ type?: string | null
84
+ }>,
85
+ ): Promise<void>
86
+ upsertUsers(users: Array<{ id: string; name: string; active?: boolean }>): Promise<void>
87
+ upsertProjects(
88
+ projects: Array<{ id: string; name: string; url?: string | null; state?: string | null }>,
89
+ ): Promise<void>
90
+
91
+ upsertIssues(
92
+ issues: Array<{
93
+ id: string
94
+ identifier: string
95
+ title: string
96
+ description?: string | null
97
+ priority?: number | null
98
+ assigneeId?: string | null
99
+ assigneeName?: string | null
100
+ projectId?: string | null
101
+ projectName?: string | null
102
+ stateId: string
103
+ stateName: string
104
+ statePosition: number
105
+ labels?: string[] | null
106
+ commentCount?: number | null
107
+ url?: string | null
108
+ createdAt: string
109
+ updatedAt: string
110
+ }>,
111
+ ): Promise<void>
112
+ deleteIssue(idOrIdentifier: string): Promise<void>
113
+ pruneIssues(liveIssueIds: string[]): Promise<void>
114
+ adjustIssueCommentCount(idOrIdentifier: string, delta: number): Promise<void>
115
+
116
+ saveActivity(rows: LinearActivityRow[]): Promise<void>
117
+ getCachedActivity(params?: { issueId?: string; limit?: number }): Promise<LinearActivityRow[]>
118
+
119
+ getCachedColumns(): Promise<LinearStateRow[]>
120
+ getCachedBoard(): Promise<BoardView>
121
+ getCachedTask(lookup: string): Promise<Task | null>
122
+ getCachedTasks(): Promise<Task[]>
123
+ getCachedConfig(): Promise<BoardConfig>
124
+
125
+ // Lookups used by the write paths
126
+ findUserIdByName(name: string): Promise<string | null>
127
+ findProjectIdByName(name: string): Promise<string | null>
128
+ resolveIssueId(lookup: string): Promise<string | null>
129
+ }
130
+
131
+ /**
132
+ * Shared Linear provider business logic (sync orchestration, issue-history
133
+ * ingest, writes, state transitions, comments, webhook dispatch, activity
134
+ * mapping). Concrete providers (LinearProvider over SQLite, PostgresLinearProvider
135
+ * over Postgres) subclass this and inject a LinearCachePort plus the API client.
136
+ */
137
+ export class LinearProviderCore implements KanbanProvider {
138
+ readonly type = 'linear' as const
139
+ // When a server-side background warmer owns cache refresh, request-path syncs
140
+ // are suppressed once the cache is warm so reads/writes never block on Linear I/O.
141
+ private backgroundManaged = false
142
+
143
+ constructor(
144
+ protected readonly cache: LinearCachePort,
145
+ protected readonly teamId: string,
146
+ protected readonly client: LinearClient,
147
+ protected readonly pollingSyncIntervalMs = DEFAULT_POLLING_SYNC_INTERVAL_MS,
148
+ ) {}
149
+
150
+ async initialize(): Promise<void> {
151
+ await this.cache.ready
152
+ }
153
+
154
+ private async resolvedTeamId(): Promise<string> {
155
+ return (await this.cache.loadSyncMeta()).team?.id ?? this.teamId
156
+ }
157
+
158
+ private async getConfiguredTeam(): Promise<ProviderTeamInfo> {
159
+ const metaTeam = (await this.cache.loadSyncMeta()).team
160
+ if (metaTeam) return metaTeam
161
+
162
+ const team = await this.client.getTeam(this.teamId)
163
+ const configuredTeam = { id: team.id, key: team.key, name: team.name }
164
+ await this.cache.saveSyncMeta({ team: configuredTeam })
165
+ return configuredTeam
166
+ }
167
+
168
+ protected async sync(force = false, viaWarmer = false): Promise<void> {
169
+ await this.cache.ready
170
+ const meta = await this.cache.loadSyncMeta()
171
+ const lastSyncAtMs = parseTimestamp(meta.lastSyncAt)
172
+ const lastFullSyncAtMs = parseTimestamp(meta.lastFullSyncAt)
173
+ // Server mode: a background warmer (syncCache(), viaWarmer=true) owns refresh.
174
+ // Once the cache has synced at least once, implicit request-path reads serve the
175
+ // warm cache instead of blocking on a Linear round-trip, which could exceed the
176
+ // HTTP idle timeout. Forced syncs (writes' read-after-write) and the warmer
177
+ // still run; CLI mode and cold start sync synchronously.
178
+ if (this.backgroundManaged && !force && !viaWarmer && lastSyncAtMs) return
179
+ const now = Date.now()
180
+ if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
181
+
182
+ const shouldFullSync =
183
+ force ||
184
+ !lastFullSyncAtMs ||
185
+ !meta.lastIssueUpdatedAt ||
186
+ now - lastFullSyncAtMs >= FULL_RECONCILIATION_INTERVAL_MS
187
+
188
+ const team = await this.client.getTeam(this.teamId)
189
+ const [users, projects, issues] = await Promise.all([
190
+ this.client.listUsers(),
191
+ this.client.listProjects(),
192
+ this.client.listIssues(
193
+ team.id,
194
+ shouldFullSync ? undefined : (meta.lastIssueUpdatedAt ?? undefined),
195
+ ),
196
+ ])
197
+
198
+ await this.cache.replaceStates(team.states)
199
+ await this.cache.upsertUsers(users)
200
+ await this.cache.upsertProjects(projects)
201
+ await this.cache.upsertIssues(
202
+ issues.map((issue) => ({
203
+ id: issue.id,
204
+ identifier: issue.identifier,
205
+ title: issue.title,
206
+ description: issue.description ?? '',
207
+ priority: issue.priority ?? 0,
208
+ assigneeId: issue.assignee?.id ?? null,
209
+ assigneeName: issue.assignee?.name ?? null,
210
+ projectId: issue.project?.id ?? null,
211
+ projectName: issue.project?.name ?? null,
212
+ stateId: issue.state.id,
213
+ stateName: issue.state.name,
214
+ statePosition: issue.state.position,
215
+ labels: issue.labels ?? [],
216
+ commentCount: issue.commentCount,
217
+ url: issue.url ?? null,
218
+ createdAt: issue.createdAt,
219
+ updatedAt: issue.updatedAt,
220
+ })),
221
+ )
222
+ if (shouldFullSync) {
223
+ await this.cache.pruneIssues(issues.map((issue) => issue.id))
224
+ }
225
+
226
+ const newestIssueTimestamp = maxTimestamp(
227
+ meta.lastIssueUpdatedAt,
228
+ issues.length > 0
229
+ ? issues.reduce(
230
+ (latest, issue) => (issue.updatedAt > latest ? issue.updatedAt : latest),
231
+ issues[0]!.updatedAt,
232
+ )
233
+ : null,
234
+ )
235
+
236
+ // Best-effort changelog ingest; failures don't fail the main sync.
237
+ await this.ingestTeamHistory(
238
+ issues.map((issue) => issue.id),
239
+ meta.lastIssueUpdatedAt,
240
+ ).catch((err) => {
241
+ console.warn('[linear] issueHistory ingest failed:', err)
242
+ })
243
+
244
+ const syncedAt = new Date().toISOString()
245
+ await this.cache.saveSyncMeta({
246
+ team: { id: team.id, key: team.key, name: team.name },
247
+ lastSyncAt: syncedAt,
248
+ lastFullSyncAt: shouldFullSync ? syncedAt : undefined,
249
+ lastIssueUpdatedAt: newestIssueTimestamp ?? syncedAt,
250
+ })
251
+ }
252
+
253
+ private async ingestTeamHistory(issueIds: string[], sinceIso: string | null): Promise<void> {
254
+ if (issueIds.length === 0) return
255
+ const concurrency = 5
256
+ for (let i = 0; i < issueIds.length; i += concurrency) {
257
+ const batch = issueIds.slice(i, i + concurrency)
258
+ const results = await Promise.all(
259
+ batch.map((issueId) => this.fetchIssueHistory(issueId, sinceIso)),
260
+ )
261
+ const rows = results.flat()
262
+ if (rows.length > 0) await this.cache.saveActivity(rows)
263
+ }
264
+ }
265
+
266
+ private async fetchIssueHistory(
267
+ issueId: string,
268
+ sinceIso: string | null,
269
+ ): Promise<LinearActivityRow[]> {
270
+ const rows: LinearActivityRow[] = []
271
+ let cursor: string | null = null
272
+ for (let page = 0; page < 10; page++) {
273
+ const batch = await this.client.listIssueHistory({ issueId, first: 50, after: cursor })
274
+ let reachedKnown = false
275
+ for (const node of batch.nodes) {
276
+ // Linear returns history newest-first; once we hit an entry we've already ingested,
277
+ // every subsequent page is older still, so break out of pagination entirely.
278
+ if (sinceIso && node.createdAt <= sinceIso) {
279
+ reachedKnown = true
280
+ break
281
+ }
282
+ if (!node.fromState && !node.toState) continue
283
+ rows.push({
284
+ issue_id: issueId,
285
+ history_id: node.id,
286
+ item_field: 'state',
287
+ from_value: node.fromState?.id ?? null,
288
+ to_value: node.toState?.id ?? null,
289
+ created_at: node.createdAt,
290
+ })
291
+ }
292
+ if (reachedKnown) break
293
+ if (!batch.pageInfo.hasNextPage || !batch.pageInfo.endCursor) break
294
+ cursor = batch.pageInfo.endCursor
295
+ }
296
+ return rows
297
+ }
298
+
299
+ private async resolveTask(idOrRef: string): Promise<Task> {
300
+ const task = await this.cache.getCachedTask(idOrRef)
301
+ if (!task) {
302
+ throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${idOrRef}'`)
303
+ }
304
+ return task
305
+ }
306
+
307
+ private async resolveState(column: string): Promise<Column> {
308
+ const states = await this.cache.getCachedColumns()
309
+ const match = states.find(
310
+ (state) => state.id === column || state.name.toLowerCase() === column.toLowerCase(),
311
+ )
312
+ if (!match) {
313
+ throw new KanbanError(
314
+ ErrorCode.COLUMN_NOT_FOUND,
315
+ `No Linear workflow state matching '${column}'`,
316
+ )
317
+ }
318
+ return match
319
+ }
320
+
321
+ private async resolveAssigneeId(name?: string): Promise<string | undefined> {
322
+ if (!name) return undefined
323
+ return (await this.cache.findUserIdByName(name)) ?? undefined
324
+ }
325
+
326
+ private async resolveProjectId(name?: string): Promise<string | undefined> {
327
+ if (!name) return undefined
328
+ return (await this.cache.findProjectIdByName(name)) ?? undefined
329
+ }
330
+
331
+ private toTaskComment(task: Task, comment: LinearComment): TaskComment {
332
+ return {
333
+ id: comment.id,
334
+ task_id: task.id,
335
+ body: comment.body,
336
+ author: comment.user?.displayName || comment.user?.name || null,
337
+ created_at: comment.createdAt,
338
+ updated_at: comment.updatedAt,
339
+ }
340
+ }
341
+
342
+ async syncCache(): Promise<void> {
343
+ // viaWarmer bypasses the backgroundManaged request-path suppression without
344
+ // forcing a full reconcile (force=false keeps the normal delta/full cadence).
345
+ await this.sync(false, true)
346
+ }
347
+
348
+ setBackgroundManaged(managed: boolean): void {
349
+ this.backgroundManaged = managed
350
+ }
351
+
352
+ async getSyncStatus(): Promise<ProviderSyncStatus> {
353
+ const meta = await this.cache.loadSyncMeta()
354
+ return {
355
+ lastSyncAt: meta.lastSyncAt,
356
+ lastFullSyncAt: meta.lastFullSyncAt,
357
+ lastWebhookAt: meta.lastWebhookAt,
358
+ }
359
+ }
360
+
361
+ async getContext(): Promise<ProviderContext> {
362
+ await this.sync()
363
+ const meta = await this.cache.loadSyncMeta()
364
+ return {
365
+ provider: 'linear',
366
+ capabilities: LINEAR_CAPABILITIES,
367
+ team: meta.team,
368
+ }
369
+ }
370
+
371
+ async getBootstrap(): Promise<BoardBootstrap> {
372
+ await this.sync()
373
+ return {
374
+ provider: 'linear',
375
+ capabilities: LINEAR_CAPABILITIES,
376
+ board: await this.cache.getCachedBoard(),
377
+ config: await this.cache.getCachedConfig(),
378
+ metrics: null,
379
+ activity: [],
380
+ team: (await this.cache.loadSyncMeta()).team,
381
+ }
382
+ }
383
+
384
+ async getBoard(): Promise<BoardView> {
385
+ await this.sync()
386
+ return this.cache.getCachedBoard()
387
+ }
388
+
389
+ async listColumns(): Promise<Column[]> {
390
+ await this.sync()
391
+ return this.cache.getCachedColumns()
392
+ }
393
+
394
+ async listTasks(filters: TaskListFilters = {}): Promise<Task[]> {
395
+ await this.sync()
396
+ let tasks = await this.cache.getCachedTasks()
397
+ if (filters.column) {
398
+ const column = await this.resolveState(filters.column)
399
+ tasks = tasks.filter((task) => task.column_id === column.id)
400
+ }
401
+ if (filters.priority) tasks = tasks.filter((task) => task.priority === filters.priority)
402
+ if (filters.assignee) tasks = tasks.filter((task) => task.assignee === filters.assignee)
403
+ if (filters.project) tasks = tasks.filter((task) => task.project === filters.project)
404
+ if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
405
+ if (filters.sort === 'updated')
406
+ tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
407
+ if (filters.limit) tasks = tasks.slice(0, filters.limit)
408
+ return tasks
409
+ }
410
+
411
+ async getTask(idOrRef: string): Promise<Task> {
412
+ await this.sync()
413
+ return this.resolveTask(idOrRef)
414
+ }
415
+
416
+ async createTask(input: CreateTaskInput): Promise<Task> {
417
+ await this.sync()
418
+ const state = input.column ? await this.resolveState(input.column) : undefined
419
+ const labelIds = await resolveLabelIdsForCreate(this.client, input.labels)
420
+ const result = await this.client.createIssue({
421
+ teamId: await this.resolvedTeamId(),
422
+ stateId: state?.id,
423
+ title: input.title,
424
+ description: input.description,
425
+ priority: toLinearPriority(input.priority),
426
+ assigneeId: await this.resolveAssigneeId(input.assignee),
427
+ projectId: await this.resolveProjectId(input.project),
428
+ labelIds,
429
+ })
430
+ if (!result.success || !result.issue) {
431
+ throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue creation failed')
432
+ }
433
+ const issue = result.issue
434
+ await this.cache.upsertIssues([
435
+ {
436
+ id: issue.id,
437
+ identifier: issue.identifier,
438
+ title: issue.title,
439
+ description: issue.description ?? '',
440
+ priority: issue.priority ?? 0,
441
+ assigneeId: issue.assignee?.id ?? null,
442
+ assigneeName: issue.assignee?.name ?? issue.assignee?.displayName ?? '',
443
+ projectId: issue.project?.id ?? null,
444
+ projectName: issue.project?.name ?? '',
445
+ stateId: issue.state.id,
446
+ stateName: issue.state.name,
447
+ statePosition: issue.state.position,
448
+ labels: issue.labels ?? [],
449
+ commentCount: issue.commentCount,
450
+ url: issue.url ?? null,
451
+ createdAt: issue.createdAt,
452
+ updatedAt: issue.updatedAt,
453
+ },
454
+ ])
455
+ return this.resolveTask(issue.id)
456
+ }
457
+
458
+ async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
459
+ await this.sync()
460
+ const task = await this.resolveTask(idOrRef)
461
+ if (input.expectedVersion !== undefined && task.version !== input.expectedVersion) {
462
+ throw new KanbanError(
463
+ ErrorCode.CONFLICT,
464
+ `Linear issue ${task.externalRef ?? idOrRef} was updated remotely (expected version ${input.expectedVersion}, current ${task.version ?? 'unknown'})`,
465
+ )
466
+ }
467
+ const updateInput: Record<string, unknown> = {}
468
+ if (input.title !== undefined) updateInput['title'] = input.title
469
+ if (input.description !== undefined) updateInput['description'] = input.description
470
+ if (input.priority !== undefined) updateInput['priority'] = toLinearPriority(input.priority)
471
+ if (input.assignee !== undefined)
472
+ updateInput['assigneeId'] = (await this.resolveAssigneeId(input.assignee)) ?? null
473
+ if (input.project !== undefined)
474
+ updateInput['projectId'] = (await this.resolveProjectId(input.project)) ?? null
475
+ if (input.metadata !== undefined) {
476
+ unsupportedOperation('Linear mode does not support metadata updates')
477
+ }
478
+ const result = await this.client.updateIssue(task.providerId || task.id, updateInput)
479
+ if (!result.success) {
480
+ throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue update failed')
481
+ }
482
+ await this.sync(true)
483
+ return this.resolveTask(task.providerId || task.id)
484
+ }
485
+
486
+ async moveTask(idOrRef: string, column: string): Promise<Task> {
487
+ await this.sync()
488
+ const task = await this.resolveTask(idOrRef)
489
+ const state = await this.resolveState(column)
490
+ const result = await this.client.updateIssue(task.providerId || task.id, { stateId: state.id })
491
+ if (!result.success) {
492
+ throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue move failed')
493
+ }
494
+ await this.sync(true)
495
+ return this.resolveTask(task.providerId || task.id)
496
+ }
497
+
498
+ async deleteTask(_idOrRef: string): Promise<Task> {
499
+ unsupportedOperation('Task deletion is not supported in Linear mode')
500
+ }
501
+
502
+ async listComments(idOrRef: string): Promise<TaskComment[]> {
503
+ await this.sync()
504
+ const task = await this.resolveTask(idOrRef)
505
+ const comments = await this.client.listComments(task.providerId || task.id)
506
+ return comments.map((comment) => this.toTaskComment(task, comment))
507
+ }
508
+
509
+ async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
510
+ await this.sync()
511
+ const task = await this.resolveTask(idOrRef)
512
+ const comment = await this.client.getComment(commentId)
513
+ return this.toTaskComment(task, comment)
514
+ }
515
+
516
+ async comment(idOrRef: string, body: string): Promise<TaskComment> {
517
+ await this.sync()
518
+ const task = await this.resolveTask(idOrRef)
519
+ const result = await this.client.commentCreate(task.providerId || task.id, body)
520
+ if (!result.success || !result.comment) {
521
+ throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear comment creation failed')
522
+ }
523
+ await this.cache.adjustIssueCommentCount(task.providerId || task.id, 1)
524
+ return this.toTaskComment(task, result.comment)
525
+ }
526
+
527
+ async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
528
+ await this.sync()
529
+ const task = await this.resolveTask(idOrRef)
530
+ const result = await this.client.commentUpdate(commentId, body)
531
+ if (!result.success || !result.comment) {
532
+ throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear comment update failed')
533
+ }
534
+ return this.toTaskComment(task, result.comment)
535
+ }
536
+
537
+ async getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]> {
538
+ await this.sync()
539
+ const issueId = taskId ? ((await this.cache.resolveIssueId(taskId)) ?? undefined) : undefined
540
+ const rows = await this.cache.getCachedActivity({
541
+ ...(issueId !== undefined ? { issueId } : {}),
542
+ limit: limit ?? 100,
543
+ })
544
+ return rows.map((row) => this.activityRowToEntry(row))
545
+ }
546
+
547
+ private activityRowToEntry(row: LinearActivityRow): ActivityEntry {
548
+ // fromState/toState already reference state ids which agent-kanban
549
+ // surfaces 1:1 as column ids (see linear_states/getCachedColumns),
550
+ // so no lookup is needed here.
551
+ return {
552
+ id: `linear-activity:${row.issue_id}:${row.history_id}:${row.item_field}`,
553
+ task_id: `linear:${row.issue_id}`,
554
+ action: row.item_field === 'state' ? 'moved' : 'updated',
555
+ field_changed: row.item_field,
556
+ old_value: row.from_value,
557
+ new_value: row.to_value,
558
+ timestamp: row.created_at,
559
+ }
560
+ }
561
+
562
+ async getMetrics(): Promise<BoardMetrics> {
563
+ unsupportedOperation('Metrics are not available in Linear mode')
564
+ }
565
+
566
+ async getConfig(): Promise<BoardConfig> {
567
+ await this.sync()
568
+ return this.cache.getCachedConfig()
569
+ }
570
+
571
+ async patchConfig(_input: Partial<BoardConfig>): Promise<BoardConfig> {
572
+ unsupportedOperation('Config mutation is not supported in Linear mode')
573
+ }
574
+
575
+ async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
576
+ return this.handleWebhookCore(payload)
577
+ }
578
+
579
+ // Shared webhook dispatch. Postgres wraps this with webhook-event auditing;
580
+ // SQLite calls it directly via the default handleWebhook above.
581
+ protected async handleWebhookCore(payload: WebhookRequest): Promise<WebhookResult> {
582
+ const auth = authorizeWebhook({
583
+ secret: process.env['LINEAR_WEBHOOK_SECRET'],
584
+ rawBody: payload.rawBody,
585
+ signature: headerLower(payload.headers, 'linear-signature'),
586
+ verify: verifyHmacSha256,
587
+ })
588
+ if (auth) return auth
589
+ let body: {
590
+ action?: 'create' | 'update' | 'remove'
591
+ type?: string
592
+ data?: {
593
+ id: string
594
+ identifier?: string
595
+ title?: string
596
+ description?: string | null
597
+ priority?: number | null
598
+ url?: string | null
599
+ createdAt?: string
600
+ updatedAt?: string
601
+ assignee?: { id: string; name?: string | null } | null
602
+ assigneeId?: string | null
603
+ project?: { id: string; name: string } | null
604
+ projectId?: string | null
605
+ state?: { id: string; name: string; position?: number } | null
606
+ stateId?: string | null
607
+ team?: { id?: string | null; key?: string | null } | null
608
+ teamId?: string | null
609
+ labels?: Array<{ id: string; name: string }> | null
610
+ commentCount?: number | null
611
+ }
612
+ } = {}
613
+ try {
614
+ body = JSON.parse(payload.rawBody) as typeof body
615
+ } catch {
616
+ return { handled: false, message: 'Invalid JSON body' }
617
+ }
618
+ if (body.type !== 'Issue') {
619
+ return { handled: false, message: `Ignoring ${body.type ?? 'unknown'} event` }
620
+ }
621
+ const data = body.data
622
+ if (!data) return { handled: false, message: 'No data in payload' }
623
+
624
+ if (body.action === 'remove') {
625
+ await this.cache.deleteIssue(data.id)
626
+ await this.cache.saveSyncMeta({ lastWebhookAt: new Date().toISOString() })
627
+ return { handled: true }
628
+ }
629
+
630
+ if (body.action === 'create' || body.action === 'update') {
631
+ const configuredTeam = await this.getConfiguredTeam()
632
+ const payloadTeamId = data.team?.id ?? data.teamId ?? null
633
+ if (payloadTeamId && payloadTeamId !== configuredTeam.id) {
634
+ return {
635
+ handled: false,
636
+ message: `Ignoring issue from team '${payloadTeamId}'`,
637
+ }
638
+ }
639
+
640
+ if (!payloadTeamId) {
641
+ const issueTeam = await this.client.getIssueTeam(data.id)
642
+ if (!issueTeam) {
643
+ return {
644
+ handled: false,
645
+ message: `Ignoring issue '${data.id}' because its team could not be verified`,
646
+ }
647
+ }
648
+ if (issueTeam.id !== configuredTeam.id) {
649
+ return {
650
+ handled: false,
651
+ message: `Ignoring issue from team '${issueTeam.key}'`,
652
+ }
653
+ }
654
+ }
655
+
656
+ if (!data.identifier || !data.title || !data.createdAt || !data.updatedAt) {
657
+ return { handled: false, message: 'Missing required issue fields' }
658
+ }
659
+ const stateId = data.state?.id ?? data.stateId ?? null
660
+ if (!stateId) return { handled: false, message: 'Missing state id' }
661
+ await this.cache.upsertIssues([
662
+ {
663
+ id: data.id,
664
+ identifier: data.identifier,
665
+ title: data.title,
666
+ description: data.description ?? '',
667
+ priority: data.priority ?? 0,
668
+ assigneeId: data.assignee?.id ?? data.assigneeId ?? null,
669
+ assigneeName: data.assignee?.name ?? null,
670
+ projectId: data.project?.id ?? data.projectId ?? null,
671
+ projectName: data.project?.name ?? null,
672
+ stateId,
673
+ stateName: data.state?.name ?? '',
674
+ statePosition: data.state?.position ?? 0,
675
+ labels: (data.labels ?? []).map((l) => l.name),
676
+ commentCount: data.commentCount,
677
+ url: data.url ?? null,
678
+ createdAt: data.createdAt,
679
+ updatedAt: data.updatedAt,
680
+ },
681
+ ])
682
+ await this.cache.saveSyncMeta({ lastWebhookAt: new Date().toISOString() })
683
+ return { handled: true }
684
+ }
685
+
686
+ return { handled: false, message: `Unsupported action: ${body.action}` }
687
+ }
688
+ }