@andypai/agent-kanban 0.6.4 → 0.6.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/package.json +11 -3
- package/src/__tests__/index.test.ts +27 -1
- package/src/__tests__/jira-cache.test.ts +68 -0
- package/src/__tests__/jira-provider-read.test.ts +130 -0
- package/src/__tests__/jira-wiring.test.ts +41 -0
- package/src/__tests__/linear-provider-comment.test.ts +81 -0
- package/src/__tests__/linear-provider-sync.test.ts +107 -0
- package/src/__tests__/linear-state-resolution.test.ts +60 -0
- package/src/__tests__/local-provider-core.test.ts +245 -0
- package/src/__tests__/postgres-jira-provider.test.ts +55 -0
- package/src/__tests__/postgres-linear-provider.test.ts +90 -0
- package/src/__tests__/postgres-local-provider.test.ts +26 -0
- package/src/__tests__/provider-sync-core.test.ts +150 -0
- package/src/__tests__/provider-team-info.test.ts +37 -0
- package/src/__tests__/ui-board-slice.test.ts +14 -0
- package/src/__tests__/use-cases.test.ts +21 -42
- package/src/api.ts +78 -132
- package/src/column-roles.ts +3 -2
- package/src/index.ts +135 -92
- package/src/mcp/core.ts +7 -8
- package/src/provider-runtime.ts +12 -56
- package/src/providers/cache-task-mappers.ts +123 -0
- package/src/providers/factory.ts +103 -0
- package/src/providers/index.ts +4 -25
- package/src/providers/jira-cache.ts +41 -99
- package/src/providers/jira-core.ts +78 -80
- package/src/providers/linear-cache.ts +42 -69
- package/src/providers/linear-core.ts +41 -55
- package/src/providers/local-core.ts +226 -0
- package/src/providers/local.ts +6 -182
- package/src/providers/postgres-batch.ts +10 -0
- package/src/providers/postgres-jira-cache.ts +255 -213
- package/src/providers/postgres-jira.ts +2 -20
- package/src/providers/postgres-linear-cache.ts +279 -183
- package/src/providers/postgres-linear.ts +2 -20
- package/src/providers/postgres-local.ts +22 -47
- package/src/providers/sqlite-local-store.ts +121 -0
- package/src/providers/sync-core.ts +91 -0
- package/src/providers/team-info.ts +24 -0
- package/src/providers/warn-once.ts +6 -0
- package/src/use-cases.ts +7 -131
- package/src/webhook-events.ts +29 -1
- package/ui/dist/assets/index-DcZH7fI3.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-Cigv8a9S.js +0 -40
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Sql } from 'postgres'
|
|
2
2
|
|
|
3
|
-
import type { BoardConfig, BoardView,
|
|
3
|
+
import type { BoardConfig, BoardView, Task } from '../types'
|
|
4
4
|
import { ensureWebhookEventsSchema } from '../webhook-events'
|
|
5
5
|
import type { LinearCachePort } from './linear-core'
|
|
6
6
|
import {
|
|
@@ -9,75 +9,13 @@ import {
|
|
|
9
9
|
type LinearStateRow,
|
|
10
10
|
type LinearSyncMeta,
|
|
11
11
|
} from './linear-cache'
|
|
12
|
+
import { linearTaskFromRow, type LinearTaskRow } from './cache-task-mappers'
|
|
13
|
+
import { type Exec, recordsetJson } from './postgres-batch'
|
|
14
|
+
import { parseProviderTeamInfo } from './team-info'
|
|
12
15
|
|
|
13
16
|
export type { LinearActivityRow, LinearStateRow, LinearSyncMeta } from './linear-cache'
|
|
14
17
|
|
|
15
|
-
export
|
|
16
|
-
id: string
|
|
17
|
-
identifier: string
|
|
18
|
-
title: string
|
|
19
|
-
description: string
|
|
20
|
-
state_id: string
|
|
21
|
-
state_position: number
|
|
22
|
-
priority: number
|
|
23
|
-
assignee_name: string
|
|
24
|
-
project_name: string
|
|
25
|
-
labels: string
|
|
26
|
-
comment_count: number
|
|
27
|
-
url: string | null
|
|
28
|
-
created_at: string
|
|
29
|
-
updated_at: string
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function mapPriority(priority: number): Task['priority'] {
|
|
33
|
-
switch (priority) {
|
|
34
|
-
case 1:
|
|
35
|
-
return 'urgent'
|
|
36
|
-
case 2:
|
|
37
|
-
return 'high'
|
|
38
|
-
case 3:
|
|
39
|
-
return 'medium'
|
|
40
|
-
case 0:
|
|
41
|
-
case 4:
|
|
42
|
-
default:
|
|
43
|
-
return 'low'
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function parseLabels(raw: string): string[] {
|
|
48
|
-
try {
|
|
49
|
-
const parsed: unknown = JSON.parse(raw)
|
|
50
|
-
return Array.isArray(parsed)
|
|
51
|
-
? parsed.filter((value): value is string => typeof value === 'string')
|
|
52
|
-
: []
|
|
53
|
-
} catch {
|
|
54
|
-
return []
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function taskFromRow(row: LinearIssueRow): Task {
|
|
59
|
-
return {
|
|
60
|
-
id: `linear:${row.id}`,
|
|
61
|
-
providerId: row.id,
|
|
62
|
-
externalRef: row.identifier,
|
|
63
|
-
url: row.url,
|
|
64
|
-
title: row.title,
|
|
65
|
-
description: row.description,
|
|
66
|
-
column_id: row.state_id,
|
|
67
|
-
position: row.state_position,
|
|
68
|
-
priority: mapPriority(row.priority),
|
|
69
|
-
assignee: row.assignee_name,
|
|
70
|
-
assignees: row.assignee_name ? [row.assignee_name] : [],
|
|
71
|
-
labels: parseLabels(row.labels),
|
|
72
|
-
comment_count: row.comment_count,
|
|
73
|
-
project: row.project_name,
|
|
74
|
-
metadata: '{}',
|
|
75
|
-
created_at: row.created_at,
|
|
76
|
-
updated_at: row.updated_at,
|
|
77
|
-
version: row.updated_at,
|
|
78
|
-
source_updated_at: row.updated_at,
|
|
79
|
-
}
|
|
80
|
-
}
|
|
18
|
+
export type LinearIssueRow = LinearTaskRow
|
|
81
19
|
|
|
82
20
|
/**
|
|
83
21
|
* Postgres-backed cache/repository for the Linear provider. Mirrors the role of
|
|
@@ -200,25 +138,36 @@ export class PostgresLinearCache implements LinearCachePort {
|
|
|
200
138
|
'lastIssueUpdatedAt',
|
|
201
139
|
'lastWebhookAt',
|
|
202
140
|
] as const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
141
|
+
await this.sql.begin(async (tx) => {
|
|
142
|
+
for (const key of keys) {
|
|
143
|
+
if (!Object.prototype.hasOwnProperty.call(meta, key)) continue
|
|
144
|
+
const value = meta[key]
|
|
145
|
+
if (value === null) {
|
|
146
|
+
await tx`DELETE FROM linear_sync_meta WHERE key = ${key}`
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
if (key === 'team') {
|
|
150
|
+
await tx`
|
|
151
|
+
INSERT INTO linear_sync_meta (key, value)
|
|
152
|
+
VALUES (${key}, ${JSON.stringify(value)})
|
|
153
|
+
ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value
|
|
154
|
+
`
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (typeof value === 'string') {
|
|
158
|
+
await tx`
|
|
159
|
+
INSERT INTO linear_sync_meta (key, value)
|
|
160
|
+
VALUES (${key}, ${value})
|
|
161
|
+
ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value
|
|
162
|
+
`
|
|
163
|
+
}
|
|
213
164
|
}
|
|
214
|
-
|
|
215
|
-
}
|
|
165
|
+
})
|
|
216
166
|
}
|
|
217
167
|
|
|
218
168
|
async loadSyncMeta(): Promise<LinearSyncMeta> {
|
|
219
|
-
const teamRaw = await this.getMeta('team')
|
|
220
169
|
return {
|
|
221
|
-
team:
|
|
170
|
+
team: parseProviderTeamInfo(await this.getMeta('team')),
|
|
222
171
|
lastSyncAt: await this.getMeta('lastSyncAt'),
|
|
223
172
|
lastFullSyncAt: await this.getMeta('lastFullSyncAt'),
|
|
224
173
|
lastIssueUpdatedAt: await this.getMeta('lastIssueUpdatedAt'),
|
|
@@ -236,56 +185,110 @@ export class PostgresLinearCache implements LinearCachePort {
|
|
|
236
185
|
}>,
|
|
237
186
|
): Promise<void> {
|
|
238
187
|
const now = new Date().toISOString()
|
|
188
|
+
const rows = states.map((state) => ({
|
|
189
|
+
id: state.id,
|
|
190
|
+
name: state.name,
|
|
191
|
+
position: state.position,
|
|
192
|
+
color: state.color ?? null,
|
|
193
|
+
type: state.type ?? null,
|
|
194
|
+
created_at: now,
|
|
195
|
+
updated_at: now,
|
|
196
|
+
}))
|
|
239
197
|
await this.sql.begin(async (tx) => {
|
|
198
|
+
await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-linear:states'))`
|
|
240
199
|
await tx`DELETE FROM linear_states`
|
|
241
|
-
|
|
200
|
+
if (rows.length > 0) {
|
|
242
201
|
await tx`
|
|
243
202
|
INSERT INTO linear_states (id, name, position, color, type, created_at, updated_at)
|
|
244
|
-
|
|
203
|
+
SELECT id, name, position, color, type, created_at, updated_at
|
|
204
|
+
FROM jsonb_to_recordset(${recordsetJson(tx, rows)}) AS data(
|
|
205
|
+
id text,
|
|
206
|
+
name text,
|
|
207
|
+
position integer,
|
|
208
|
+
color text,
|
|
209
|
+
type text,
|
|
210
|
+
created_at text,
|
|
211
|
+
updated_at text
|
|
212
|
+
)
|
|
245
213
|
`
|
|
246
214
|
}
|
|
247
215
|
})
|
|
248
216
|
}
|
|
249
217
|
|
|
250
218
|
async upsertUsers(users: Array<{ id: string; name: string; active?: boolean }>): Promise<void> {
|
|
219
|
+
if (users.length === 0) return
|
|
251
220
|
const now = new Date().toISOString()
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
221
|
+
const rows = users.map((user) => ({
|
|
222
|
+
id: user.id,
|
|
223
|
+
name: user.name,
|
|
224
|
+
active: user.active === false ? 0 : 1,
|
|
225
|
+
updated_at: now,
|
|
226
|
+
}))
|
|
227
|
+
await this.sql`
|
|
228
|
+
INSERT INTO linear_users (id, name, active, updated_at)
|
|
229
|
+
SELECT id, name, active, updated_at
|
|
230
|
+
FROM jsonb_to_recordset(${recordsetJson(this.sql, rows)}) AS data(
|
|
231
|
+
id text,
|
|
232
|
+
name text,
|
|
233
|
+
active integer,
|
|
234
|
+
updated_at text
|
|
235
|
+
)
|
|
236
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
237
|
+
name = EXCLUDED.name,
|
|
238
|
+
active = EXCLUDED.active,
|
|
239
|
+
updated_at = EXCLUDED.updated_at
|
|
240
|
+
`
|
|
262
241
|
}
|
|
263
242
|
|
|
264
243
|
async upsertProjects(
|
|
265
244
|
projects: Array<{ id: string; name: string; url?: string | null; state?: string | null }>,
|
|
266
245
|
): Promise<void> {
|
|
246
|
+
if (projects.length === 0) return
|
|
267
247
|
const now = new Date().toISOString()
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
248
|
+
const rows = projects.map((project) => ({
|
|
249
|
+
id: project.id,
|
|
250
|
+
name: project.name,
|
|
251
|
+
url: project.url ?? null,
|
|
252
|
+
state: project.state ?? null,
|
|
253
|
+
updated_at: now,
|
|
254
|
+
}))
|
|
255
|
+
await this.sql`
|
|
256
|
+
INSERT INTO linear_projects (id, name, url, state, updated_at)
|
|
257
|
+
SELECT id, name, url, state, updated_at
|
|
258
|
+
FROM jsonb_to_recordset(${recordsetJson(this.sql, rows)}) AS data(
|
|
259
|
+
id text,
|
|
260
|
+
name text,
|
|
261
|
+
url text,
|
|
262
|
+
state text,
|
|
263
|
+
updated_at text
|
|
264
|
+
)
|
|
265
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
266
|
+
name = EXCLUDED.name,
|
|
267
|
+
url = EXCLUDED.url,
|
|
268
|
+
state = EXCLUDED.state,
|
|
269
|
+
updated_at = EXCLUDED.updated_at
|
|
270
|
+
`
|
|
279
271
|
}
|
|
280
272
|
|
|
281
273
|
async saveActivity(rows: LinearActivityRow[]): Promise<void> {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
274
|
+
await this.insertActivityRows(this.sql, rows)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private async insertActivityRows(sql: Exec, rows: LinearActivityRow[]): Promise<void> {
|
|
278
|
+
if (rows.length === 0) return
|
|
279
|
+
await sql`
|
|
280
|
+
INSERT INTO linear_activity (issue_id, history_id, item_field, from_value, to_value, created_at)
|
|
281
|
+
SELECT issue_id, history_id, item_field, from_value, to_value, created_at
|
|
282
|
+
FROM jsonb_to_recordset(${recordsetJson(sql, rows)}) AS data(
|
|
283
|
+
issue_id text,
|
|
284
|
+
history_id text,
|
|
285
|
+
item_field text,
|
|
286
|
+
from_value text,
|
|
287
|
+
to_value text,
|
|
288
|
+
created_at text
|
|
289
|
+
)
|
|
290
|
+
ON CONFLICT(issue_id, history_id, item_field) DO NOTHING
|
|
291
|
+
`
|
|
289
292
|
}
|
|
290
293
|
|
|
291
294
|
async upsertIssues(
|
|
@@ -309,84 +312,177 @@ export class PostgresLinearCache implements LinearCachePort {
|
|
|
309
312
|
updatedAt: string
|
|
310
313
|
}>,
|
|
311
314
|
): Promise<void> {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
315
|
+
if (issues.length === 0) return
|
|
316
|
+
// A sync batch can repeat an issue id (Linear's updatedAt-ordered pagination
|
|
317
|
+
// can return an issue on two pages); the batched ON CONFLICT DO UPDATE
|
|
318
|
+
// statement errors on intra-statement duplicates, so keep the last
|
|
319
|
+
// occurrence — matching the old row-by-row and SQLite last-wins behavior.
|
|
320
|
+
const mapped = issues.map((issue) => {
|
|
321
|
+
const hasCommentCount = issue.commentCount !== undefined && issue.commentCount !== null
|
|
322
|
+
return {
|
|
323
|
+
id: issue.id,
|
|
324
|
+
identifier: issue.identifier,
|
|
325
|
+
title: issue.title,
|
|
326
|
+
description: issue.description ?? '',
|
|
327
|
+
priority: issue.priority ?? 0,
|
|
328
|
+
assignee_id: issue.assigneeId ?? null,
|
|
329
|
+
assignee_name: issue.assigneeName ?? '',
|
|
330
|
+
project_id: issue.projectId ?? null,
|
|
331
|
+
project_name: issue.projectName ?? '',
|
|
332
|
+
state_id: issue.stateId,
|
|
333
|
+
state_name: issue.stateName,
|
|
334
|
+
state_position: issue.statePosition,
|
|
335
|
+
labels: JSON.stringify(issue.labels ?? []),
|
|
336
|
+
comment_count: issue.commentCount ?? 0,
|
|
337
|
+
comment_count_provided: hasCommentCount,
|
|
338
|
+
url: issue.url ?? null,
|
|
339
|
+
created_at: issue.createdAt,
|
|
340
|
+
updated_at: issue.updatedAt,
|
|
328
341
|
}
|
|
342
|
+
})
|
|
343
|
+
const rows = [...new Map(mapped.map((row) => [row.id, row])).values()]
|
|
329
344
|
|
|
330
|
-
|
|
331
|
-
await
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
url, created_at, updated_at
|
|
336
|
-
) VALUES (
|
|
337
|
-
${issue.id}, ${issue.identifier}, ${issue.title}, ${nextDescription}, ${issue.priority ?? 0},
|
|
338
|
-
${issue.assigneeId ?? null}, ${issue.assigneeName ?? ''}, ${issue.projectId ?? null},
|
|
339
|
-
${issue.projectName ?? ''}, ${issue.stateId}, ${issue.stateName}, ${issue.statePosition},
|
|
340
|
-
${JSON.stringify(issue.labels ?? [])}, ${hasCommentCount ? (issue.commentCount ?? 0) : 0},
|
|
341
|
-
${issue.url ?? null}, ${issue.createdAt}, ${issue.updatedAt}
|
|
342
|
-
)
|
|
343
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
344
|
-
identifier = EXCLUDED.identifier,
|
|
345
|
-
title = EXCLUDED.title,
|
|
346
|
-
description = EXCLUDED.description,
|
|
347
|
-
priority = EXCLUDED.priority,
|
|
348
|
-
assignee_id = EXCLUDED.assignee_id,
|
|
349
|
-
assignee_name = EXCLUDED.assignee_name,
|
|
350
|
-
project_id = EXCLUDED.project_id,
|
|
351
|
-
project_name = EXCLUDED.project_name,
|
|
352
|
-
state_id = EXCLUDED.state_id,
|
|
353
|
-
state_name = EXCLUDED.state_name,
|
|
354
|
-
state_position = EXCLUDED.state_position,
|
|
355
|
-
labels = EXCLUDED.labels,
|
|
356
|
-
comment_count = CASE
|
|
357
|
-
WHEN ${hasCommentCount} THEN EXCLUDED.comment_count
|
|
358
|
-
ELSE linear_issues.comment_count
|
|
359
|
-
END,
|
|
360
|
-
url = EXCLUDED.url,
|
|
361
|
-
created_at = EXCLUDED.created_at,
|
|
362
|
-
updated_at = EXCLUDED.updated_at
|
|
345
|
+
await this.sql.begin(async (tx) => {
|
|
346
|
+
await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-linear:issues'))`
|
|
347
|
+
const ids = rows.map((issue) => issue.id)
|
|
348
|
+
const priorRows = await tx<{ id: string; description: string }[]>`
|
|
349
|
+
SELECT id, description FROM linear_issues WHERE id = ANY(${ids})
|
|
363
350
|
`
|
|
364
|
-
|
|
351
|
+
const priorDescriptions = new Map(priorRows.map((row) => [row.id, row.description]))
|
|
352
|
+
await this.insertActivityRows(
|
|
353
|
+
tx,
|
|
354
|
+
rows.flatMap((issue) => {
|
|
355
|
+
const prior = priorDescriptions.get(issue.id)
|
|
356
|
+
if (prior === undefined || prior === issue.description) return []
|
|
357
|
+
return [
|
|
358
|
+
{
|
|
359
|
+
issue_id: issue.id,
|
|
360
|
+
history_id: `desc:${issue.updated_at}`,
|
|
361
|
+
item_field: 'description',
|
|
362
|
+
from_value: clampActivityValue(prior),
|
|
363
|
+
to_value: clampActivityValue(issue.description),
|
|
364
|
+
created_at: issue.updated_at,
|
|
365
|
+
},
|
|
366
|
+
]
|
|
367
|
+
}),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
await this.upsertIssueRows(
|
|
371
|
+
tx,
|
|
372
|
+
rows.filter((issue) => issue.comment_count_provided),
|
|
373
|
+
true,
|
|
374
|
+
)
|
|
375
|
+
await this.upsertIssueRows(
|
|
376
|
+
tx,
|
|
377
|
+
rows.filter((issue) => !issue.comment_count_provided),
|
|
378
|
+
false,
|
|
379
|
+
)
|
|
380
|
+
})
|
|
365
381
|
}
|
|
366
382
|
|
|
367
|
-
async
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
383
|
+
private async upsertIssueRows(
|
|
384
|
+
sql: Exec,
|
|
385
|
+
rows: Array<{
|
|
386
|
+
id: string
|
|
387
|
+
identifier: string
|
|
388
|
+
title: string
|
|
389
|
+
description: string
|
|
390
|
+
priority: number
|
|
391
|
+
assignee_id: string | null
|
|
392
|
+
assignee_name: string
|
|
393
|
+
project_id: string | null
|
|
394
|
+
project_name: string
|
|
395
|
+
state_id: string
|
|
396
|
+
state_name: string
|
|
397
|
+
state_position: number
|
|
398
|
+
labels: string
|
|
399
|
+
comment_count: number
|
|
400
|
+
url: string | null
|
|
401
|
+
created_at: string
|
|
402
|
+
updated_at: string
|
|
403
|
+
}>,
|
|
404
|
+
updateCommentCount: boolean,
|
|
405
|
+
): Promise<void> {
|
|
406
|
+
if (rows.length === 0) return
|
|
407
|
+
const commentCountAssignment = updateCommentCount
|
|
408
|
+
? sql`, comment_count = EXCLUDED.comment_count`
|
|
409
|
+
: sql``
|
|
410
|
+
await sql`
|
|
411
|
+
INSERT INTO linear_issues (
|
|
412
|
+
id, identifier, title, description, priority, assignee_id, assignee_name,
|
|
413
|
+
project_id, project_name, state_id, state_name, state_position, labels, comment_count,
|
|
414
|
+
url, created_at, updated_at
|
|
415
|
+
)
|
|
416
|
+
SELECT
|
|
417
|
+
id, identifier, title, description, priority, assignee_id, assignee_name,
|
|
418
|
+
project_id, project_name, state_id, state_name, state_position, labels, comment_count,
|
|
419
|
+
url, created_at, updated_at
|
|
420
|
+
FROM jsonb_to_recordset(${recordsetJson(sql, rows)}) AS data(
|
|
421
|
+
id text,
|
|
422
|
+
identifier text,
|
|
423
|
+
title text,
|
|
424
|
+
description text,
|
|
425
|
+
priority integer,
|
|
426
|
+
assignee_id text,
|
|
427
|
+
assignee_name text,
|
|
428
|
+
project_id text,
|
|
429
|
+
project_name text,
|
|
430
|
+
state_id text,
|
|
431
|
+
state_name text,
|
|
432
|
+
state_position integer,
|
|
433
|
+
labels text,
|
|
434
|
+
comment_count integer,
|
|
435
|
+
url text,
|
|
436
|
+
created_at text,
|
|
437
|
+
updated_at text
|
|
438
|
+
)
|
|
439
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
440
|
+
identifier = EXCLUDED.identifier,
|
|
441
|
+
title = EXCLUDED.title,
|
|
442
|
+
description = EXCLUDED.description,
|
|
443
|
+
priority = EXCLUDED.priority,
|
|
444
|
+
assignee_id = EXCLUDED.assignee_id,
|
|
445
|
+
assignee_name = EXCLUDED.assignee_name,
|
|
446
|
+
project_id = EXCLUDED.project_id,
|
|
447
|
+
project_name = EXCLUDED.project_name,
|
|
448
|
+
state_id = EXCLUDED.state_id,
|
|
449
|
+
state_name = EXCLUDED.state_name,
|
|
450
|
+
state_position = EXCLUDED.state_position,
|
|
451
|
+
labels = EXCLUDED.labels${commentCountAssignment},
|
|
452
|
+
url = EXCLUDED.url,
|
|
453
|
+
created_at = EXCLUDED.created_at,
|
|
454
|
+
updated_at = EXCLUDED.updated_at
|
|
372
455
|
`
|
|
373
|
-
|
|
374
|
-
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async deleteIssue(idOrIdentifier: string): Promise<void> {
|
|
459
|
+
await this.sql.begin(async (tx) => {
|
|
460
|
+
await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-linear:issues'))`
|
|
461
|
+
await tx`
|
|
462
|
+
DELETE FROM linear_activity
|
|
463
|
+
WHERE issue_id = ${idOrIdentifier}
|
|
464
|
+
OR issue_id IN (SELECT id FROM linear_issues WHERE identifier = ${idOrIdentifier})
|
|
465
|
+
`
|
|
466
|
+
await tx`DELETE FROM linear_issues WHERE id = ${idOrIdentifier} OR identifier = ${idOrIdentifier}`
|
|
467
|
+
})
|
|
375
468
|
}
|
|
376
469
|
|
|
377
470
|
async pruneIssues(liveIssueIds: string[]): Promise<void> {
|
|
378
|
-
|
|
379
|
-
await
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
471
|
+
await this.sql.begin(async (tx) => {
|
|
472
|
+
await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-linear:issues'))`
|
|
473
|
+
if (liveIssueIds.length === 0) {
|
|
474
|
+
await tx`DELETE FROM linear_activity`
|
|
475
|
+
await tx`DELETE FROM linear_issues`
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
await tx`
|
|
479
|
+
DELETE FROM linear_activity
|
|
480
|
+
WHERE issue_id IN (
|
|
481
|
+
SELECT id FROM linear_issues WHERE NOT (id = ANY(${liveIssueIds}))
|
|
482
|
+
)
|
|
483
|
+
`
|
|
484
|
+
await tx`DELETE FROM linear_issues WHERE NOT (id = ANY(${liveIssueIds}))`
|
|
485
|
+
})
|
|
390
486
|
}
|
|
391
487
|
|
|
392
488
|
async adjustIssueCommentCount(idOrIdentifier: string, delta: number): Promise<void> {
|
|
@@ -411,7 +507,7 @@ export class PostgresLinearCache implements LinearCachePort {
|
|
|
411
507
|
WHERE state_id = ${column.id}
|
|
412
508
|
ORDER BY updated_at DESC, title ASC
|
|
413
509
|
`
|
|
414
|
-
).map(
|
|
510
|
+
).map(linearTaskFromRow)
|
|
415
511
|
boardColumns.push({ ...column, tasks })
|
|
416
512
|
}
|
|
417
513
|
return { columns: boardColumns }
|
|
@@ -424,7 +520,7 @@ export class PostgresLinearCache implements LinearCachePort {
|
|
|
424
520
|
WHERE id = ${normalized} OR identifier = ${normalized}
|
|
425
521
|
LIMIT 1
|
|
426
522
|
`
|
|
427
|
-
return row ?
|
|
523
|
+
return row ? linearTaskFromRow(row) : null
|
|
428
524
|
}
|
|
429
525
|
|
|
430
526
|
async getCachedTasks(): Promise<Task[]> {
|
|
@@ -432,7 +528,7 @@ export class PostgresLinearCache implements LinearCachePort {
|
|
|
432
528
|
await this.sql<LinearIssueRow[]>`
|
|
433
529
|
SELECT * FROM linear_issues ORDER BY updated_at DESC, title ASC
|
|
434
530
|
`
|
|
435
|
-
).map(
|
|
531
|
+
).map(linearTaskFromRow)
|
|
436
532
|
}
|
|
437
533
|
|
|
438
534
|
async getCachedConfig(): Promise<BoardConfig> {
|
|
@@ -2,7 +2,7 @@ import type { Sql } from 'postgres'
|
|
|
2
2
|
|
|
3
3
|
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
|
|
4
4
|
import type { WebhookRequest, WebhookResult } from '../webhooks'
|
|
5
|
-
import {
|
|
5
|
+
import { withWebhookRecording } from '../webhook-events'
|
|
6
6
|
import { LinearClient } from './linear-client'
|
|
7
7
|
import { LinearProviderCore } from './linear-core'
|
|
8
8
|
import { PostgresLinearCache } from './postgres-linear-cache'
|
|
@@ -30,24 +30,6 @@ export class PostgresLinearProvider extends LinearProviderCore {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
override async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
|
|
33
|
-
|
|
34
|
-
let result: WebhookResult
|
|
35
|
-
try {
|
|
36
|
-
result = await this.handleWebhookCore(payload)
|
|
37
|
-
} catch (err) {
|
|
38
|
-
void recordWebhookEvent(this.sql, {
|
|
39
|
-
provider: 'linear',
|
|
40
|
-
...meta,
|
|
41
|
-
status: 'error',
|
|
42
|
-
detail: { error: err instanceof Error ? err.message : String(err) },
|
|
43
|
-
})
|
|
44
|
-
throw err
|
|
45
|
-
}
|
|
46
|
-
void recordWebhookEvent(this.sql, {
|
|
47
|
-
provider: 'linear',
|
|
48
|
-
...meta,
|
|
49
|
-
status: webhookEventStatus(result),
|
|
50
|
-
})
|
|
51
|
-
return result
|
|
33
|
+
return withWebhookRecording(this.sql, 'linear', payload, () => this.handleWebhookCore(payload))
|
|
52
34
|
}
|
|
53
35
|
}
|