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