@andypai/agent-kanban 0.3.3 → 0.3.5

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.
@@ -0,0 +1,1088 @@
1
+ import type { Sql } from 'postgres'
2
+
3
+ import { ErrorCode, KanbanError } from '../errors'
4
+ import type {
5
+ ActivityEntry,
6
+ BoardBootstrap,
7
+ BoardConfig,
8
+ BoardMetrics,
9
+ BoardView,
10
+ Column,
11
+ ProviderTeamInfo,
12
+ Task,
13
+ TaskComment,
14
+ } from '../types'
15
+ import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
16
+ import { headerLower, verifyHmacSha256, type WebhookRequest, type WebhookResult } from '../webhooks'
17
+ import { LINEAR_CAPABILITIES } from './capabilities'
18
+ import { unsupportedOperation } from './errors'
19
+ import { LinearClient, type LinearComment } from './linear-client'
20
+ import type {
21
+ CreateTaskInput,
22
+ KanbanProvider,
23
+ ProviderContext,
24
+ ProviderSyncStatus,
25
+ TaskListFilters,
26
+ UpdateTaskInput,
27
+ } from './types'
28
+
29
+ const FULL_RECONCILIATION_INTERVAL_MS = 5 * 60_000
30
+ const ACTIVITY_VALUE_MAX_CHARS = 4096
31
+ const ACTIVITY_TRUNCATION_SUFFIX = '...[truncated]'
32
+ const ACTIVITY_VALUE_BUDGET = ACTIVITY_VALUE_MAX_CHARS - ACTIVITY_TRUNCATION_SUFFIX.length
33
+
34
+ interface LinearStateRow {
35
+ id: string
36
+ name: string
37
+ position: number
38
+ color: string | null
39
+ type: string | null
40
+ created_at: string
41
+ updated_at: string
42
+ }
43
+
44
+ interface LinearIssueRow {
45
+ id: string
46
+ identifier: string
47
+ title: string
48
+ description: string
49
+ state_id: string
50
+ state_position: number
51
+ priority: number
52
+ assignee_name: string
53
+ project_name: string
54
+ labels: string
55
+ comment_count: number
56
+ url: string | null
57
+ created_at: string
58
+ updated_at: string
59
+ }
60
+
61
+ interface LinearSyncMeta {
62
+ team: ProviderTeamInfo | null
63
+ lastSyncAt: string | null
64
+ lastFullSyncAt: string | null
65
+ lastIssueUpdatedAt: string | null
66
+ lastWebhookAt: string | null
67
+ }
68
+
69
+ interface LinearActivityRow {
70
+ issue_id: string
71
+ history_id: string
72
+ item_field: string
73
+ from_value: string | null
74
+ to_value: string | null
75
+ created_at: string
76
+ }
77
+
78
+ function parseTimestamp(value: string | null | undefined): number {
79
+ if (!value) return 0
80
+ const parsed = Date.parse(value)
81
+ return Number.isFinite(parsed) ? parsed : 0
82
+ }
83
+
84
+ function maxTimestamp(a: string | null | undefined, b: string | null | undefined): string | null {
85
+ const aMs = parseTimestamp(a)
86
+ const bMs = parseTimestamp(b)
87
+ if (!aMs && !bMs) return null
88
+ return aMs >= bMs ? (a ?? null) : (b ?? null)
89
+ }
90
+
91
+ function toLinearPriority(priority: Task['priority'] | undefined): number | undefined {
92
+ switch (priority) {
93
+ case 'urgent':
94
+ return 1
95
+ case 'high':
96
+ return 2
97
+ case 'medium':
98
+ return 3
99
+ case 'low':
100
+ return 4
101
+ default:
102
+ return undefined
103
+ }
104
+ }
105
+
106
+ function mapPriority(priority: number): Task['priority'] {
107
+ switch (priority) {
108
+ case 1:
109
+ return 'urgent'
110
+ case 2:
111
+ return 'high'
112
+ case 3:
113
+ return 'medium'
114
+ case 0:
115
+ case 4:
116
+ default:
117
+ return 'low'
118
+ }
119
+ }
120
+
121
+ function parseLabels(raw: string): string[] {
122
+ try {
123
+ const parsed: unknown = JSON.parse(raw)
124
+ return Array.isArray(parsed)
125
+ ? parsed.filter((value): value is string => typeof value === 'string')
126
+ : []
127
+ } catch {
128
+ return []
129
+ }
130
+ }
131
+
132
+ function taskFromRow(row: LinearIssueRow): Task {
133
+ return {
134
+ id: `linear:${row.id}`,
135
+ providerId: row.id,
136
+ externalRef: row.identifier,
137
+ url: row.url,
138
+ title: row.title,
139
+ description: row.description,
140
+ column_id: row.state_id,
141
+ position: row.state_position,
142
+ priority: mapPriority(row.priority),
143
+ assignee: row.assignee_name,
144
+ assignees: row.assignee_name ? [row.assignee_name] : [],
145
+ labels: parseLabels(row.labels),
146
+ comment_count: row.comment_count,
147
+ project: row.project_name,
148
+ metadata: '{}',
149
+ created_at: row.created_at,
150
+ updated_at: row.updated_at,
151
+ version: row.updated_at,
152
+ source_updated_at: row.updated_at,
153
+ }
154
+ }
155
+
156
+ function clampActivityValue(value: string): string {
157
+ if (value.length <= ACTIVITY_VALUE_MAX_CHARS) return value
158
+ return value.slice(0, ACTIVITY_VALUE_BUDGET) + ACTIVITY_TRUNCATION_SUFFIX
159
+ }
160
+
161
+ export class PostgresLinearProvider implements KanbanProvider {
162
+ readonly type = 'linear' as const
163
+ private readonly ready: Promise<void>
164
+ private readonly client: LinearClient
165
+
166
+ constructor(
167
+ private readonly sql: Sql,
168
+ private readonly teamId: string,
169
+ apiKey: string,
170
+ private readonly pollingSyncIntervalMs = DEFAULT_POLLING_SYNC_INTERVAL_MS,
171
+ client?: LinearClient,
172
+ ) {
173
+ this.ready = this.ensureSchema()
174
+ this.client = client ?? new LinearClient(apiKey)
175
+ }
176
+
177
+ async initialize(): Promise<void> {
178
+ await this.ready
179
+ }
180
+
181
+ private async ensureSchema(): Promise<void> {
182
+ await this.sql`
183
+ CREATE TABLE IF NOT EXISTS linear_sync_meta (
184
+ key TEXT PRIMARY KEY,
185
+ value TEXT NOT NULL
186
+ )
187
+ `
188
+ await this.sql`
189
+ CREATE TABLE IF NOT EXISTS linear_states (
190
+ id TEXT PRIMARY KEY,
191
+ name TEXT NOT NULL,
192
+ position INTEGER NOT NULL,
193
+ color TEXT,
194
+ type TEXT,
195
+ created_at TEXT NOT NULL,
196
+ updated_at TEXT NOT NULL
197
+ )
198
+ `
199
+ await this.sql`
200
+ CREATE TABLE IF NOT EXISTS linear_users (
201
+ id TEXT PRIMARY KEY,
202
+ name TEXT NOT NULL,
203
+ active INTEGER NOT NULL DEFAULT 1,
204
+ updated_at TEXT NOT NULL
205
+ )
206
+ `
207
+ await this.sql`
208
+ CREATE TABLE IF NOT EXISTS linear_projects (
209
+ id TEXT PRIMARY KEY,
210
+ name TEXT NOT NULL,
211
+ url TEXT,
212
+ state TEXT,
213
+ updated_at TEXT NOT NULL
214
+ )
215
+ `
216
+ await this.sql`
217
+ CREATE TABLE IF NOT EXISTS linear_issues (
218
+ id TEXT PRIMARY KEY,
219
+ identifier TEXT NOT NULL UNIQUE,
220
+ title TEXT NOT NULL,
221
+ description TEXT NOT NULL DEFAULT '',
222
+ priority INTEGER NOT NULL DEFAULT 0,
223
+ assignee_id TEXT,
224
+ assignee_name TEXT NOT NULL DEFAULT '',
225
+ project_id TEXT,
226
+ project_name TEXT NOT NULL DEFAULT '',
227
+ state_id TEXT NOT NULL,
228
+ state_name TEXT NOT NULL,
229
+ state_position INTEGER NOT NULL DEFAULT 0,
230
+ labels TEXT NOT NULL DEFAULT '[]',
231
+ comment_count INTEGER NOT NULL DEFAULT 0,
232
+ url TEXT,
233
+ created_at TEXT NOT NULL,
234
+ updated_at TEXT NOT NULL
235
+ )
236
+ `
237
+ await this
238
+ .sql`ALTER TABLE linear_issues ADD COLUMN IF NOT EXISTS labels TEXT NOT NULL DEFAULT '[]'`
239
+ await this
240
+ .sql`ALTER TABLE linear_issues ADD COLUMN IF NOT EXISTS comment_count INTEGER NOT NULL DEFAULT 0`
241
+ await this.sql`CREATE INDEX IF NOT EXISTS idx_linear_issues_state_id ON linear_issues(state_id)`
242
+ await this
243
+ .sql`CREATE INDEX IF NOT EXISTS idx_linear_issues_updated_at ON linear_issues(updated_at)`
244
+ await this.sql`
245
+ CREATE TABLE IF NOT EXISTS linear_activity (
246
+ issue_id TEXT NOT NULL,
247
+ history_id TEXT NOT NULL,
248
+ item_field TEXT NOT NULL,
249
+ from_value TEXT,
250
+ to_value TEXT,
251
+ created_at TEXT NOT NULL,
252
+ PRIMARY KEY (issue_id, history_id, item_field)
253
+ )
254
+ `
255
+ await this.sql`
256
+ CREATE INDEX IF NOT EXISTS linear_activity_created_at_idx ON linear_activity(created_at DESC)
257
+ `
258
+ }
259
+
260
+ private async setMeta(key: string, value: string): Promise<void> {
261
+ await this.sql`
262
+ INSERT INTO linear_sync_meta (key, value)
263
+ VALUES (${key}, ${value})
264
+ ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value
265
+ `
266
+ }
267
+
268
+ private async deleteMeta(key: string): Promise<void> {
269
+ await this.sql`DELETE FROM linear_sync_meta WHERE key = ${key}`
270
+ }
271
+
272
+ private async getMeta(key: string): Promise<string | null> {
273
+ const [row] = await this.sql<{ value: string }[]>`
274
+ SELECT value FROM linear_sync_meta WHERE key = ${key}
275
+ `
276
+ return row?.value ?? null
277
+ }
278
+
279
+ private async saveSyncMeta(meta: Partial<LinearSyncMeta>): Promise<void> {
280
+ const keys = [
281
+ 'team',
282
+ 'lastSyncAt',
283
+ 'lastFullSyncAt',
284
+ 'lastIssueUpdatedAt',
285
+ 'lastWebhookAt',
286
+ ] as const
287
+ for (const key of keys) {
288
+ if (!Object.prototype.hasOwnProperty.call(meta, key)) continue
289
+ const value = meta[key]
290
+ if (value === null) {
291
+ await this.deleteMeta(key)
292
+ continue
293
+ }
294
+ if (key === 'team') {
295
+ await this.setMeta(key, JSON.stringify(value))
296
+ continue
297
+ }
298
+ if (typeof value === 'string') await this.setMeta(key, value)
299
+ }
300
+ }
301
+
302
+ private async loadSyncMeta(): Promise<LinearSyncMeta> {
303
+ const teamRaw = await this.getMeta('team')
304
+ return {
305
+ team: teamRaw ? (JSON.parse(teamRaw) as ProviderTeamInfo) : null,
306
+ lastSyncAt: await this.getMeta('lastSyncAt'),
307
+ lastFullSyncAt: await this.getMeta('lastFullSyncAt'),
308
+ lastIssueUpdatedAt: await this.getMeta('lastIssueUpdatedAt'),
309
+ lastWebhookAt: await this.getMeta('lastWebhookAt'),
310
+ }
311
+ }
312
+
313
+ private async resolvedTeamId(): Promise<string> {
314
+ return (await this.loadSyncMeta()).team?.id ?? this.teamId
315
+ }
316
+
317
+ private async getConfiguredTeam(): Promise<ProviderTeamInfo> {
318
+ const metaTeam = (await this.loadSyncMeta()).team
319
+ if (metaTeam) return metaTeam
320
+
321
+ const team = await this.client.getTeam(this.teamId)
322
+ const configuredTeam = { id: team.id, key: team.key, name: team.name }
323
+ await this.saveSyncMeta({ team: configuredTeam })
324
+ return configuredTeam
325
+ }
326
+
327
+ private async replaceStates(
328
+ states: Array<{
329
+ id: string
330
+ name: string
331
+ position: number
332
+ color?: string | null
333
+ type?: string | null
334
+ }>,
335
+ ): Promise<void> {
336
+ const now = new Date().toISOString()
337
+ await this.sql.begin(async (tx) => {
338
+ await tx`DELETE FROM linear_states`
339
+ for (const state of states) {
340
+ await tx`
341
+ INSERT INTO linear_states (id, name, position, color, type, created_at, updated_at)
342
+ VALUES (${state.id}, ${state.name}, ${state.position}, ${state.color ?? null}, ${state.type ?? null}, ${now}, ${now})
343
+ `
344
+ }
345
+ })
346
+ }
347
+
348
+ private async upsertUsers(
349
+ users: Array<{ id: string; name: string; active?: boolean }>,
350
+ ): Promise<void> {
351
+ const now = new Date().toISOString()
352
+ for (const user of users) {
353
+ await this.sql`
354
+ INSERT INTO linear_users (id, name, active, updated_at)
355
+ VALUES (${user.id}, ${user.name}, ${user.active === false ? 0 : 1}, ${now})
356
+ ON CONFLICT(id) DO UPDATE SET
357
+ name = EXCLUDED.name,
358
+ active = EXCLUDED.active,
359
+ updated_at = EXCLUDED.updated_at
360
+ `
361
+ }
362
+ }
363
+
364
+ private async upsertProjects(
365
+ projects: Array<{ id: string; name: string; url?: string | null; state?: string | null }>,
366
+ ): Promise<void> {
367
+ const now = new Date().toISOString()
368
+ for (const project of projects) {
369
+ await this.sql`
370
+ INSERT INTO linear_projects (id, name, url, state, updated_at)
371
+ VALUES (${project.id}, ${project.name}, ${project.url ?? null}, ${project.state ?? null}, ${now})
372
+ ON CONFLICT(id) DO UPDATE SET
373
+ name = EXCLUDED.name,
374
+ url = EXCLUDED.url,
375
+ state = EXCLUDED.state,
376
+ updated_at = EXCLUDED.updated_at
377
+ `
378
+ }
379
+ }
380
+
381
+ private async saveActivity(rows: LinearActivityRow[]): Promise<void> {
382
+ for (const row of rows) {
383
+ await this.sql`
384
+ INSERT INTO linear_activity (issue_id, history_id, item_field, from_value, to_value, created_at)
385
+ VALUES (${row.issue_id}, ${row.history_id}, ${row.item_field}, ${row.from_value}, ${row.to_value}, ${row.created_at})
386
+ ON CONFLICT(issue_id, history_id, item_field) DO NOTHING
387
+ `
388
+ }
389
+ }
390
+
391
+ private async upsertIssues(
392
+ issues: Array<{
393
+ id: string
394
+ identifier: string
395
+ title: string
396
+ description?: string | null
397
+ priority?: number | null
398
+ assigneeId?: string | null
399
+ assigneeName?: string | null
400
+ projectId?: string | null
401
+ projectName?: string | null
402
+ stateId: string
403
+ stateName: string
404
+ statePosition: number
405
+ labels?: string[] | null
406
+ commentCount?: number | null
407
+ url?: string | null
408
+ createdAt: string
409
+ updatedAt: string
410
+ }>,
411
+ ): Promise<void> {
412
+ for (const issue of issues) {
413
+ const nextDescription = issue.description ?? ''
414
+ const [prior] = await this.sql<{ description: string }[]>`
415
+ SELECT description FROM linear_issues WHERE id = ${issue.id} LIMIT 1
416
+ `
417
+ if (prior && prior.description !== nextDescription) {
418
+ await this.saveActivity([
419
+ {
420
+ issue_id: issue.id,
421
+ history_id: `desc:${issue.updatedAt}`,
422
+ item_field: 'description',
423
+ from_value: clampActivityValue(prior.description),
424
+ to_value: clampActivityValue(nextDescription),
425
+ created_at: issue.updatedAt,
426
+ },
427
+ ])
428
+ }
429
+
430
+ const hasCommentCount = issue.commentCount !== undefined && issue.commentCount !== null
431
+ await this.sql`
432
+ INSERT INTO linear_issues (
433
+ id, identifier, title, description, priority, assignee_id, assignee_name,
434
+ project_id, project_name, state_id, state_name, state_position, labels, comment_count,
435
+ url, created_at, updated_at
436
+ ) VALUES (
437
+ ${issue.id}, ${issue.identifier}, ${issue.title}, ${nextDescription}, ${issue.priority ?? 0},
438
+ ${issue.assigneeId ?? null}, ${issue.assigneeName ?? ''}, ${issue.projectId ?? null},
439
+ ${issue.projectName ?? ''}, ${issue.stateId}, ${issue.stateName}, ${issue.statePosition},
440
+ ${JSON.stringify(issue.labels ?? [])}, ${hasCommentCount ? (issue.commentCount ?? 0) : 0},
441
+ ${issue.url ?? null}, ${issue.createdAt}, ${issue.updatedAt}
442
+ )
443
+ ON CONFLICT(id) DO UPDATE SET
444
+ identifier = EXCLUDED.identifier,
445
+ title = EXCLUDED.title,
446
+ description = EXCLUDED.description,
447
+ priority = EXCLUDED.priority,
448
+ assignee_id = EXCLUDED.assignee_id,
449
+ assignee_name = EXCLUDED.assignee_name,
450
+ project_id = EXCLUDED.project_id,
451
+ project_name = EXCLUDED.project_name,
452
+ state_id = EXCLUDED.state_id,
453
+ state_name = EXCLUDED.state_name,
454
+ state_position = EXCLUDED.state_position,
455
+ labels = EXCLUDED.labels,
456
+ comment_count = CASE
457
+ WHEN ${hasCommentCount} THEN EXCLUDED.comment_count
458
+ ELSE linear_issues.comment_count
459
+ END,
460
+ url = EXCLUDED.url,
461
+ created_at = EXCLUDED.created_at,
462
+ updated_at = EXCLUDED.updated_at
463
+ `
464
+ }
465
+ }
466
+
467
+ private async deleteIssue(idOrIdentifier: string): Promise<void> {
468
+ await this.sql`
469
+ DELETE FROM linear_activity
470
+ WHERE issue_id = ${idOrIdentifier}
471
+ OR issue_id IN (SELECT id FROM linear_issues WHERE identifier = ${idOrIdentifier})
472
+ `
473
+ await this
474
+ .sql`DELETE FROM linear_issues WHERE id = ${idOrIdentifier} OR identifier = ${idOrIdentifier}`
475
+ }
476
+
477
+ private async pruneIssues(liveIssueIds: string[]): Promise<void> {
478
+ if (liveIssueIds.length === 0) {
479
+ await this.sql`DELETE FROM linear_activity`
480
+ await this.sql`DELETE FROM linear_issues`
481
+ return
482
+ }
483
+ await this.sql`
484
+ DELETE FROM linear_activity
485
+ WHERE issue_id IN (
486
+ SELECT id FROM linear_issues WHERE NOT (id = ANY(${liveIssueIds}))
487
+ )
488
+ `
489
+ await this.sql`DELETE FROM linear_issues WHERE NOT (id = ANY(${liveIssueIds}))`
490
+ }
491
+
492
+ private async adjustIssueCommentCount(idOrIdentifier: string, delta: number): Promise<void> {
493
+ await this.sql`
494
+ UPDATE linear_issues
495
+ SET comment_count = GREATEST(0, comment_count + ${delta})
496
+ WHERE id = ${idOrIdentifier} OR identifier = ${idOrIdentifier}
497
+ `
498
+ }
499
+
500
+ private async getCachedColumns(): Promise<LinearStateRow[]> {
501
+ return this.sql<LinearStateRow[]>`SELECT * FROM linear_states ORDER BY position, name`
502
+ }
503
+
504
+ private async getCachedBoard(): Promise<BoardView> {
505
+ const columns = await this.getCachedColumns()
506
+ const boardColumns = []
507
+ for (const column of columns) {
508
+ const tasks = (
509
+ await this.sql<LinearIssueRow[]>`
510
+ SELECT * FROM linear_issues
511
+ WHERE state_id = ${column.id}
512
+ ORDER BY updated_at DESC, title ASC
513
+ `
514
+ ).map(taskFromRow)
515
+ boardColumns.push({ ...column, tasks })
516
+ }
517
+ return { columns: boardColumns }
518
+ }
519
+
520
+ private async getCachedTask(lookup: string): Promise<Task | null> {
521
+ const normalized = lookup.startsWith('linear:') ? lookup.slice('linear:'.length) : lookup
522
+ const [row] = await this.sql<LinearIssueRow[]>`
523
+ SELECT * FROM linear_issues
524
+ WHERE id = ${normalized} OR identifier = ${normalized}
525
+ LIMIT 1
526
+ `
527
+ return row ? taskFromRow(row) : null
528
+ }
529
+
530
+ private async getCachedTasks(): Promise<Task[]> {
531
+ return (
532
+ await this.sql<LinearIssueRow[]>`
533
+ SELECT * FROM linear_issues ORDER BY updated_at DESC, title ASC
534
+ `
535
+ ).map(taskFromRow)
536
+ }
537
+
538
+ private async getCachedConfig(): Promise<BoardConfig> {
539
+ const members = (
540
+ await this.sql<{ name: string }[]>`
541
+ SELECT name FROM linear_users WHERE active = 1 AND name != '' ORDER BY name
542
+ `
543
+ ).map((row) => ({ name: row.name, role: 'human' as const }))
544
+ const projects = (
545
+ await this.sql<{ name: string }[]>`
546
+ SELECT name FROM linear_projects WHERE name != '' ORDER BY name
547
+ `
548
+ ).map((row) => row.name)
549
+ return {
550
+ members,
551
+ projects,
552
+ provider: 'linear',
553
+ discoveredAssignees: members.map((member) => member.name),
554
+ discoveredProjects: projects,
555
+ }
556
+ }
557
+
558
+ private async getCachedActivity(
559
+ params: { issueId?: string; limit?: number } = {},
560
+ ): Promise<LinearActivityRow[]> {
561
+ const limit = params.limit ?? 100
562
+ if (params.issueId) {
563
+ return this.sql<LinearActivityRow[]>`
564
+ SELECT issue_id, history_id, item_field, from_value, to_value, created_at
565
+ FROM linear_activity
566
+ WHERE issue_id = ${params.issueId}
567
+ ORDER BY created_at DESC
568
+ LIMIT ${limit}
569
+ `
570
+ }
571
+ return this.sql<LinearActivityRow[]>`
572
+ SELECT issue_id, history_id, item_field, from_value, to_value, created_at
573
+ FROM linear_activity
574
+ ORDER BY created_at DESC
575
+ LIMIT ${limit}
576
+ `
577
+ }
578
+
579
+ private async sync(force = false): Promise<void> {
580
+ await this.ready
581
+ const meta = await this.loadSyncMeta()
582
+ const lastSyncAtMs = parseTimestamp(meta.lastSyncAt)
583
+ const lastFullSyncAtMs = parseTimestamp(meta.lastFullSyncAt)
584
+ const now = Date.now()
585
+ if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
586
+
587
+ const shouldFullSync =
588
+ force ||
589
+ !lastFullSyncAtMs ||
590
+ !meta.lastIssueUpdatedAt ||
591
+ now - lastFullSyncAtMs >= FULL_RECONCILIATION_INTERVAL_MS
592
+
593
+ const team = await this.client.getTeam(this.teamId)
594
+ const [users, projects, issues] = await Promise.all([
595
+ this.client.listUsers(),
596
+ this.client.listProjects(),
597
+ this.client.listIssues(
598
+ team.id,
599
+ shouldFullSync ? undefined : (meta.lastIssueUpdatedAt ?? undefined),
600
+ ),
601
+ ])
602
+
603
+ await this.replaceStates(team.states)
604
+ await this.upsertUsers(users)
605
+ await this.upsertProjects(projects)
606
+ await this.upsertIssues(
607
+ issues.map((issue) => ({
608
+ id: issue.id,
609
+ identifier: issue.identifier,
610
+ title: issue.title,
611
+ description: issue.description ?? '',
612
+ priority: issue.priority ?? 0,
613
+ assigneeId: issue.assignee?.id ?? null,
614
+ assigneeName: issue.assignee?.name ?? null,
615
+ projectId: issue.project?.id ?? null,
616
+ projectName: issue.project?.name ?? null,
617
+ stateId: issue.state.id,
618
+ stateName: issue.state.name,
619
+ statePosition: issue.state.position,
620
+ labels: issue.labels ?? [],
621
+ commentCount: issue.commentCount,
622
+ url: issue.url ?? null,
623
+ createdAt: issue.createdAt,
624
+ updatedAt: issue.updatedAt,
625
+ })),
626
+ )
627
+ if (shouldFullSync) await this.pruneIssues(issues.map((issue) => issue.id))
628
+
629
+ const newestIssueTimestamp = maxTimestamp(
630
+ meta.lastIssueUpdatedAt,
631
+ issues.length > 0
632
+ ? issues.reduce(
633
+ (latest, issue) => (issue.updatedAt > latest ? issue.updatedAt : latest),
634
+ issues[0]!.updatedAt,
635
+ )
636
+ : null,
637
+ )
638
+
639
+ await this.ingestTeamHistory(
640
+ issues.map((issue) => issue.id),
641
+ meta.lastIssueUpdatedAt,
642
+ ).catch((err) => {
643
+ console.warn('[linear] issueHistory ingest failed:', err)
644
+ })
645
+
646
+ const syncedAt = new Date().toISOString()
647
+ await this.saveSyncMeta({
648
+ team: { id: team.id, key: team.key, name: team.name },
649
+ lastSyncAt: syncedAt,
650
+ lastFullSyncAt: shouldFullSync ? syncedAt : undefined,
651
+ lastIssueUpdatedAt: newestIssueTimestamp ?? syncedAt,
652
+ })
653
+ }
654
+
655
+ private async ingestTeamHistory(issueIds: string[], sinceIso: string | null): Promise<void> {
656
+ if (issueIds.length === 0) return
657
+ const concurrency = 5
658
+ for (let i = 0; i < issueIds.length; i += concurrency) {
659
+ const batch = issueIds.slice(i, i + concurrency)
660
+ const results = await Promise.all(
661
+ batch.map((issueId) => this.fetchIssueHistory(issueId, sinceIso)),
662
+ )
663
+ const rows = results.flat()
664
+ if (rows.length > 0) await this.saveActivity(rows)
665
+ }
666
+ }
667
+
668
+ private async fetchIssueHistory(
669
+ issueId: string,
670
+ sinceIso: string | null,
671
+ ): Promise<LinearActivityRow[]> {
672
+ const rows: LinearActivityRow[] = []
673
+ let cursor: string | null = null
674
+ for (let page = 0; page < 10; page++) {
675
+ const batch = await this.client.listIssueHistory({ issueId, first: 50, after: cursor })
676
+ let reachedKnown = false
677
+ for (const node of batch.nodes) {
678
+ if (sinceIso && node.createdAt <= sinceIso) {
679
+ reachedKnown = true
680
+ break
681
+ }
682
+ if (!node.fromState && !node.toState) continue
683
+ rows.push({
684
+ issue_id: issueId,
685
+ history_id: node.id,
686
+ item_field: 'state',
687
+ from_value: node.fromState?.id ?? null,
688
+ to_value: node.toState?.id ?? null,
689
+ created_at: node.createdAt,
690
+ })
691
+ }
692
+ if (reachedKnown) break
693
+ if (!batch.pageInfo.hasNextPage || !batch.pageInfo.endCursor) break
694
+ cursor = batch.pageInfo.endCursor
695
+ }
696
+ return rows
697
+ }
698
+
699
+ private async resolveTask(idOrRef: string): Promise<Task> {
700
+ const task = await this.getCachedTask(idOrRef)
701
+ if (!task) {
702
+ throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${idOrRef}'`)
703
+ }
704
+ return task
705
+ }
706
+
707
+ private async resolveState(column: string): Promise<Column> {
708
+ const states = await this.getCachedColumns()
709
+ const match = states.find(
710
+ (state) => state.id === column || state.name.toLowerCase() === column.toLowerCase(),
711
+ )
712
+ if (!match) {
713
+ throw new KanbanError(
714
+ ErrorCode.COLUMN_NOT_FOUND,
715
+ `No Linear workflow state matching '${column}'`,
716
+ )
717
+ }
718
+ return match
719
+ }
720
+
721
+ private async resolveAssigneeId(name?: string): Promise<string | undefined> {
722
+ if (!name) return undefined
723
+ const [row] = await this.sql<{ id: string }[]>`
724
+ SELECT id FROM linear_users WHERE LOWER(name) = LOWER(${name}) LIMIT 1
725
+ `
726
+ return row?.id
727
+ }
728
+
729
+ private async resolveProjectId(name?: string): Promise<string | undefined> {
730
+ if (!name) return undefined
731
+ const [row] = await this.sql<{ id: string }[]>`
732
+ SELECT id FROM linear_projects WHERE LOWER(name) = LOWER(${name}) LIMIT 1
733
+ `
734
+ return row?.id
735
+ }
736
+
737
+ private toTaskComment(task: Task, comment: LinearComment): TaskComment {
738
+ return {
739
+ id: comment.id,
740
+ task_id: task.id,
741
+ body: comment.body,
742
+ author: comment.user?.displayName || comment.user?.name || null,
743
+ created_at: comment.createdAt,
744
+ updated_at: comment.updatedAt,
745
+ }
746
+ }
747
+
748
+ async syncCache(): Promise<void> {
749
+ await this.sync()
750
+ }
751
+
752
+ async getSyncStatus(): Promise<ProviderSyncStatus> {
753
+ const meta = await this.loadSyncMeta()
754
+ return {
755
+ lastSyncAt: meta.lastSyncAt,
756
+ lastFullSyncAt: meta.lastFullSyncAt,
757
+ lastWebhookAt: meta.lastWebhookAt,
758
+ }
759
+ }
760
+
761
+ async getContext(): Promise<ProviderContext> {
762
+ await this.sync()
763
+ return {
764
+ provider: 'linear',
765
+ capabilities: LINEAR_CAPABILITIES,
766
+ team: (await this.loadSyncMeta()).team,
767
+ }
768
+ }
769
+
770
+ async getBootstrap(): Promise<BoardBootstrap> {
771
+ await this.sync()
772
+ return {
773
+ provider: 'linear',
774
+ capabilities: LINEAR_CAPABILITIES,
775
+ board: await this.getCachedBoard(),
776
+ config: await this.getCachedConfig(),
777
+ metrics: null,
778
+ activity: [],
779
+ team: (await this.loadSyncMeta()).team,
780
+ }
781
+ }
782
+
783
+ async getBoard(): Promise<BoardView> {
784
+ await this.sync()
785
+ return this.getCachedBoard()
786
+ }
787
+
788
+ async listColumns(): Promise<Column[]> {
789
+ await this.sync()
790
+ return this.getCachedColumns()
791
+ }
792
+
793
+ async listTasks(filters: TaskListFilters = {}): Promise<Task[]> {
794
+ await this.sync()
795
+ let tasks = await this.getCachedTasks()
796
+ if (filters.column) {
797
+ const column = await this.resolveState(filters.column)
798
+ tasks = tasks.filter((task) => task.column_id === column.id)
799
+ }
800
+ if (filters.priority) tasks = tasks.filter((task) => task.priority === filters.priority)
801
+ if (filters.assignee) tasks = tasks.filter((task) => task.assignee === filters.assignee)
802
+ if (filters.project) tasks = tasks.filter((task) => task.project === filters.project)
803
+ if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
804
+ if (filters.sort === 'updated')
805
+ tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
806
+ if (filters.limit) tasks = tasks.slice(0, filters.limit)
807
+ return tasks
808
+ }
809
+
810
+ async getTask(idOrRef: string): Promise<Task> {
811
+ await this.sync()
812
+ return this.resolveTask(idOrRef)
813
+ }
814
+
815
+ async createTask(input: CreateTaskInput): Promise<Task> {
816
+ await this.sync()
817
+ const state = input.column ? await this.resolveState(input.column) : undefined
818
+ const result = await this.client.createIssue({
819
+ teamId: await this.resolvedTeamId(),
820
+ stateId: state?.id,
821
+ title: input.title,
822
+ description: input.description,
823
+ priority: toLinearPriority(input.priority),
824
+ assigneeId: await this.resolveAssigneeId(input.assignee),
825
+ projectId: await this.resolveProjectId(input.project),
826
+ })
827
+ if (!result.success || !result.issue) {
828
+ throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue creation failed')
829
+ }
830
+ const issue = result.issue
831
+ await this.upsertIssues([
832
+ {
833
+ id: issue.id,
834
+ identifier: issue.identifier,
835
+ title: issue.title,
836
+ description: issue.description ?? '',
837
+ priority: issue.priority ?? 0,
838
+ assigneeId: issue.assignee?.id ?? null,
839
+ assigneeName: issue.assignee?.name ?? issue.assignee?.displayName ?? '',
840
+ projectId: issue.project?.id ?? null,
841
+ projectName: issue.project?.name ?? '',
842
+ stateId: issue.state.id,
843
+ stateName: issue.state.name,
844
+ statePosition: issue.state.position,
845
+ labels: issue.labels ?? [],
846
+ commentCount: issue.commentCount,
847
+ url: issue.url ?? null,
848
+ createdAt: issue.createdAt,
849
+ updatedAt: issue.updatedAt,
850
+ },
851
+ ])
852
+ return this.resolveTask(issue.id)
853
+ }
854
+
855
+ async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
856
+ await this.sync()
857
+ const task = await this.resolveTask(idOrRef)
858
+ if (input.expectedVersion !== undefined && task.version !== input.expectedVersion) {
859
+ throw new KanbanError(
860
+ ErrorCode.CONFLICT,
861
+ `Linear issue ${task.externalRef ?? idOrRef} was updated remotely (expected version ${input.expectedVersion}, current ${task.version ?? 'unknown'})`,
862
+ )
863
+ }
864
+ const updateInput: Record<string, unknown> = {}
865
+ if (input.title !== undefined) updateInput['title'] = input.title
866
+ if (input.description !== undefined) updateInput['description'] = input.description
867
+ if (input.priority !== undefined) updateInput['priority'] = toLinearPriority(input.priority)
868
+ if (input.assignee !== undefined)
869
+ updateInput['assigneeId'] = input.assignee
870
+ ? ((await this.resolveAssigneeId(input.assignee)) ?? null)
871
+ : null
872
+ if (input.project !== undefined)
873
+ updateInput['projectId'] = input.project
874
+ ? ((await this.resolveProjectId(input.project)) ?? null)
875
+ : null
876
+ if (input.metadata !== undefined) {
877
+ unsupportedOperation('Linear mode does not support metadata updates')
878
+ }
879
+ const result = await this.client.updateIssue(task.providerId || task.id, updateInput)
880
+ if (!result.success) {
881
+ throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue update failed')
882
+ }
883
+ await this.sync(true)
884
+ return this.resolveTask(task.providerId || task.id)
885
+ }
886
+
887
+ async moveTask(idOrRef: string, column: string): Promise<Task> {
888
+ await this.sync()
889
+ const task = await this.resolveTask(idOrRef)
890
+ const state = await this.resolveState(column)
891
+ const result = await this.client.updateIssue(task.providerId || task.id, { stateId: state.id })
892
+ if (!result.success) {
893
+ throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue move failed')
894
+ }
895
+ await this.sync(true)
896
+ return this.resolveTask(task.providerId || task.id)
897
+ }
898
+
899
+ async deleteTask(_idOrRef: string): Promise<Task> {
900
+ unsupportedOperation('Task deletion is not supported in Linear mode')
901
+ }
902
+
903
+ async listComments(idOrRef: string): Promise<TaskComment[]> {
904
+ await this.sync()
905
+ const task = await this.resolveTask(idOrRef)
906
+ const comments = await this.client.listComments(task.providerId || task.id)
907
+ return comments.map((comment) => this.toTaskComment(task, comment))
908
+ }
909
+
910
+ async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
911
+ await this.sync()
912
+ const task = await this.resolveTask(idOrRef)
913
+ const comment = await this.client.getComment(commentId)
914
+ return this.toTaskComment(task, comment)
915
+ }
916
+
917
+ async comment(idOrRef: string, body: string): Promise<TaskComment> {
918
+ await this.sync()
919
+ const task = await this.resolveTask(idOrRef)
920
+ const result = await this.client.commentCreate(task.providerId || task.id, body)
921
+ if (!result.success || !result.comment) {
922
+ throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear comment creation failed')
923
+ }
924
+ await this.adjustIssueCommentCount(task.providerId || task.id, 1)
925
+ return this.toTaskComment(task, result.comment)
926
+ }
927
+
928
+ async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
929
+ await this.sync()
930
+ const task = await this.resolveTask(idOrRef)
931
+ const result = await this.client.commentUpdate(commentId, body)
932
+ if (!result.success || !result.comment) {
933
+ throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear comment update failed')
934
+ }
935
+ return this.toTaskComment(task, result.comment)
936
+ }
937
+
938
+ async getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]> {
939
+ await this.sync()
940
+ const issueId = taskId ? await this.resolveIssueIdFromTaskId(taskId) : undefined
941
+ const rows = await this.getCachedActivity({
942
+ ...(issueId !== undefined ? { issueId } : {}),
943
+ limit: limit ?? 100,
944
+ })
945
+ return rows.map((row) => this.activityRowToEntry(row))
946
+ }
947
+
948
+ private async resolveIssueIdFromTaskId(taskId: string): Promise<string | undefined> {
949
+ const normalized = taskId.startsWith('linear:') ? taskId.slice('linear:'.length) : taskId
950
+ const [row] = await this.sql<{ id: string }[]>`
951
+ SELECT id FROM linear_issues WHERE id = ${normalized} OR identifier = ${normalized} LIMIT 1
952
+ `
953
+ return row?.id
954
+ }
955
+
956
+ private activityRowToEntry(row: LinearActivityRow): ActivityEntry {
957
+ return {
958
+ id: `linear-activity:${row.issue_id}:${row.history_id}:${row.item_field}`,
959
+ task_id: `linear:${row.issue_id}`,
960
+ action: row.item_field === 'state' ? 'moved' : 'updated',
961
+ field_changed: row.item_field,
962
+ old_value: row.from_value,
963
+ new_value: row.to_value,
964
+ timestamp: row.created_at,
965
+ }
966
+ }
967
+
968
+ async getMetrics(): Promise<BoardMetrics> {
969
+ unsupportedOperation('Metrics are not available in Linear mode')
970
+ }
971
+
972
+ async getConfig(): Promise<BoardConfig> {
973
+ await this.sync()
974
+ return this.getCachedConfig()
975
+ }
976
+
977
+ async patchConfig(_input: Partial<BoardConfig>): Promise<BoardConfig> {
978
+ unsupportedOperation('Config mutation is not supported in Linear mode')
979
+ }
980
+
981
+ async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
982
+ const secret = process.env['LINEAR_WEBHOOK_SECRET']
983
+ if (secret) {
984
+ const sig = headerLower(payload.headers, 'linear-signature')
985
+ if (!verifyHmacSha256(secret, payload.rawBody, sig)) {
986
+ return { handled: false, unauthorized: true, message: 'Invalid signature' }
987
+ }
988
+ }
989
+ let body: {
990
+ action?: 'create' | 'update' | 'remove'
991
+ type?: string
992
+ data?: {
993
+ id: string
994
+ identifier?: string
995
+ title?: string
996
+ description?: string | null
997
+ priority?: number | null
998
+ url?: string | null
999
+ createdAt?: string
1000
+ updatedAt?: string
1001
+ assignee?: { id: string; name?: string | null } | null
1002
+ assigneeId?: string | null
1003
+ project?: { id: string; name: string } | null
1004
+ projectId?: string | null
1005
+ state?: { id: string; name: string; position?: number } | null
1006
+ stateId?: string | null
1007
+ team?: { id?: string | null; key?: string | null } | null
1008
+ teamId?: string | null
1009
+ labels?: Array<{ id: string; name: string }> | null
1010
+ commentCount?: number | null
1011
+ }
1012
+ } = {}
1013
+ try {
1014
+ body = JSON.parse(payload.rawBody) as typeof body
1015
+ } catch {
1016
+ return { handled: false, message: 'Invalid JSON body' }
1017
+ }
1018
+ if (body.type !== 'Issue') {
1019
+ return { handled: false, message: `Ignoring ${body.type ?? 'unknown'} event` }
1020
+ }
1021
+ const data = body.data
1022
+ if (!data) return { handled: false, message: 'No data in payload' }
1023
+
1024
+ if (body.action === 'remove') {
1025
+ await this.deleteIssue(data.id)
1026
+ await this.saveSyncMeta({ lastWebhookAt: new Date().toISOString() })
1027
+ return { handled: true }
1028
+ }
1029
+
1030
+ if (body.action === 'create' || body.action === 'update') {
1031
+ const configuredTeam = await this.getConfiguredTeam()
1032
+ const payloadTeamId = data.team?.id ?? data.teamId ?? null
1033
+ if (payloadTeamId && payloadTeamId !== configuredTeam.id) {
1034
+ return {
1035
+ handled: false,
1036
+ message: `Ignoring issue from team '${payloadTeamId}'`,
1037
+ }
1038
+ }
1039
+
1040
+ if (!payloadTeamId) {
1041
+ const issueTeam = await this.client.getIssueTeam(data.id)
1042
+ if (!issueTeam) {
1043
+ return {
1044
+ handled: false,
1045
+ message: `Ignoring issue '${data.id}' because its team could not be verified`,
1046
+ }
1047
+ }
1048
+ if (issueTeam.id !== configuredTeam.id) {
1049
+ return {
1050
+ handled: false,
1051
+ message: `Ignoring issue from team '${issueTeam.key}'`,
1052
+ }
1053
+ }
1054
+ }
1055
+
1056
+ if (!data.identifier || !data.title || !data.createdAt || !data.updatedAt) {
1057
+ return { handled: false, message: 'Missing required issue fields' }
1058
+ }
1059
+ const stateId = data.state?.id ?? data.stateId ?? null
1060
+ if (!stateId) return { handled: false, message: 'Missing state id' }
1061
+ await this.upsertIssues([
1062
+ {
1063
+ id: data.id,
1064
+ identifier: data.identifier,
1065
+ title: data.title,
1066
+ description: data.description ?? '',
1067
+ priority: data.priority ?? 0,
1068
+ assigneeId: data.assignee?.id ?? data.assigneeId ?? null,
1069
+ assigneeName: data.assignee?.name ?? null,
1070
+ projectId: data.project?.id ?? data.projectId ?? null,
1071
+ projectName: data.project?.name ?? null,
1072
+ stateId,
1073
+ stateName: data.state?.name ?? '',
1074
+ statePosition: data.state?.position ?? 0,
1075
+ labels: (data.labels ?? []).map((label) => label.name),
1076
+ commentCount: data.commentCount,
1077
+ url: data.url ?? null,
1078
+ createdAt: data.createdAt,
1079
+ updatedAt: data.updatedAt,
1080
+ },
1081
+ ])
1082
+ await this.saveSyncMeta({ lastWebhookAt: new Date().toISOString() })
1083
+ return { handled: true }
1084
+ }
1085
+
1086
+ return { handled: false, message: `Unsupported action: ${body.action}` }
1087
+ }
1088
+ }