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