@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.
- package/README.md +42 -19
- package/package.json +3 -2
- package/src/__tests__/activity.test.ts +12 -0
- package/src/__tests__/api.test.ts +4 -2
- package/src/__tests__/board-utils.test.ts +16 -3
- package/src/__tests__/column-roles.test.ts +34 -0
- package/src/__tests__/commands/board.test.ts +7 -0
- package/src/__tests__/conflict.test.ts +11 -1
- package/src/__tests__/db.test.ts +70 -0
- package/src/__tests__/index.test.ts +55 -0
- package/src/__tests__/jira-cache.test.ts +56 -0
- package/src/__tests__/jira-jql.test.ts +51 -0
- package/src/__tests__/jira-provider-read.test.ts +50 -0
- package/src/__tests__/mcp-server.test.ts +2 -2
- package/src/__tests__/metrics.test.ts +37 -1
- package/src/__tests__/postgres-local-provider.test.ts +90 -1
- package/src/__tests__/server.test.ts +126 -0
- package/src/__tests__/use-cases.test.ts +77 -0
- package/src/__tests__/webhooks.test.ts +75 -22
- package/src/api.ts +58 -36
- package/src/column-roles.ts +52 -0
- package/src/commands/board.ts +4 -4
- package/src/db.ts +145 -114
- package/src/errors.ts +1 -0
- package/src/index.ts +84 -33
- package/src/mcp/core.ts +8 -7
- package/src/metrics.ts +48 -23
- package/src/provider-runtime.ts +9 -2
- package/src/providers/index.ts +4 -1
- package/src/providers/jira-cache.ts +23 -6
- package/src/providers/jira-core.ts +921 -0
- package/src/providers/jira-jql.ts +48 -0
- package/src/providers/jira.ts +111 -759
- package/src/providers/linear-cache.ts +5 -3
- package/src/providers/linear-core.ts +688 -0
- package/src/providers/linear.ts +70 -554
- package/src/providers/postgres-jira-cache.ts +597 -0
- package/src/providers/postgres-jira.ts +15 -1312
- package/src/providers/postgres-linear-cache.ts +500 -0
- package/src/providers/postgres-linear.ts +22 -1088
- package/src/providers/postgres-local.ts +181 -74
- package/src/providers/types.ts +71 -2
- package/src/server.ts +118 -32
- package/src/use-cases.ts +139 -0
- package/src/webhooks.ts +26 -0
- package/ui/dist/assets/index-65LcV0R7.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-CFhtfqCn.js +0 -40
package/src/providers/linear.ts
CHANGED
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite'
|
|
2
|
-
import {
|
|
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
|
|
35
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
91
|
-
|
|
47
|
+
async replaceStates(states: Parameters<LinearCachePort['replaceStates']>[0]): Promise<void> {
|
|
48
|
+
replaceStates(this.db, states)
|
|
92
49
|
}
|
|
93
50
|
|
|
94
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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
|
|
282
|
-
|
|
63
|
+
async deleteIssue(idOrIdentifier: string): Promise<void> {
|
|
64
|
+
deleteLinearIssue(this.db, idOrIdentifier)
|
|
283
65
|
}
|
|
284
66
|
|
|
285
|
-
async
|
|
286
|
-
|
|
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
|
|
295
|
-
|
|
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
|
|
305
|
-
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
323
|
-
await this.sync()
|
|
86
|
+
async getCachedColumns(): Promise<LinearStateRow[]> {
|
|
324
87
|
return getCachedColumns(this.db)
|
|
325
88
|
}
|
|
326
89
|
|
|
327
|
-
async
|
|
328
|
-
|
|
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
|
|
436
|
-
|
|
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
|
|
443
|
-
|
|
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
|
|
450
|
-
|
|
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
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
481
|
-
const normalized =
|
|
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
|
-
>(
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
}
|