@andypai/agent-kanban 0.6.0 → 0.6.2
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 +91 -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 +926 -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 +693 -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 +19 -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
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import type { Sql } from 'postgres'
|
|
2
|
+
|
|
3
|
+
import type { BoardConfig, BoardView, ProviderTeamInfo, Task } from '../types'
|
|
4
|
+
import { ensureWebhookEventsSchema } from '../webhook-events'
|
|
5
|
+
import type { LinearCachePort } from './linear-core'
|
|
6
|
+
import {
|
|
7
|
+
clampActivityValue,
|
|
8
|
+
type LinearActivityRow,
|
|
9
|
+
type LinearStateRow,
|
|
10
|
+
type LinearSyncMeta,
|
|
11
|
+
} from './linear-cache'
|
|
12
|
+
|
|
13
|
+
export type { LinearActivityRow, LinearStateRow, LinearSyncMeta } from './linear-cache'
|
|
14
|
+
|
|
15
|
+
export interface LinearIssueRow {
|
|
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
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Postgres-backed cache/repository for the Linear provider. Mirrors the role of
|
|
84
|
+
* the SQLite-side `linear-cache.ts` free functions, but as an instance that owns
|
|
85
|
+
* the async `postgres.js` client and its own schema-readiness promise. Holds only
|
|
86
|
+
* cache I/O (persistence + materialization, including description-change activity
|
|
87
|
+
* synthesis on write); API sync and business logic stay in `PostgresLinearProvider`.
|
|
88
|
+
*/
|
|
89
|
+
export class PostgresLinearCache implements LinearCachePort {
|
|
90
|
+
readonly ready: Promise<void>
|
|
91
|
+
|
|
92
|
+
constructor(private readonly sql: Sql) {
|
|
93
|
+
this.ready = this.ensureSchema()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private async ensureSchema(): Promise<void> {
|
|
97
|
+
await this.sql`
|
|
98
|
+
CREATE TABLE IF NOT EXISTS linear_sync_meta (
|
|
99
|
+
key TEXT PRIMARY KEY,
|
|
100
|
+
value TEXT NOT NULL
|
|
101
|
+
)
|
|
102
|
+
`
|
|
103
|
+
await this.sql`
|
|
104
|
+
CREATE TABLE IF NOT EXISTS linear_states (
|
|
105
|
+
id TEXT PRIMARY KEY,
|
|
106
|
+
name TEXT NOT NULL,
|
|
107
|
+
position INTEGER NOT NULL,
|
|
108
|
+
color TEXT,
|
|
109
|
+
type TEXT,
|
|
110
|
+
created_at TEXT NOT NULL,
|
|
111
|
+
updated_at TEXT NOT NULL
|
|
112
|
+
)
|
|
113
|
+
`
|
|
114
|
+
await this.sql`
|
|
115
|
+
CREATE TABLE IF NOT EXISTS linear_users (
|
|
116
|
+
id TEXT PRIMARY KEY,
|
|
117
|
+
name TEXT NOT NULL,
|
|
118
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
119
|
+
updated_at TEXT NOT NULL
|
|
120
|
+
)
|
|
121
|
+
`
|
|
122
|
+
await this.sql`
|
|
123
|
+
CREATE TABLE IF NOT EXISTS linear_projects (
|
|
124
|
+
id TEXT PRIMARY KEY,
|
|
125
|
+
name TEXT NOT NULL,
|
|
126
|
+
url TEXT,
|
|
127
|
+
state TEXT,
|
|
128
|
+
updated_at TEXT NOT NULL
|
|
129
|
+
)
|
|
130
|
+
`
|
|
131
|
+
await this.sql`
|
|
132
|
+
CREATE TABLE IF NOT EXISTS linear_issues (
|
|
133
|
+
id TEXT PRIMARY KEY,
|
|
134
|
+
identifier TEXT NOT NULL UNIQUE,
|
|
135
|
+
title TEXT NOT NULL,
|
|
136
|
+
description TEXT NOT NULL DEFAULT '',
|
|
137
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
138
|
+
assignee_id TEXT,
|
|
139
|
+
assignee_name TEXT NOT NULL DEFAULT '',
|
|
140
|
+
project_id TEXT,
|
|
141
|
+
project_name TEXT NOT NULL DEFAULT '',
|
|
142
|
+
state_id TEXT NOT NULL,
|
|
143
|
+
state_name TEXT NOT NULL,
|
|
144
|
+
state_position INTEGER NOT NULL DEFAULT 0,
|
|
145
|
+
labels TEXT NOT NULL DEFAULT '[]',
|
|
146
|
+
comment_count INTEGER NOT NULL DEFAULT 0,
|
|
147
|
+
url TEXT,
|
|
148
|
+
created_at TEXT NOT NULL,
|
|
149
|
+
updated_at TEXT NOT NULL
|
|
150
|
+
)
|
|
151
|
+
`
|
|
152
|
+
await this
|
|
153
|
+
.sql`ALTER TABLE linear_issues ADD COLUMN IF NOT EXISTS labels TEXT NOT NULL DEFAULT '[]'`
|
|
154
|
+
await this
|
|
155
|
+
.sql`ALTER TABLE linear_issues ADD COLUMN IF NOT EXISTS comment_count INTEGER NOT NULL DEFAULT 0`
|
|
156
|
+
await this.sql`CREATE INDEX IF NOT EXISTS idx_linear_issues_state_id ON linear_issues(state_id)`
|
|
157
|
+
await this
|
|
158
|
+
.sql`CREATE INDEX IF NOT EXISTS idx_linear_issues_updated_at ON linear_issues(updated_at)`
|
|
159
|
+
await this.sql`
|
|
160
|
+
CREATE TABLE IF NOT EXISTS linear_activity (
|
|
161
|
+
issue_id TEXT NOT NULL,
|
|
162
|
+
history_id TEXT NOT NULL,
|
|
163
|
+
item_field TEXT NOT NULL,
|
|
164
|
+
from_value TEXT,
|
|
165
|
+
to_value TEXT,
|
|
166
|
+
created_at TEXT NOT NULL,
|
|
167
|
+
PRIMARY KEY (issue_id, history_id, item_field)
|
|
168
|
+
)
|
|
169
|
+
`
|
|
170
|
+
await this.sql`
|
|
171
|
+
CREATE INDEX IF NOT EXISTS linear_activity_created_at_idx ON linear_activity(created_at DESC)
|
|
172
|
+
`
|
|
173
|
+
await ensureWebhookEventsSchema(this.sql)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async setMeta(key: string, value: string): Promise<void> {
|
|
177
|
+
await this.sql`
|
|
178
|
+
INSERT INTO linear_sync_meta (key, value)
|
|
179
|
+
VALUES (${key}, ${value})
|
|
180
|
+
ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value
|
|
181
|
+
`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async deleteMeta(key: string): Promise<void> {
|
|
185
|
+
await this.sql`DELETE FROM linear_sync_meta WHERE key = ${key}`
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async getMeta(key: string): Promise<string | null> {
|
|
189
|
+
const [row] = await this.sql<{ value: string }[]>`
|
|
190
|
+
SELECT value FROM linear_sync_meta WHERE key = ${key}
|
|
191
|
+
`
|
|
192
|
+
return row?.value ?? null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async saveSyncMeta(meta: Partial<LinearSyncMeta>): Promise<void> {
|
|
196
|
+
const keys = [
|
|
197
|
+
'team',
|
|
198
|
+
'lastSyncAt',
|
|
199
|
+
'lastFullSyncAt',
|
|
200
|
+
'lastIssueUpdatedAt',
|
|
201
|
+
'lastWebhookAt',
|
|
202
|
+
] as const
|
|
203
|
+
for (const key of keys) {
|
|
204
|
+
if (!Object.prototype.hasOwnProperty.call(meta, key)) continue
|
|
205
|
+
const value = meta[key]
|
|
206
|
+
if (value === null) {
|
|
207
|
+
await this.deleteMeta(key)
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
if (key === 'team') {
|
|
211
|
+
await this.setMeta(key, JSON.stringify(value))
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
if (typeof value === 'string') await this.setMeta(key, value)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async loadSyncMeta(): Promise<LinearSyncMeta> {
|
|
219
|
+
const teamRaw = await this.getMeta('team')
|
|
220
|
+
return {
|
|
221
|
+
team: teamRaw ? (JSON.parse(teamRaw) as ProviderTeamInfo) : null,
|
|
222
|
+
lastSyncAt: await this.getMeta('lastSyncAt'),
|
|
223
|
+
lastFullSyncAt: await this.getMeta('lastFullSyncAt'),
|
|
224
|
+
lastIssueUpdatedAt: await this.getMeta('lastIssueUpdatedAt'),
|
|
225
|
+
lastWebhookAt: await this.getMeta('lastWebhookAt'),
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async replaceStates(
|
|
230
|
+
states: Array<{
|
|
231
|
+
id: string
|
|
232
|
+
name: string
|
|
233
|
+
position: number
|
|
234
|
+
color?: string | null
|
|
235
|
+
type?: string | null
|
|
236
|
+
}>,
|
|
237
|
+
): Promise<void> {
|
|
238
|
+
const now = new Date().toISOString()
|
|
239
|
+
await this.sql.begin(async (tx) => {
|
|
240
|
+
await tx`DELETE FROM linear_states`
|
|
241
|
+
for (const state of states) {
|
|
242
|
+
await tx`
|
|
243
|
+
INSERT INTO linear_states (id, name, position, color, type, created_at, updated_at)
|
|
244
|
+
VALUES (${state.id}, ${state.name}, ${state.position}, ${state.color ?? null}, ${state.type ?? null}, ${now}, ${now})
|
|
245
|
+
`
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async upsertUsers(users: Array<{ id: string; name: string; active?: boolean }>): Promise<void> {
|
|
251
|
+
const now = new Date().toISOString()
|
|
252
|
+
for (const user of users) {
|
|
253
|
+
await this.sql`
|
|
254
|
+
INSERT INTO linear_users (id, name, active, updated_at)
|
|
255
|
+
VALUES (${user.id}, ${user.name}, ${user.active === false ? 0 : 1}, ${now})
|
|
256
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
257
|
+
name = EXCLUDED.name,
|
|
258
|
+
active = EXCLUDED.active,
|
|
259
|
+
updated_at = EXCLUDED.updated_at
|
|
260
|
+
`
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async upsertProjects(
|
|
265
|
+
projects: Array<{ id: string; name: string; url?: string | null; state?: string | null }>,
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
const now = new Date().toISOString()
|
|
268
|
+
for (const project of projects) {
|
|
269
|
+
await this.sql`
|
|
270
|
+
INSERT INTO linear_projects (id, name, url, state, updated_at)
|
|
271
|
+
VALUES (${project.id}, ${project.name}, ${project.url ?? null}, ${project.state ?? null}, ${now})
|
|
272
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
273
|
+
name = EXCLUDED.name,
|
|
274
|
+
url = EXCLUDED.url,
|
|
275
|
+
state = EXCLUDED.state,
|
|
276
|
+
updated_at = EXCLUDED.updated_at
|
|
277
|
+
`
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async saveActivity(rows: LinearActivityRow[]): Promise<void> {
|
|
282
|
+
for (const row of rows) {
|
|
283
|
+
await this.sql`
|
|
284
|
+
INSERT INTO linear_activity (issue_id, history_id, item_field, from_value, to_value, created_at)
|
|
285
|
+
VALUES (${row.issue_id}, ${row.history_id}, ${row.item_field}, ${row.from_value}, ${row.to_value}, ${row.created_at})
|
|
286
|
+
ON CONFLICT(issue_id, history_id, item_field) DO NOTHING
|
|
287
|
+
`
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async upsertIssues(
|
|
292
|
+
issues: Array<{
|
|
293
|
+
id: string
|
|
294
|
+
identifier: string
|
|
295
|
+
title: string
|
|
296
|
+
description?: string | null
|
|
297
|
+
priority?: number | null
|
|
298
|
+
assigneeId?: string | null
|
|
299
|
+
assigneeName?: string | null
|
|
300
|
+
projectId?: string | null
|
|
301
|
+
projectName?: string | null
|
|
302
|
+
stateId: string
|
|
303
|
+
stateName: string
|
|
304
|
+
statePosition: number
|
|
305
|
+
labels?: string[] | null
|
|
306
|
+
commentCount?: number | null
|
|
307
|
+
url?: string | null
|
|
308
|
+
createdAt: string
|
|
309
|
+
updatedAt: string
|
|
310
|
+
}>,
|
|
311
|
+
): Promise<void> {
|
|
312
|
+
for (const issue of issues) {
|
|
313
|
+
const nextDescription = issue.description ?? ''
|
|
314
|
+
const [prior] = await this.sql<{ description: string }[]>`
|
|
315
|
+
SELECT description FROM linear_issues WHERE id = ${issue.id} LIMIT 1
|
|
316
|
+
`
|
|
317
|
+
if (prior && prior.description !== nextDescription) {
|
|
318
|
+
await this.saveActivity([
|
|
319
|
+
{
|
|
320
|
+
issue_id: issue.id,
|
|
321
|
+
history_id: `desc:${issue.updatedAt}`,
|
|
322
|
+
item_field: 'description',
|
|
323
|
+
from_value: clampActivityValue(prior.description),
|
|
324
|
+
to_value: clampActivityValue(nextDescription),
|
|
325
|
+
created_at: issue.updatedAt,
|
|
326
|
+
},
|
|
327
|
+
])
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const hasCommentCount = issue.commentCount !== undefined && issue.commentCount !== null
|
|
331
|
+
await this.sql`
|
|
332
|
+
INSERT INTO linear_issues (
|
|
333
|
+
id, identifier, title, description, priority, assignee_id, assignee_name,
|
|
334
|
+
project_id, project_name, state_id, state_name, state_position, labels, comment_count,
|
|
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
|
|
363
|
+
`
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async deleteIssue(idOrIdentifier: string): Promise<void> {
|
|
368
|
+
await this.sql`
|
|
369
|
+
DELETE FROM linear_activity
|
|
370
|
+
WHERE issue_id = ${idOrIdentifier}
|
|
371
|
+
OR issue_id IN (SELECT id FROM linear_issues WHERE identifier = ${idOrIdentifier})
|
|
372
|
+
`
|
|
373
|
+
await this
|
|
374
|
+
.sql`DELETE FROM linear_issues WHERE id = ${idOrIdentifier} OR identifier = ${idOrIdentifier}`
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async pruneIssues(liveIssueIds: string[]): Promise<void> {
|
|
378
|
+
if (liveIssueIds.length === 0) {
|
|
379
|
+
await this.sql`DELETE FROM linear_activity`
|
|
380
|
+
await this.sql`DELETE FROM linear_issues`
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
await this.sql`
|
|
384
|
+
DELETE FROM linear_activity
|
|
385
|
+
WHERE issue_id IN (
|
|
386
|
+
SELECT id FROM linear_issues WHERE NOT (id = ANY(${liveIssueIds}))
|
|
387
|
+
)
|
|
388
|
+
`
|
|
389
|
+
await this.sql`DELETE FROM linear_issues WHERE NOT (id = ANY(${liveIssueIds}))`
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async adjustIssueCommentCount(idOrIdentifier: string, delta: number): Promise<void> {
|
|
393
|
+
await this.sql`
|
|
394
|
+
UPDATE linear_issues
|
|
395
|
+
SET comment_count = GREATEST(0, comment_count + ${delta})
|
|
396
|
+
WHERE id = ${idOrIdentifier} OR identifier = ${idOrIdentifier}
|
|
397
|
+
`
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async getCachedColumns(): Promise<LinearStateRow[]> {
|
|
401
|
+
return this.sql<LinearStateRow[]>`SELECT * FROM linear_states ORDER BY position, name`
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async getCachedBoard(): Promise<BoardView> {
|
|
405
|
+
const columns = await this.getCachedColumns()
|
|
406
|
+
const boardColumns = []
|
|
407
|
+
for (const column of columns) {
|
|
408
|
+
const tasks = (
|
|
409
|
+
await this.sql<LinearIssueRow[]>`
|
|
410
|
+
SELECT * FROM linear_issues
|
|
411
|
+
WHERE state_id = ${column.id}
|
|
412
|
+
ORDER BY updated_at DESC, title ASC
|
|
413
|
+
`
|
|
414
|
+
).map(taskFromRow)
|
|
415
|
+
boardColumns.push({ ...column, tasks })
|
|
416
|
+
}
|
|
417
|
+
return { columns: boardColumns }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async getCachedTask(lookup: string): Promise<Task | null> {
|
|
421
|
+
const normalized = lookup.startsWith('linear:') ? lookup.slice('linear:'.length) : lookup
|
|
422
|
+
const [row] = await this.sql<LinearIssueRow[]>`
|
|
423
|
+
SELECT * FROM linear_issues
|
|
424
|
+
WHERE id = ${normalized} OR identifier = ${normalized}
|
|
425
|
+
LIMIT 1
|
|
426
|
+
`
|
|
427
|
+
return row ? taskFromRow(row) : null
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async getCachedTasks(): Promise<Task[]> {
|
|
431
|
+
return (
|
|
432
|
+
await this.sql<LinearIssueRow[]>`
|
|
433
|
+
SELECT * FROM linear_issues ORDER BY updated_at DESC, title ASC
|
|
434
|
+
`
|
|
435
|
+
).map(taskFromRow)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async getCachedConfig(): Promise<BoardConfig> {
|
|
439
|
+
const members = (
|
|
440
|
+
await this.sql<{ name: string }[]>`
|
|
441
|
+
SELECT name FROM linear_users WHERE active = 1 AND name != '' ORDER BY name
|
|
442
|
+
`
|
|
443
|
+
).map((row) => ({ name: row.name, role: 'human' as const }))
|
|
444
|
+
const projects = (
|
|
445
|
+
await this.sql<{ name: string }[]>`
|
|
446
|
+
SELECT name FROM linear_projects WHERE name != '' ORDER BY name
|
|
447
|
+
`
|
|
448
|
+
).map((row) => row.name)
|
|
449
|
+
return {
|
|
450
|
+
members,
|
|
451
|
+
projects,
|
|
452
|
+
provider: 'linear',
|
|
453
|
+
discoveredAssignees: members.map((member) => member.name),
|
|
454
|
+
discoveredProjects: projects,
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async getCachedActivity(
|
|
459
|
+
params: { issueId?: string; limit?: number } = {},
|
|
460
|
+
): Promise<LinearActivityRow[]> {
|
|
461
|
+
const limit = params.limit ?? 100
|
|
462
|
+
if (params.issueId) {
|
|
463
|
+
return this.sql<LinearActivityRow[]>`
|
|
464
|
+
SELECT issue_id, history_id, item_field, from_value, to_value, created_at
|
|
465
|
+
FROM linear_activity
|
|
466
|
+
WHERE issue_id = ${params.issueId}
|
|
467
|
+
ORDER BY created_at DESC
|
|
468
|
+
LIMIT ${limit}
|
|
469
|
+
`
|
|
470
|
+
}
|
|
471
|
+
return this.sql<LinearActivityRow[]>`
|
|
472
|
+
SELECT issue_id, history_id, item_field, from_value, to_value, created_at
|
|
473
|
+
FROM linear_activity
|
|
474
|
+
ORDER BY created_at DESC
|
|
475
|
+
LIMIT ${limit}
|
|
476
|
+
`
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async findUserIdByName(name: string): Promise<string | null> {
|
|
480
|
+
const [row] = await this.sql<{ id: string }[]>`
|
|
481
|
+
SELECT id FROM linear_users WHERE LOWER(name) = LOWER(${name}) LIMIT 1
|
|
482
|
+
`
|
|
483
|
+
return row?.id ?? null
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async findProjectIdByName(name: string): Promise<string | null> {
|
|
487
|
+
const [row] = await this.sql<{ id: string }[]>`
|
|
488
|
+
SELECT id FROM linear_projects WHERE LOWER(name) = LOWER(${name}) LIMIT 1
|
|
489
|
+
`
|
|
490
|
+
return row?.id ?? null
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async resolveIssueId(lookup: string): Promise<string | null> {
|
|
494
|
+
const normalized = lookup.startsWith('linear:') ? lookup.slice('linear:'.length) : lookup
|
|
495
|
+
const [row] = await this.sql<{ id: string }[]>`
|
|
496
|
+
SELECT id FROM linear_issues WHERE id = ${normalized} OR identifier = ${normalized} LIMIT 1
|
|
497
|
+
`
|
|
498
|
+
return row?.id ?? null
|
|
499
|
+
}
|
|
500
|
+
}
|