@andypai/agent-kanban 0.2.0 → 0.3.0
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 +89 -22
- package/package.json +4 -2
- package/src/__tests__/activity.test.ts +15 -9
- package/src/__tests__/api.test.ts +96 -0
- package/src/__tests__/board-utils.test.ts +100 -0
- package/src/__tests__/commands/board.test.ts +6 -13
- package/src/__tests__/conflict.test.ts +64 -0
- package/src/__tests__/index.test.ts +233 -56
- package/src/__tests__/jira-adf.test.ts +168 -0
- package/src/__tests__/jira-cache.test.ts +304 -0
- package/src/__tests__/jira-client.test.ts +169 -0
- package/src/__tests__/jira-provider-comment.test.ts +281 -0
- package/src/__tests__/jira-provider-mutations.test.ts +771 -0
- package/src/__tests__/jira-provider-read.test.ts +594 -0
- package/src/__tests__/jira-wiring.test.ts +187 -0
- package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
- package/src/__tests__/linear-provider-comment.test.ts +243 -0
- package/src/__tests__/linear-provider-sync.test.ts +493 -0
- package/src/__tests__/local-provider-comment.test.ts +60 -0
- package/src/__tests__/mcp-core.test.ts +164 -0
- package/src/__tests__/mcp-server.test.ts +252 -0
- package/src/__tests__/server.test.ts +298 -0
- package/src/__tests__/webhooks.test.ts +604 -0
- package/src/activity.ts +1 -11
- package/src/api.ts +154 -19
- package/src/commands/board.ts +1 -11
- package/src/commands/mcp.ts +87 -0
- package/src/db.ts +115 -3
- package/src/errors.ts +2 -0
- package/src/id.ts +1 -1
- package/src/index.ts +72 -18
- package/src/mcp/core.ts +193 -0
- package/src/mcp/errors.ts +109 -0
- package/src/mcp/index.ts +13 -0
- package/src/mcp/server.ts +512 -0
- package/src/mcp/types.ts +72 -0
- package/src/providers/capabilities.ts +15 -0
- package/src/providers/index.ts +31 -1
- package/src/providers/jira-adf.ts +275 -0
- package/src/providers/jira-cache.ts +625 -0
- package/src/providers/jira-client.ts +390 -0
- package/src/providers/jira.ts +778 -0
- package/src/providers/linear-cache.ts +249 -70
- package/src/providers/linear-client.ts +256 -13
- package/src/providers/linear.ts +337 -14
- package/src/providers/local.ts +68 -17
- package/src/providers/types.ts +18 -2
- package/src/server.ts +139 -11
- package/src/tunnel.ts +79 -0
- package/src/types.ts +18 -2
- package/src/webhooks.ts +36 -0
- package/ui/dist/assets/index-DBnoKL_k.css +1 -0
- package/ui/dist/assets/index-qNVJ6clH.js +40 -0
- package/ui/dist/index.html +2 -2
- package/src/__tests__/commands/task.test.ts +0 -144
- package/src/commands/task.ts +0 -117
- package/src/fixtures.ts +0 -128
- package/ui/dist/assets/index-B8f9NB4z.css +0 -1
- package/ui/dist/assets/index-zWp-rB7b.js +0 -40
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import type { BoardView, ProviderTeamInfo, Task } from '../types.ts'
|
|
3
|
+
|
|
4
|
+
// Column ids are prefixed to avoid collisions across sources:
|
|
5
|
+
// - board-sourced columns: 'board:<boardId>:<columnName>'
|
|
6
|
+
// - status-fallback columns: 'status:<statusId>'
|
|
7
|
+
// The provider (T04) picks ONE source per sync, so mixed-source boards
|
|
8
|
+
// do not occur in practice.
|
|
9
|
+
export interface JiraColumnRow {
|
|
10
|
+
id: string
|
|
11
|
+
name: string
|
|
12
|
+
position: number
|
|
13
|
+
status_ids: string
|
|
14
|
+
source: 'board' | 'status'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface JiraSyncMeta {
|
|
18
|
+
projectKey: string | null
|
|
19
|
+
boardId: number | null
|
|
20
|
+
lastSyncAt: string | null
|
|
21
|
+
lastIssueUpdatedAt: string | null
|
|
22
|
+
lastFullSyncAt: string | null
|
|
23
|
+
lastWebhookAt: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface JiraCacheConfig {
|
|
27
|
+
projectKey: string | null
|
|
28
|
+
users: Array<{ accountId: string; displayName: string }>
|
|
29
|
+
priorities: Array<{ id: string; name: string }>
|
|
30
|
+
issueTypes: Array<{ id: string; name: string }>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface JiraIssueRow {
|
|
34
|
+
id: string
|
|
35
|
+
key: string
|
|
36
|
+
summary: string
|
|
37
|
+
description_text: string
|
|
38
|
+
status_id: string
|
|
39
|
+
priority_name: string
|
|
40
|
+
issue_type_name: string
|
|
41
|
+
assignee_account_id: string | null
|
|
42
|
+
assignee_name: string
|
|
43
|
+
labels: string
|
|
44
|
+
comment_count: number
|
|
45
|
+
project_key: string
|
|
46
|
+
url: string | null
|
|
47
|
+
created_at: string
|
|
48
|
+
updated_at: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function initJiraCacheSchema(db: Database): void {
|
|
52
|
+
db.run(`
|
|
53
|
+
CREATE TABLE IF NOT EXISTS jira_sync_meta (
|
|
54
|
+
key TEXT PRIMARY KEY,
|
|
55
|
+
value TEXT NOT NULL
|
|
56
|
+
)
|
|
57
|
+
`)
|
|
58
|
+
db.run(`
|
|
59
|
+
CREATE TABLE IF NOT EXISTS jira_columns (
|
|
60
|
+
id TEXT PRIMARY KEY,
|
|
61
|
+
name TEXT NOT NULL,
|
|
62
|
+
position INTEGER NOT NULL,
|
|
63
|
+
status_ids TEXT NOT NULL,
|
|
64
|
+
source TEXT NOT NULL CHECK(source IN ('board','status'))
|
|
65
|
+
)
|
|
66
|
+
`)
|
|
67
|
+
db.run(`
|
|
68
|
+
CREATE TABLE IF NOT EXISTS jira_users (
|
|
69
|
+
account_id TEXT PRIMARY KEY,
|
|
70
|
+
display_name TEXT NOT NULL,
|
|
71
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
72
|
+
updated_at TEXT NOT NULL
|
|
73
|
+
)
|
|
74
|
+
`)
|
|
75
|
+
db.run(`
|
|
76
|
+
CREATE TABLE IF NOT EXISTS jira_priorities (
|
|
77
|
+
id TEXT PRIMARY KEY,
|
|
78
|
+
name TEXT NOT NULL
|
|
79
|
+
)
|
|
80
|
+
`)
|
|
81
|
+
db.run(`
|
|
82
|
+
CREATE TABLE IF NOT EXISTS jira_issue_types (
|
|
83
|
+
id TEXT PRIMARY KEY,
|
|
84
|
+
name TEXT NOT NULL
|
|
85
|
+
)
|
|
86
|
+
`)
|
|
87
|
+
db.run(`
|
|
88
|
+
CREATE TABLE IF NOT EXISTS jira_activity (
|
|
89
|
+
issue_id TEXT NOT NULL,
|
|
90
|
+
history_id TEXT NOT NULL,
|
|
91
|
+
item_field TEXT NOT NULL,
|
|
92
|
+
from_value TEXT,
|
|
93
|
+
to_value TEXT,
|
|
94
|
+
created_at TEXT NOT NULL,
|
|
95
|
+
PRIMARY KEY (issue_id, history_id, item_field)
|
|
96
|
+
)
|
|
97
|
+
`)
|
|
98
|
+
db.run(`
|
|
99
|
+
CREATE INDEX IF NOT EXISTS jira_activity_created_at_idx ON jira_activity(created_at DESC)
|
|
100
|
+
`)
|
|
101
|
+
db.run(`
|
|
102
|
+
CREATE TABLE IF NOT EXISTS jira_issues (
|
|
103
|
+
id TEXT PRIMARY KEY,
|
|
104
|
+
key TEXT NOT NULL UNIQUE,
|
|
105
|
+
summary TEXT NOT NULL,
|
|
106
|
+
description_text TEXT NOT NULL DEFAULT '',
|
|
107
|
+
status_id TEXT NOT NULL,
|
|
108
|
+
priority_name TEXT NOT NULL DEFAULT '',
|
|
109
|
+
issue_type_name TEXT NOT NULL DEFAULT '',
|
|
110
|
+
assignee_account_id TEXT,
|
|
111
|
+
assignee_name TEXT NOT NULL DEFAULT '',
|
|
112
|
+
labels TEXT NOT NULL DEFAULT '[]',
|
|
113
|
+
comment_count INTEGER NOT NULL DEFAULT 0,
|
|
114
|
+
project_key TEXT NOT NULL,
|
|
115
|
+
url TEXT,
|
|
116
|
+
created_at TEXT NOT NULL,
|
|
117
|
+
updated_at TEXT NOT NULL
|
|
118
|
+
)
|
|
119
|
+
`)
|
|
120
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_jira_issues_status_id ON jira_issues(status_id)')
|
|
121
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_jira_issues_updated_at ON jira_issues(updated_at)')
|
|
122
|
+
migrateJiraCacheSchema(db)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function migrateJiraCacheSchema(db: Database): void {
|
|
126
|
+
const cols = db.query('PRAGMA table_info(jira_issues)').all() as { name: string }[]
|
|
127
|
+
if (!cols.some((c) => c.name === 'labels')) {
|
|
128
|
+
db.run("ALTER TABLE jira_issues ADD COLUMN labels TEXT NOT NULL DEFAULT '[]'")
|
|
129
|
+
}
|
|
130
|
+
if (!cols.some((c) => c.name === 'comment_count')) {
|
|
131
|
+
db.run('ALTER TABLE jira_issues ADD COLUMN comment_count INTEGER NOT NULL DEFAULT 0')
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function setMeta(db: Database, key: string, value: string): void {
|
|
136
|
+
db.query(
|
|
137
|
+
`INSERT INTO jira_sync_meta (key, value) VALUES ($key, $value)
|
|
138
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
139
|
+
).run({ $key: key, $value: value })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function deleteMeta(db: Database, key: string): void {
|
|
143
|
+
db.query('DELETE FROM jira_sync_meta WHERE key = $key').run({ $key: key })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getMeta(db: Database, key: string): string | null {
|
|
147
|
+
const row = db.query('SELECT value FROM jira_sync_meta WHERE key = $key').get({
|
|
148
|
+
$key: key,
|
|
149
|
+
}) as { value: string } | null
|
|
150
|
+
return row?.value ?? null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const META_KEYS = [
|
|
154
|
+
'projectKey',
|
|
155
|
+
'boardId',
|
|
156
|
+
'lastSyncAt',
|
|
157
|
+
'lastIssueUpdatedAt',
|
|
158
|
+
'lastFullSyncAt',
|
|
159
|
+
'lastWebhookAt',
|
|
160
|
+
] as const
|
|
161
|
+
type MetaKey = (typeof META_KEYS)[number]
|
|
162
|
+
|
|
163
|
+
export function saveJiraSyncMeta(db: Database, meta: Partial<JiraSyncMeta>): void {
|
|
164
|
+
for (const key of META_KEYS) {
|
|
165
|
+
if (!Object.prototype.hasOwnProperty.call(meta, key)) continue
|
|
166
|
+
const value = (meta as Record<MetaKey, unknown>)[key]
|
|
167
|
+
if (value === null) {
|
|
168
|
+
deleteMeta(db, key)
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
if (key === 'boardId') {
|
|
172
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
173
|
+
setMeta(db, key, String(value))
|
|
174
|
+
}
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
if (typeof value === 'string') {
|
|
178
|
+
setMeta(db, key, value)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function saveTeamInfo(db: Database, team: ProviderTeamInfo | null): void {
|
|
184
|
+
if (team === null) {
|
|
185
|
+
deleteMeta(db, 'team')
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
setMeta(db, 'team', JSON.stringify(team))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function loadTeamInfo(db: Database): ProviderTeamInfo | null {
|
|
192
|
+
const raw = getMeta(db, 'team')
|
|
193
|
+
if (raw === null) return null
|
|
194
|
+
try {
|
|
195
|
+
const parsed = JSON.parse(raw) as unknown
|
|
196
|
+
if (
|
|
197
|
+
parsed &&
|
|
198
|
+
typeof parsed === 'object' &&
|
|
199
|
+
'id' in parsed &&
|
|
200
|
+
'key' in parsed &&
|
|
201
|
+
'name' in parsed &&
|
|
202
|
+
typeof (parsed as { id: unknown }).id === 'string' &&
|
|
203
|
+
typeof (parsed as { key: unknown }).key === 'string' &&
|
|
204
|
+
typeof (parsed as { name: unknown }).name === 'string'
|
|
205
|
+
) {
|
|
206
|
+
const t = parsed as { id: string; key: string; name: string }
|
|
207
|
+
return { id: t.id, key: t.key, name: t.name }
|
|
208
|
+
}
|
|
209
|
+
return null
|
|
210
|
+
} catch {
|
|
211
|
+
return null
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function loadJiraSyncMeta(db: Database): JiraSyncMeta {
|
|
216
|
+
const boardIdRaw = getMeta(db, 'boardId')
|
|
217
|
+
const boardId = boardIdRaw === null ? null : Number.parseInt(boardIdRaw, 10)
|
|
218
|
+
return {
|
|
219
|
+
projectKey: getMeta(db, 'projectKey'),
|
|
220
|
+
boardId: boardId === null || Number.isNaN(boardId) ? null : boardId,
|
|
221
|
+
lastSyncAt: getMeta(db, 'lastSyncAt'),
|
|
222
|
+
lastIssueUpdatedAt: getMeta(db, 'lastIssueUpdatedAt'),
|
|
223
|
+
lastFullSyncAt: getMeta(db, 'lastFullSyncAt'),
|
|
224
|
+
lastWebhookAt: getMeta(db, 'lastWebhookAt'),
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function replaceJiraColumns(
|
|
229
|
+
db: Database,
|
|
230
|
+
columns: Array<{
|
|
231
|
+
id: string
|
|
232
|
+
name: string
|
|
233
|
+
position: number
|
|
234
|
+
statusIds: string[]
|
|
235
|
+
source: 'board' | 'status'
|
|
236
|
+
}>,
|
|
237
|
+
): void {
|
|
238
|
+
const run = db.transaction(() => {
|
|
239
|
+
db.run('DELETE FROM jira_columns')
|
|
240
|
+
const stmt = db.prepare(
|
|
241
|
+
`INSERT INTO jira_columns (id, name, position, status_ids, source)
|
|
242
|
+
VALUES ($id, $name, $position, $status_ids, $source)`,
|
|
243
|
+
)
|
|
244
|
+
for (const column of columns) {
|
|
245
|
+
stmt.run({
|
|
246
|
+
$id: column.id,
|
|
247
|
+
$name: column.name,
|
|
248
|
+
$position: column.position,
|
|
249
|
+
$status_ids: JSON.stringify(column.statusIds),
|
|
250
|
+
$source: column.source,
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
run()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function upsertJiraUsers(
|
|
258
|
+
db: Database,
|
|
259
|
+
users: Array<{ accountId: string; displayName: string; active?: boolean }>,
|
|
260
|
+
): void {
|
|
261
|
+
const stmt = db.prepare(
|
|
262
|
+
`INSERT INTO jira_users (account_id, display_name, active, updated_at)
|
|
263
|
+
VALUES ($account_id, $display_name, $active, datetime('now'))
|
|
264
|
+
ON CONFLICT(account_id) DO UPDATE SET
|
|
265
|
+
display_name = excluded.display_name,
|
|
266
|
+
active = excluded.active,
|
|
267
|
+
updated_at = excluded.updated_at`,
|
|
268
|
+
)
|
|
269
|
+
for (const user of users) {
|
|
270
|
+
stmt.run({
|
|
271
|
+
$account_id: user.accountId,
|
|
272
|
+
$display_name: user.displayName,
|
|
273
|
+
$active: user.active === false ? 0 : 1,
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function replaceJiraPriorities(
|
|
279
|
+
db: Database,
|
|
280
|
+
priorities: Array<{ id: string; name: string }>,
|
|
281
|
+
): void {
|
|
282
|
+
const run = db.transaction(() => {
|
|
283
|
+
db.run('DELETE FROM jira_priorities')
|
|
284
|
+
const stmt = db.prepare('INSERT INTO jira_priorities (id, name) VALUES ($id, $name)')
|
|
285
|
+
for (const priority of priorities) {
|
|
286
|
+
stmt.run({ $id: priority.id, $name: priority.name })
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
run()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function replaceJiraIssueTypes(
|
|
293
|
+
db: Database,
|
|
294
|
+
types: Array<{ id: string; name: string }>,
|
|
295
|
+
): void {
|
|
296
|
+
const run = db.transaction(() => {
|
|
297
|
+
db.run('DELETE FROM jira_issue_types')
|
|
298
|
+
const stmt = db.prepare('INSERT INTO jira_issue_types (id, name) VALUES ($id, $name)')
|
|
299
|
+
for (const type of types) {
|
|
300
|
+
stmt.run({ $id: type.id, $name: type.name })
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
run()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function upsertJiraIssues(
|
|
307
|
+
db: Database,
|
|
308
|
+
issues: Array<{
|
|
309
|
+
id: string
|
|
310
|
+
key: string
|
|
311
|
+
summary: string
|
|
312
|
+
descriptionText: string
|
|
313
|
+
statusId: string
|
|
314
|
+
priorityName?: string | null
|
|
315
|
+
issueTypeName?: string | null
|
|
316
|
+
assigneeAccountId?: string | null
|
|
317
|
+
assigneeName?: string | null
|
|
318
|
+
labels?: string[] | null
|
|
319
|
+
commentCount?: number | null
|
|
320
|
+
projectKey: string
|
|
321
|
+
url?: string | null
|
|
322
|
+
createdAt: string
|
|
323
|
+
updatedAt: string
|
|
324
|
+
}>,
|
|
325
|
+
): void {
|
|
326
|
+
const stmt = db.prepare(
|
|
327
|
+
`INSERT INTO jira_issues (
|
|
328
|
+
id, key, summary, description_text, status_id, priority_name, issue_type_name,
|
|
329
|
+
assignee_account_id, assignee_name, labels, comment_count, project_key, url, created_at, updated_at
|
|
330
|
+
) VALUES (
|
|
331
|
+
$id, $key, $summary, $description_text, $status_id, $priority_name, $issue_type_name,
|
|
332
|
+
$assignee_account_id, $assignee_name, $labels, $comment_count, $project_key, $url, $created_at, $updated_at
|
|
333
|
+
)
|
|
334
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
335
|
+
key = excluded.key,
|
|
336
|
+
summary = excluded.summary,
|
|
337
|
+
description_text = excluded.description_text,
|
|
338
|
+
status_id = excluded.status_id,
|
|
339
|
+
priority_name = excluded.priority_name,
|
|
340
|
+
issue_type_name = excluded.issue_type_name,
|
|
341
|
+
assignee_account_id = excluded.assignee_account_id,
|
|
342
|
+
assignee_name = excluded.assignee_name,
|
|
343
|
+
labels = excluded.labels,
|
|
344
|
+
comment_count = excluded.comment_count,
|
|
345
|
+
project_key = excluded.project_key,
|
|
346
|
+
url = excluded.url,
|
|
347
|
+
created_at = excluded.created_at,
|
|
348
|
+
updated_at = excluded.updated_at`,
|
|
349
|
+
)
|
|
350
|
+
for (const issue of issues) {
|
|
351
|
+
stmt.run({
|
|
352
|
+
$id: issue.id,
|
|
353
|
+
$key: issue.key,
|
|
354
|
+
$summary: issue.summary,
|
|
355
|
+
$description_text: issue.descriptionText,
|
|
356
|
+
$status_id: issue.statusId,
|
|
357
|
+
$priority_name: issue.priorityName ?? '',
|
|
358
|
+
$issue_type_name: issue.issueTypeName ?? '',
|
|
359
|
+
$assignee_account_id: issue.assigneeAccountId ?? null,
|
|
360
|
+
$assignee_name: issue.assigneeName ?? '',
|
|
361
|
+
$labels: JSON.stringify(issue.labels ?? []),
|
|
362
|
+
$comment_count: issue.commentCount ?? 0,
|
|
363
|
+
$project_key: issue.projectKey,
|
|
364
|
+
$url: issue.url ?? null,
|
|
365
|
+
$created_at: issue.createdAt,
|
|
366
|
+
$updated_at: issue.updatedAt,
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function deleteJiraIssue(db: Database, idOrKey: string): void {
|
|
372
|
+
db.query(
|
|
373
|
+
`DELETE FROM jira_activity
|
|
374
|
+
WHERE issue_id = $value
|
|
375
|
+
OR issue_id IN (SELECT id FROM jira_issues WHERE key = $value)`,
|
|
376
|
+
).run({ $value: idOrKey })
|
|
377
|
+
db.query('DELETE FROM jira_issues WHERE id = $v OR key = $v').run({ $v: idOrKey })
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function pruneJiraIssuesMissingUpstream(
|
|
381
|
+
db: Database,
|
|
382
|
+
projectKey: string,
|
|
383
|
+
upstreamIssueIds: string[],
|
|
384
|
+
): void {
|
|
385
|
+
const run = db.transaction(() => {
|
|
386
|
+
if (upstreamIssueIds.length === 0) {
|
|
387
|
+
db.query(
|
|
388
|
+
`DELETE FROM jira_activity
|
|
389
|
+
WHERE issue_id IN (SELECT id FROM jira_issues WHERE project_key = $projectKey)`,
|
|
390
|
+
).run({ $projectKey: projectKey })
|
|
391
|
+
db.query('DELETE FROM jira_issues WHERE project_key = $projectKey').run({
|
|
392
|
+
$projectKey: projectKey,
|
|
393
|
+
})
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const placeholders = upstreamIssueIds.map((_, index) => `$id${index}`).join(', ')
|
|
398
|
+
const params: Record<string, string> = { $projectKey: projectKey }
|
|
399
|
+
upstreamIssueIds.forEach((issueId, index) => {
|
|
400
|
+
params[`$id${index}`] = issueId
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
db.query(
|
|
404
|
+
`DELETE FROM jira_activity
|
|
405
|
+
WHERE issue_id IN (
|
|
406
|
+
SELECT id
|
|
407
|
+
FROM jira_issues
|
|
408
|
+
WHERE project_key = $projectKey
|
|
409
|
+
AND id NOT IN (${placeholders})
|
|
410
|
+
)`,
|
|
411
|
+
).run(params)
|
|
412
|
+
db.query(
|
|
413
|
+
`DELETE FROM jira_issues
|
|
414
|
+
WHERE project_key = $projectKey
|
|
415
|
+
AND id NOT IN (${placeholders})`,
|
|
416
|
+
).run(params)
|
|
417
|
+
})
|
|
418
|
+
run()
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function adjustJiraIssueCommentCount(db: Database, idOrKey: string, delta: number): void {
|
|
422
|
+
db.query(
|
|
423
|
+
`UPDATE jira_issues
|
|
424
|
+
SET comment_count = MAX(0, comment_count + $delta)
|
|
425
|
+
WHERE id = $value OR key = $value`,
|
|
426
|
+
).run({
|
|
427
|
+
$delta: delta,
|
|
428
|
+
$value: idOrKey,
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function decodeColumnStatusIds(row: Pick<JiraColumnRow, 'status_ids'>): string[] {
|
|
433
|
+
try {
|
|
434
|
+
const parsed: unknown = JSON.parse(row.status_ids)
|
|
435
|
+
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
|
|
436
|
+
} catch {
|
|
437
|
+
return []
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function getCachedColumns(db: Database): JiraColumnRow[] {
|
|
442
|
+
return db.query('SELECT * FROM jira_columns ORDER BY position, name').all() as JiraColumnRow[]
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function mapPriorityNameToCanonical(name: string): Task['priority'] {
|
|
446
|
+
switch (name.trim().toLowerCase()) {
|
|
447
|
+
case 'highest':
|
|
448
|
+
return 'urgent'
|
|
449
|
+
case 'high':
|
|
450
|
+
return 'high'
|
|
451
|
+
case 'medium':
|
|
452
|
+
return 'medium'
|
|
453
|
+
default:
|
|
454
|
+
return 'low'
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function parseLabels(raw: string): string[] {
|
|
459
|
+
try {
|
|
460
|
+
const parsed: unknown = JSON.parse(raw)
|
|
461
|
+
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
|
|
462
|
+
} catch {
|
|
463
|
+
return []
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function taskFromRow(row: JiraIssueRow): Task {
|
|
468
|
+
return {
|
|
469
|
+
id: `jira:${row.id}`,
|
|
470
|
+
providerId: row.id,
|
|
471
|
+
externalRef: row.key,
|
|
472
|
+
url: row.url,
|
|
473
|
+
title: row.summary,
|
|
474
|
+
description: row.description_text,
|
|
475
|
+
column_id: row.status_id,
|
|
476
|
+
position: 0,
|
|
477
|
+
priority: mapPriorityNameToCanonical(row.priority_name),
|
|
478
|
+
assignee: row.assignee_name,
|
|
479
|
+
assignees: row.assignee_name ? [row.assignee_name] : [],
|
|
480
|
+
labels: parseLabels(row.labels),
|
|
481
|
+
comment_count: row.comment_count,
|
|
482
|
+
project: row.project_key,
|
|
483
|
+
metadata: '{}',
|
|
484
|
+
created_at: row.created_at,
|
|
485
|
+
updated_at: row.updated_at,
|
|
486
|
+
version: row.updated_at,
|
|
487
|
+
source_updated_at: row.updated_at,
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function selectIssuesByStatusIds(db: Database, statusIds: string[]): JiraIssueRow[] {
|
|
492
|
+
if (statusIds.length === 0) return []
|
|
493
|
+
const placeholders = statusIds.map((_, i) => `$s${i}`).join(', ')
|
|
494
|
+
const params: Record<string, string> = {}
|
|
495
|
+
statusIds.forEach((id, i) => {
|
|
496
|
+
params[`$s${i}`] = id
|
|
497
|
+
})
|
|
498
|
+
return db
|
|
499
|
+
.query(
|
|
500
|
+
`SELECT * FROM jira_issues
|
|
501
|
+
WHERE status_id IN (${placeholders})
|
|
502
|
+
ORDER BY updated_at DESC, summary ASC`,
|
|
503
|
+
)
|
|
504
|
+
.all(params) as JiraIssueRow[]
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function getCachedBoard(db: Database): BoardView {
|
|
508
|
+
const columns = getCachedColumns(db)
|
|
509
|
+
return {
|
|
510
|
+
columns: columns.map((column) => {
|
|
511
|
+
const statusIds = decodeColumnStatusIds(column)
|
|
512
|
+
const tasks = selectIssuesByStatusIds(db, statusIds).map(taskFromRow)
|
|
513
|
+
return {
|
|
514
|
+
id: column.id,
|
|
515
|
+
name: column.name,
|
|
516
|
+
position: column.position,
|
|
517
|
+
color: null,
|
|
518
|
+
created_at: '',
|
|
519
|
+
updated_at: '',
|
|
520
|
+
tasks,
|
|
521
|
+
}
|
|
522
|
+
}),
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export function getCachedTask(db: Database, lookup: string): Task | null {
|
|
527
|
+
const normalized = lookup.startsWith('jira:') ? lookup.slice('jira:'.length) : lookup
|
|
528
|
+
const row = db
|
|
529
|
+
.query(
|
|
530
|
+
`SELECT * FROM jira_issues
|
|
531
|
+
WHERE id = $lookup OR key = $lookup
|
|
532
|
+
LIMIT 1`,
|
|
533
|
+
)
|
|
534
|
+
.get({ $lookup: normalized }) as JiraIssueRow | null
|
|
535
|
+
return row ? taskFromRow(row) : null
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function getCachedTasks(db: Database, params?: { columnId?: string }): Task[] {
|
|
539
|
+
if (params?.columnId !== undefined) {
|
|
540
|
+
const columnRow = db
|
|
541
|
+
.query('SELECT status_ids FROM jira_columns WHERE id = $id')
|
|
542
|
+
.get({ $id: params.columnId }) as Pick<JiraColumnRow, 'status_ids'> | null
|
|
543
|
+
if (!columnRow) return []
|
|
544
|
+
const statusIds = decodeColumnStatusIds(columnRow)
|
|
545
|
+
return selectIssuesByStatusIds(db, statusIds).map(taskFromRow)
|
|
546
|
+
}
|
|
547
|
+
return (
|
|
548
|
+
db
|
|
549
|
+
.query('SELECT * FROM jira_issues ORDER BY updated_at DESC, summary ASC')
|
|
550
|
+
.all() as JiraIssueRow[]
|
|
551
|
+
).map(taskFromRow)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function getCachedConfig(db: Database): JiraCacheConfig {
|
|
555
|
+
const users = (
|
|
556
|
+
db
|
|
557
|
+
.query(
|
|
558
|
+
'SELECT account_id, display_name FROM jira_users WHERE active = 1 ORDER BY display_name',
|
|
559
|
+
)
|
|
560
|
+
.all() as { account_id: string; display_name: string }[]
|
|
561
|
+
).map((row) => ({ accountId: row.account_id, displayName: row.display_name }))
|
|
562
|
+
const priorities = db.query('SELECT id, name FROM jira_priorities ORDER BY name').all() as Array<{
|
|
563
|
+
id: string
|
|
564
|
+
name: string
|
|
565
|
+
}>
|
|
566
|
+
const issueTypes = db
|
|
567
|
+
.query('SELECT id, name FROM jira_issue_types ORDER BY name')
|
|
568
|
+
.all() as Array<{ id: string; name: string }>
|
|
569
|
+
return {
|
|
570
|
+
projectKey: getMeta(db, 'projectKey'),
|
|
571
|
+
users,
|
|
572
|
+
priorities,
|
|
573
|
+
issueTypes,
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export interface JiraActivityRow {
|
|
578
|
+
issue_id: string
|
|
579
|
+
history_id: string
|
|
580
|
+
item_field: string
|
|
581
|
+
from_value: string | null
|
|
582
|
+
to_value: string | null
|
|
583
|
+
created_at: string
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export function saveJiraActivity(db: Database, rows: JiraActivityRow[]): void {
|
|
587
|
+
if (rows.length === 0) return
|
|
588
|
+
const stmt = db.prepare(
|
|
589
|
+
`INSERT OR IGNORE INTO jira_activity
|
|
590
|
+
(issue_id, history_id, item_field, from_value, to_value, created_at)
|
|
591
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
592
|
+
)
|
|
593
|
+
const tx = db.transaction((items: JiraActivityRow[]) => {
|
|
594
|
+
for (const r of items) {
|
|
595
|
+
stmt.run(r.issue_id, r.history_id, r.item_field, r.from_value, r.to_value, r.created_at)
|
|
596
|
+
}
|
|
597
|
+
})
|
|
598
|
+
tx(rows)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function getCachedActivity(
|
|
602
|
+
db: Database,
|
|
603
|
+
params: { issueId?: string; limit?: number } = {},
|
|
604
|
+
): JiraActivityRow[] {
|
|
605
|
+
const limit = params.limit ?? 100
|
|
606
|
+
if (params.issueId) {
|
|
607
|
+
return db
|
|
608
|
+
.query(
|
|
609
|
+
`SELECT issue_id, history_id, item_field, from_value, to_value, created_at
|
|
610
|
+
FROM jira_activity
|
|
611
|
+
WHERE issue_id = $issueId
|
|
612
|
+
ORDER BY created_at DESC
|
|
613
|
+
LIMIT $limit`,
|
|
614
|
+
)
|
|
615
|
+
.all({ $issueId: params.issueId, $limit: limit }) as JiraActivityRow[]
|
|
616
|
+
}
|
|
617
|
+
return db
|
|
618
|
+
.query(
|
|
619
|
+
`SELECT issue_id, history_id, item_field, from_value, to_value, created_at
|
|
620
|
+
FROM jira_activity
|
|
621
|
+
ORDER BY created_at DESC
|
|
622
|
+
LIMIT $limit`,
|
|
623
|
+
)
|
|
624
|
+
.all({ $limit: limit }) as JiraActivityRow[]
|
|
625
|
+
}
|