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