@andypai/agent-kanban 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -19
- package/package.json +3 -2
- package/src/__tests__/activity.test.ts +12 -0
- package/src/__tests__/api.test.ts +4 -2
- package/src/__tests__/board-utils.test.ts +16 -3
- package/src/__tests__/column-roles.test.ts +34 -0
- package/src/__tests__/commands/board.test.ts +7 -0
- package/src/__tests__/conflict.test.ts +11 -1
- package/src/__tests__/db.test.ts +70 -0
- package/src/__tests__/index.test.ts +55 -0
- package/src/__tests__/jira-cache.test.ts +56 -0
- package/src/__tests__/jira-client.test.ts +98 -1
- package/src/__tests__/jira-jql.test.ts +51 -0
- package/src/__tests__/jira-provider-mutations.test.ts +84 -59
- package/src/__tests__/jira-provider-read.test.ts +227 -20
- package/src/__tests__/mcp-server.test.ts +2 -2
- package/src/__tests__/metrics.test.ts +37 -1
- package/src/__tests__/postgres-jira-provider.test.ts +155 -0
- package/src/__tests__/postgres-local-provider.test.ts +90 -1
- package/src/__tests__/server.test.ts +126 -0
- package/src/__tests__/use-cases.test.ts +77 -0
- package/src/__tests__/webhooks.test.ts +75 -22
- package/src/api.ts +58 -36
- package/src/column-roles.ts +52 -0
- package/src/commands/board.ts +4 -4
- package/src/db.ts +145 -114
- package/src/errors.ts +1 -0
- package/src/index.ts +84 -33
- package/src/mcp/core.ts +8 -7
- package/src/metrics.ts +48 -23
- package/src/provider-runtime.ts +9 -2
- package/src/providers/index.ts +4 -1
- package/src/providers/jira-cache.ts +23 -6
- package/src/providers/jira-client.ts +70 -4
- package/src/providers/jira-core.ts +921 -0
- package/src/providers/jira-jql.ts +48 -0
- package/src/providers/jira.ts +111 -677
- package/src/providers/linear-cache.ts +5 -3
- package/src/providers/linear-core.ts +688 -0
- package/src/providers/linear.ts +70 -554
- package/src/providers/postgres-jira-cache.ts +597 -0
- package/src/providers/postgres-jira.ts +15 -1188
- package/src/providers/postgres-linear-cache.ts +500 -0
- package/src/providers/postgres-linear.ts +22 -1088
- package/src/providers/postgres-local.ts +181 -74
- package/src/providers/types.ts +71 -2
- package/src/server.ts +118 -32
- package/src/use-cases.ts +139 -0
- package/src/webhooks.ts +26 -0
- package/ui/dist/assets/index-65LcV0R7.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-CFhtfqCn.js +0 -40
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
import type { Sql } from 'postgres'
|
|
2
|
+
|
|
3
|
+
import type { BoardView, ProviderTeamInfo, Task } from '../types'
|
|
4
|
+
import {
|
|
5
|
+
decodeColumnStatusIds,
|
|
6
|
+
type JiraActivityRow,
|
|
7
|
+
type JiraCacheConfig,
|
|
8
|
+
type JiraColumnRow,
|
|
9
|
+
type JiraSyncMeta,
|
|
10
|
+
} from './jira-cache'
|
|
11
|
+
import type { JiraCachePort } from './jira-core'
|
|
12
|
+
import { ensureWebhookEventsSchema } from '../webhook-events'
|
|
13
|
+
|
|
14
|
+
export interface JiraIssueRow {
|
|
15
|
+
id: string
|
|
16
|
+
key: string
|
|
17
|
+
summary: string
|
|
18
|
+
description_text: string
|
|
19
|
+
status_id: string
|
|
20
|
+
priority_name: string
|
|
21
|
+
issue_type_name: string
|
|
22
|
+
assignee_account_id: string | null
|
|
23
|
+
assignee_name: string
|
|
24
|
+
labels: string
|
|
25
|
+
comment_count: number
|
|
26
|
+
project_key: string
|
|
27
|
+
url: string | null
|
|
28
|
+
created_at: string
|
|
29
|
+
updated_at: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mapPriorityNameToCanonical(name: string): Task['priority'] {
|
|
33
|
+
switch (name.trim().toLowerCase()) {
|
|
34
|
+
case 'highest':
|
|
35
|
+
return 'urgent'
|
|
36
|
+
case 'high':
|
|
37
|
+
return 'high'
|
|
38
|
+
case 'medium':
|
|
39
|
+
return 'medium'
|
|
40
|
+
default:
|
|
41
|
+
return 'low'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseLabels(raw: string): string[] {
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(raw)
|
|
48
|
+
return Array.isArray(parsed)
|
|
49
|
+
? parsed.filter((value): value is string => typeof value === 'string')
|
|
50
|
+
: []
|
|
51
|
+
} catch {
|
|
52
|
+
return []
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function taskFromRow(row: JiraIssueRow): Task {
|
|
57
|
+
return {
|
|
58
|
+
id: `jira:${row.id}`,
|
|
59
|
+
providerId: row.id,
|
|
60
|
+
externalRef: row.key,
|
|
61
|
+
url: row.url,
|
|
62
|
+
title: row.summary,
|
|
63
|
+
description: row.description_text,
|
|
64
|
+
column_id: row.status_id,
|
|
65
|
+
position: 0,
|
|
66
|
+
priority: mapPriorityNameToCanonical(row.priority_name),
|
|
67
|
+
assignee: row.assignee_name,
|
|
68
|
+
assignees: row.assignee_name ? [row.assignee_name] : [],
|
|
69
|
+
labels: parseLabels(row.labels),
|
|
70
|
+
comment_count: row.comment_count,
|
|
71
|
+
project: row.project_key,
|
|
72
|
+
metadata: '{}',
|
|
73
|
+
created_at: row.created_at,
|
|
74
|
+
updated_at: row.updated_at,
|
|
75
|
+
version: row.updated_at,
|
|
76
|
+
source_updated_at: row.updated_at,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Postgres-backed cache/repository for the Jira provider. Mirrors the role of the
|
|
82
|
+
* SQLite-side `jira-cache.ts` free functions, but as an instance that owns the
|
|
83
|
+
* async `postgres.js` client and its own schema-readiness promise. Holds only
|
|
84
|
+
* cache I/O (persistence + materialization); API sync and business logic stay in
|
|
85
|
+
* `PostgresJiraProvider`.
|
|
86
|
+
*/
|
|
87
|
+
export class PostgresJiraCache implements JiraCachePort {
|
|
88
|
+
readonly ready: Promise<void>
|
|
89
|
+
|
|
90
|
+
constructor(private readonly sql: Sql) {
|
|
91
|
+
this.ready = this.ensureSchema()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async ensureSchema(): Promise<void> {
|
|
95
|
+
await this.sql`
|
|
96
|
+
CREATE TABLE IF NOT EXISTS jira_sync_meta (
|
|
97
|
+
key TEXT PRIMARY KEY,
|
|
98
|
+
value TEXT NOT NULL
|
|
99
|
+
)
|
|
100
|
+
`
|
|
101
|
+
await this.sql`
|
|
102
|
+
CREATE TABLE IF NOT EXISTS jira_columns (
|
|
103
|
+
id TEXT PRIMARY KEY,
|
|
104
|
+
name TEXT NOT NULL,
|
|
105
|
+
position INTEGER NOT NULL,
|
|
106
|
+
status_ids TEXT NOT NULL,
|
|
107
|
+
source TEXT NOT NULL CHECK(source IN ('board','status'))
|
|
108
|
+
)
|
|
109
|
+
`
|
|
110
|
+
await this.sql`
|
|
111
|
+
CREATE TABLE IF NOT EXISTS jira_users (
|
|
112
|
+
account_id TEXT PRIMARY KEY,
|
|
113
|
+
display_name TEXT NOT NULL,
|
|
114
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
115
|
+
updated_at TEXT NOT NULL
|
|
116
|
+
)
|
|
117
|
+
`
|
|
118
|
+
await this.sql`
|
|
119
|
+
CREATE TABLE IF NOT EXISTS jira_priorities (
|
|
120
|
+
id TEXT PRIMARY KEY,
|
|
121
|
+
name TEXT NOT NULL
|
|
122
|
+
)
|
|
123
|
+
`
|
|
124
|
+
await this.sql`
|
|
125
|
+
CREATE TABLE IF NOT EXISTS jira_issue_types (
|
|
126
|
+
id TEXT PRIMARY KEY,
|
|
127
|
+
name TEXT NOT NULL
|
|
128
|
+
)
|
|
129
|
+
`
|
|
130
|
+
await this.sql`
|
|
131
|
+
CREATE TABLE IF NOT EXISTS jira_activity (
|
|
132
|
+
issue_id TEXT NOT NULL,
|
|
133
|
+
history_id TEXT NOT NULL,
|
|
134
|
+
item_field TEXT NOT NULL,
|
|
135
|
+
from_value TEXT,
|
|
136
|
+
to_value TEXT,
|
|
137
|
+
created_at TEXT NOT NULL,
|
|
138
|
+
PRIMARY KEY (issue_id, history_id, item_field)
|
|
139
|
+
)
|
|
140
|
+
`
|
|
141
|
+
await this.sql`
|
|
142
|
+
CREATE TABLE IF NOT EXISTS jira_issues (
|
|
143
|
+
id TEXT PRIMARY KEY,
|
|
144
|
+
key TEXT NOT NULL UNIQUE,
|
|
145
|
+
summary TEXT NOT NULL,
|
|
146
|
+
description_text TEXT NOT NULL DEFAULT '',
|
|
147
|
+
status_id TEXT NOT NULL,
|
|
148
|
+
priority_name TEXT NOT NULL DEFAULT '',
|
|
149
|
+
issue_type_name TEXT NOT NULL DEFAULT '',
|
|
150
|
+
assignee_account_id TEXT,
|
|
151
|
+
assignee_name TEXT NOT NULL DEFAULT '',
|
|
152
|
+
labels TEXT NOT NULL DEFAULT '[]',
|
|
153
|
+
comment_count INTEGER NOT NULL DEFAULT 0,
|
|
154
|
+
project_key TEXT NOT NULL,
|
|
155
|
+
url TEXT,
|
|
156
|
+
created_at TEXT NOT NULL,
|
|
157
|
+
updated_at TEXT NOT NULL
|
|
158
|
+
)
|
|
159
|
+
`
|
|
160
|
+
await this.sql`CREATE INDEX IF NOT EXISTS idx_jira_issues_status_id ON jira_issues(status_id)`
|
|
161
|
+
await this.sql`CREATE INDEX IF NOT EXISTS idx_jira_issues_updated_at ON jira_issues(updated_at)`
|
|
162
|
+
await this.sql`
|
|
163
|
+
CREATE INDEX IF NOT EXISTS jira_activity_created_at_idx ON jira_activity(created_at DESC)
|
|
164
|
+
`
|
|
165
|
+
await ensureWebhookEventsSchema(this.sql)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async setMeta(key: string, value: string): Promise<void> {
|
|
169
|
+
await this.sql`
|
|
170
|
+
INSERT INTO jira_sync_meta (key, value)
|
|
171
|
+
VALUES (${key}, ${value})
|
|
172
|
+
ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value
|
|
173
|
+
`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async deleteMeta(key: string): Promise<void> {
|
|
177
|
+
await this.sql`DELETE FROM jira_sync_meta WHERE key = ${key}`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async getMeta(key: string): Promise<string | null> {
|
|
181
|
+
const [row] = await this.sql<{ value: string }[]>`
|
|
182
|
+
SELECT value FROM jira_sync_meta WHERE key = ${key}
|
|
183
|
+
`
|
|
184
|
+
return row?.value ?? null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async saveSyncMeta(meta: Partial<JiraSyncMeta>): Promise<void> {
|
|
188
|
+
const keys = [
|
|
189
|
+
'projectKey',
|
|
190
|
+
'boardId',
|
|
191
|
+
'lastSyncAt',
|
|
192
|
+
'lastIssueUpdatedAt',
|
|
193
|
+
'lastFullSyncAt',
|
|
194
|
+
'lastWebhookAt',
|
|
195
|
+
] as const
|
|
196
|
+
for (const key of keys) {
|
|
197
|
+
if (!Object.prototype.hasOwnProperty.call(meta, key)) continue
|
|
198
|
+
const value = meta[key]
|
|
199
|
+
if (value === null) {
|
|
200
|
+
await this.deleteMeta(key)
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if (key === 'boardId') {
|
|
204
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
205
|
+
await this.setMeta(key, String(value))
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if (typeof value === 'string') await this.setMeta(key, value)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async loadSyncMeta(): Promise<JiraSyncMeta> {
|
|
213
|
+
const boardIdRaw = await this.getMeta('boardId')
|
|
214
|
+
const boardId = boardIdRaw === null ? null : Number.parseInt(boardIdRaw, 10)
|
|
215
|
+
return {
|
|
216
|
+
projectKey: await this.getMeta('projectKey'),
|
|
217
|
+
boardId: boardId === null || Number.isNaN(boardId) ? null : boardId,
|
|
218
|
+
lastSyncAt: await this.getMeta('lastSyncAt'),
|
|
219
|
+
lastIssueUpdatedAt: await this.getMeta('lastIssueUpdatedAt'),
|
|
220
|
+
lastFullSyncAt: await this.getMeta('lastFullSyncAt'),
|
|
221
|
+
lastWebhookAt: await this.getMeta('lastWebhookAt'),
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async saveTeamInfo(team: ProviderTeamInfo | null): Promise<void> {
|
|
226
|
+
if (team === null) {
|
|
227
|
+
await this.deleteMeta('team')
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
await this.setMeta('team', JSON.stringify(team))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async loadTeamInfo(): Promise<ProviderTeamInfo | null> {
|
|
234
|
+
const raw = await this.getMeta('team')
|
|
235
|
+
if (raw === null) return null
|
|
236
|
+
try {
|
|
237
|
+
const parsed = JSON.parse(raw) as ProviderTeamInfo
|
|
238
|
+
return typeof parsed.id === 'string' &&
|
|
239
|
+
typeof parsed.key === 'string' &&
|
|
240
|
+
typeof parsed.name === 'string'
|
|
241
|
+
? parsed
|
|
242
|
+
: null
|
|
243
|
+
} catch {
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Catalog refreshes (columns, priorities, issue types) UPSERT the current rows
|
|
249
|
+
// on every sync — UPSERT serializes per row and never collides on the primary
|
|
250
|
+
// key when multiple Dispatch replicas refresh concurrently, so a newly-created
|
|
251
|
+
// status/column/priority is reflected on the next sync. The obsolete-row DELETE
|
|
252
|
+
// (`prune`) runs ONLY on a full reconcile, mirroring how upstream-missing issues
|
|
253
|
+
// are pruned (see pruneIssuesMissingUpstream): a delta sync's snapshot can be
|
|
254
|
+
// stale, and a stale snapshot's DELETE would drop a row another replica just
|
|
255
|
+
// added with a fresher snapshot. Confining the delete to the periodic full
|
|
256
|
+
// reconcile (and self-healing via the every-sync UPSERT) keeps catalog pruning
|
|
257
|
+
// consistent with issue pruning and out of the common delta path.
|
|
258
|
+
async replaceColumns(
|
|
259
|
+
columns: Array<{
|
|
260
|
+
id: string
|
|
261
|
+
name: string
|
|
262
|
+
position: number
|
|
263
|
+
statusIds: string[]
|
|
264
|
+
source: 'board' | 'status'
|
|
265
|
+
}>,
|
|
266
|
+
prune: boolean,
|
|
267
|
+
): Promise<void> {
|
|
268
|
+
for (const column of columns) {
|
|
269
|
+
await this.sql`
|
|
270
|
+
INSERT INTO jira_columns (id, name, position, status_ids, source)
|
|
271
|
+
VALUES (
|
|
272
|
+
${column.id},
|
|
273
|
+
${column.name},
|
|
274
|
+
${column.position},
|
|
275
|
+
${JSON.stringify(column.statusIds)},
|
|
276
|
+
${column.source}
|
|
277
|
+
)
|
|
278
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
279
|
+
name = EXCLUDED.name,
|
|
280
|
+
position = EXCLUDED.position,
|
|
281
|
+
status_ids = EXCLUDED.status_ids,
|
|
282
|
+
source = EXCLUDED.source
|
|
283
|
+
`
|
|
284
|
+
}
|
|
285
|
+
if (prune) {
|
|
286
|
+
await this.sql`DELETE FROM jira_columns WHERE NOT (id = ANY(${columns.map((c) => c.id)}))`
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async upsertUsers(
|
|
291
|
+
users: Array<{ accountId: string; displayName: string; active?: boolean }>,
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
for (const user of users) {
|
|
294
|
+
await this.sql`
|
|
295
|
+
INSERT INTO jira_users (account_id, display_name, active, updated_at)
|
|
296
|
+
VALUES (${user.accountId}, ${user.displayName}, ${user.active === false ? 0 : 1}, ${new Date().toISOString()})
|
|
297
|
+
ON CONFLICT(account_id) DO UPDATE SET
|
|
298
|
+
display_name = EXCLUDED.display_name,
|
|
299
|
+
active = EXCLUDED.active,
|
|
300
|
+
updated_at = EXCLUDED.updated_at
|
|
301
|
+
`
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async replacePriorities(
|
|
306
|
+
priorities: Array<{ id: string; name: string }>,
|
|
307
|
+
prune: boolean,
|
|
308
|
+
): Promise<void> {
|
|
309
|
+
for (const priority of priorities) {
|
|
310
|
+
await this.sql`
|
|
311
|
+
INSERT INTO jira_priorities (id, name)
|
|
312
|
+
VALUES (${priority.id}, ${priority.name})
|
|
313
|
+
ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name
|
|
314
|
+
`
|
|
315
|
+
}
|
|
316
|
+
if (prune) {
|
|
317
|
+
await this
|
|
318
|
+
.sql`DELETE FROM jira_priorities WHERE NOT (id = ANY(${priorities.map((p) => p.id)}))`
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async replaceIssueTypes(
|
|
323
|
+
types: Array<{ id: string; name: string }>,
|
|
324
|
+
prune: boolean,
|
|
325
|
+
): Promise<void> {
|
|
326
|
+
for (const type of types) {
|
|
327
|
+
await this.sql`
|
|
328
|
+
INSERT INTO jira_issue_types (id, name)
|
|
329
|
+
VALUES (${type.id}, ${type.name})
|
|
330
|
+
ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name
|
|
331
|
+
`
|
|
332
|
+
}
|
|
333
|
+
if (prune) {
|
|
334
|
+
await this.sql`DELETE FROM jira_issue_types WHERE NOT (id = ANY(${types.map((t) => t.id)}))`
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async upsertIssues(
|
|
339
|
+
issues: Array<{
|
|
340
|
+
id: string
|
|
341
|
+
key: string
|
|
342
|
+
summary: string
|
|
343
|
+
descriptionText: string
|
|
344
|
+
statusId: string
|
|
345
|
+
priorityName?: string | null
|
|
346
|
+
issueTypeName?: string | null
|
|
347
|
+
assigneeAccountId?: string | null
|
|
348
|
+
assigneeName?: string | null
|
|
349
|
+
labels?: string[] | null
|
|
350
|
+
commentCount?: number | null
|
|
351
|
+
projectKey: string
|
|
352
|
+
url?: string | null
|
|
353
|
+
createdAt: string
|
|
354
|
+
updatedAt: string
|
|
355
|
+
}>,
|
|
356
|
+
): Promise<void> {
|
|
357
|
+
if (issues.length === 0) return
|
|
358
|
+
await this.sql.begin(async (tx) => {
|
|
359
|
+
await tx`SELECT pg_advisory_xact_lock(hashtext('agent-kanban:postgres-jira:issues'))`
|
|
360
|
+
for (const issue of issues) {
|
|
361
|
+
await tx`
|
|
362
|
+
INSERT INTO jira_issues (
|
|
363
|
+
id, key, summary, description_text, status_id, priority_name, issue_type_name,
|
|
364
|
+
assignee_account_id, assignee_name, labels, comment_count, project_key, url, created_at, updated_at
|
|
365
|
+
) VALUES (
|
|
366
|
+
${issue.id}, ${issue.key}, ${issue.summary}, ${issue.descriptionText}, ${issue.statusId},
|
|
367
|
+
${issue.priorityName ?? ''}, ${issue.issueTypeName ?? ''}, ${issue.assigneeAccountId ?? null},
|
|
368
|
+
${issue.assigneeName ?? ''}, ${JSON.stringify(issue.labels ?? [])}, ${issue.commentCount ?? 0},
|
|
369
|
+
${issue.projectKey}, ${issue.url ?? null}, ${issue.createdAt}, ${issue.updatedAt}
|
|
370
|
+
)
|
|
371
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
372
|
+
key = EXCLUDED.key,
|
|
373
|
+
summary = EXCLUDED.summary,
|
|
374
|
+
description_text = EXCLUDED.description_text,
|
|
375
|
+
status_id = EXCLUDED.status_id,
|
|
376
|
+
priority_name = EXCLUDED.priority_name,
|
|
377
|
+
issue_type_name = EXCLUDED.issue_type_name,
|
|
378
|
+
assignee_account_id = EXCLUDED.assignee_account_id,
|
|
379
|
+
assignee_name = EXCLUDED.assignee_name,
|
|
380
|
+
labels = EXCLUDED.labels,
|
|
381
|
+
comment_count = EXCLUDED.comment_count,
|
|
382
|
+
project_key = EXCLUDED.project_key,
|
|
383
|
+
url = EXCLUDED.url,
|
|
384
|
+
created_at = EXCLUDED.created_at,
|
|
385
|
+
updated_at = EXCLUDED.updated_at
|
|
386
|
+
`
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async deleteIssue(idOrKey: string): Promise<void> {
|
|
392
|
+
await this.sql`
|
|
393
|
+
DELETE FROM jira_activity
|
|
394
|
+
WHERE issue_id = ${idOrKey}
|
|
395
|
+
OR issue_id IN (SELECT id FROM jira_issues WHERE key = ${idOrKey})
|
|
396
|
+
`
|
|
397
|
+
await this.sql`DELETE FROM jira_issues WHERE id = ${idOrKey} OR key = ${idOrKey}`
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async pruneIssuesMissingUpstream(projectKey: string, upstreamIssueIds: string[]): Promise<void> {
|
|
401
|
+
if (upstreamIssueIds.length === 0) {
|
|
402
|
+
await this.sql`
|
|
403
|
+
DELETE FROM jira_activity
|
|
404
|
+
WHERE issue_id IN (SELECT id FROM jira_issues WHERE project_key = ${projectKey})
|
|
405
|
+
`
|
|
406
|
+
await this.sql`DELETE FROM jira_issues WHERE project_key = ${projectKey}`
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
await this.sql`
|
|
411
|
+
DELETE FROM jira_activity
|
|
412
|
+
WHERE issue_id IN (
|
|
413
|
+
SELECT id FROM jira_issues
|
|
414
|
+
WHERE project_key = ${projectKey}
|
|
415
|
+
AND NOT (id = ANY(${upstreamIssueIds}))
|
|
416
|
+
)
|
|
417
|
+
`
|
|
418
|
+
await this.sql`
|
|
419
|
+
DELETE FROM jira_issues
|
|
420
|
+
WHERE project_key = ${projectKey}
|
|
421
|
+
AND NOT (id = ANY(${upstreamIssueIds}))
|
|
422
|
+
`
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async getColumns(): Promise<JiraColumnRow[]> {
|
|
426
|
+
await this.ready
|
|
427
|
+
return this.sql<JiraColumnRow[]>`SELECT * FROM jira_columns ORDER BY position, name`
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private async selectIssuesByStatusIds(statusIds: string[]): Promise<JiraIssueRow[]> {
|
|
431
|
+
if (statusIds.length === 0) return []
|
|
432
|
+
return this.sql<JiraIssueRow[]>`
|
|
433
|
+
SELECT * FROM jira_issues
|
|
434
|
+
WHERE status_id = ANY(${statusIds})
|
|
435
|
+
ORDER BY updated_at DESC, summary ASC
|
|
436
|
+
`
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async getCachedBoard(): Promise<BoardView> {
|
|
440
|
+
const columns = await this.getColumns()
|
|
441
|
+
const boardColumns = []
|
|
442
|
+
for (const column of columns) {
|
|
443
|
+
const tasks = (await this.selectIssuesByStatusIds(decodeColumnStatusIds(column))).map(
|
|
444
|
+
taskFromRow,
|
|
445
|
+
)
|
|
446
|
+
boardColumns.push({
|
|
447
|
+
id: column.id,
|
|
448
|
+
name: column.name,
|
|
449
|
+
position: column.position,
|
|
450
|
+
color: null,
|
|
451
|
+
created_at: '',
|
|
452
|
+
updated_at: '',
|
|
453
|
+
tasks,
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
return { columns: boardColumns }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async getCachedTask(lookup: string): Promise<Task | null> {
|
|
460
|
+
const normalized = lookup.startsWith('jira:') ? lookup.slice('jira:'.length) : lookup
|
|
461
|
+
const [row] = await this.sql<JiraIssueRow[]>`
|
|
462
|
+
SELECT * FROM jira_issues
|
|
463
|
+
WHERE id = ${normalized} OR key = ${normalized}
|
|
464
|
+
LIMIT 1
|
|
465
|
+
`
|
|
466
|
+
return row ? taskFromRow(row) : null
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async adjustIssueCommentCount(idOrKey: string, delta: number): Promise<void> {
|
|
470
|
+
await this.sql`
|
|
471
|
+
UPDATE jira_issues
|
|
472
|
+
SET comment_count = GREATEST(0, comment_count + ${delta})
|
|
473
|
+
WHERE id = ${idOrKey} OR key = ${idOrKey}
|
|
474
|
+
`
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async getCachedTasks(params?: { columnId?: string }): Promise<Task[]> {
|
|
478
|
+
if (params?.columnId !== undefined) {
|
|
479
|
+
const [columnRow] = await this.sql<Pick<JiraColumnRow, 'status_ids'>[]>`
|
|
480
|
+
SELECT status_ids FROM jira_columns WHERE id = ${params.columnId}
|
|
481
|
+
`
|
|
482
|
+
if (!columnRow) return []
|
|
483
|
+
return (await this.selectIssuesByStatusIds(decodeColumnStatusIds(columnRow))).map(taskFromRow)
|
|
484
|
+
}
|
|
485
|
+
return (
|
|
486
|
+
await this.sql<JiraIssueRow[]>`
|
|
487
|
+
SELECT * FROM jira_issues ORDER BY updated_at DESC, summary ASC
|
|
488
|
+
`
|
|
489
|
+
).map(taskFromRow)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async getCachedConfig(): Promise<JiraCacheConfig> {
|
|
493
|
+
const users = (
|
|
494
|
+
await this.sql<{ account_id: string; display_name: string }[]>`
|
|
495
|
+
SELECT account_id, display_name
|
|
496
|
+
FROM jira_users
|
|
497
|
+
WHERE active = 1
|
|
498
|
+
ORDER BY display_name
|
|
499
|
+
`
|
|
500
|
+
).map((row) => ({ accountId: row.account_id, displayName: row.display_name }))
|
|
501
|
+
const priorities = await this.sql<Array<{ id: string; name: string }>>`
|
|
502
|
+
SELECT id, name FROM jira_priorities ORDER BY name
|
|
503
|
+
`
|
|
504
|
+
const issueTypes = await this.sql<Array<{ id: string; name: string }>>`
|
|
505
|
+
SELECT id, name FROM jira_issue_types ORDER BY name
|
|
506
|
+
`
|
|
507
|
+
return {
|
|
508
|
+
projectKey: await this.getMeta('projectKey'),
|
|
509
|
+
users,
|
|
510
|
+
priorities,
|
|
511
|
+
issueTypes,
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async saveActivity(rows: JiraActivityRow[]): Promise<void> {
|
|
516
|
+
for (const row of rows) {
|
|
517
|
+
await this.sql`
|
|
518
|
+
INSERT INTO jira_activity (issue_id, history_id, item_field, from_value, to_value, created_at)
|
|
519
|
+
VALUES (${row.issue_id}, ${row.history_id}, ${row.item_field}, ${row.from_value}, ${row.to_value}, ${row.created_at})
|
|
520
|
+
ON CONFLICT(issue_id, history_id, item_field) DO NOTHING
|
|
521
|
+
`
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async getCachedActivity(
|
|
526
|
+
params: { issueId?: string; limit?: number } = {},
|
|
527
|
+
): Promise<JiraActivityRow[]> {
|
|
528
|
+
const limit = params.limit ?? 100
|
|
529
|
+
if (params.issueId) {
|
|
530
|
+
return this.sql<JiraActivityRow[]>`
|
|
531
|
+
SELECT issue_id, history_id, item_field, from_value, to_value, created_at
|
|
532
|
+
FROM jira_activity
|
|
533
|
+
WHERE issue_id = ${params.issueId}
|
|
534
|
+
ORDER BY created_at DESC
|
|
535
|
+
LIMIT ${limit}
|
|
536
|
+
`
|
|
537
|
+
}
|
|
538
|
+
return this.sql<JiraActivityRow[]>`
|
|
539
|
+
SELECT issue_id, history_id, item_field, from_value, to_value, created_at
|
|
540
|
+
FROM jira_activity
|
|
541
|
+
ORDER BY created_at DESC
|
|
542
|
+
LIMIT ${limit}
|
|
543
|
+
`
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async getDiscoveredAssignees(): Promise<string[]> {
|
|
547
|
+
return (
|
|
548
|
+
await this.sql<{ assignee_name: string }[]>`
|
|
549
|
+
SELECT DISTINCT assignee_name FROM jira_issues WHERE assignee_name != '' ORDER BY assignee_name
|
|
550
|
+
`
|
|
551
|
+
).map((row) => row.assignee_name)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async findPriorityName(wanted: string): Promise<string | null> {
|
|
555
|
+
const [row] = await this.sql<{ name: string }[]>`
|
|
556
|
+
SELECT name FROM jira_priorities WHERE LOWER(name) = LOWER(${wanted}) LIMIT 1
|
|
557
|
+
`
|
|
558
|
+
return row?.name ?? null
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async getPriorityNames(): Promise<string[]> {
|
|
562
|
+
return (await this.sql<{ name: string }[]>`SELECT name FROM jira_priorities ORDER BY name`).map(
|
|
563
|
+
(row) => row.name,
|
|
564
|
+
)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async findActiveAssigneeAccountId(displayName: string): Promise<string | null> {
|
|
568
|
+
const [row] = await this.sql<{ account_id: string }[]>`
|
|
569
|
+
SELECT account_id
|
|
570
|
+
FROM jira_users
|
|
571
|
+
WHERE active = 1 AND LOWER(display_name) = LOWER(${displayName})
|
|
572
|
+
LIMIT 1
|
|
573
|
+
`
|
|
574
|
+
return row?.account_id ?? null
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async findIssueTypeId(name: string): Promise<string | null> {
|
|
578
|
+
const [row] = await this.sql<{ id: string }[]>`
|
|
579
|
+
SELECT id FROM jira_issue_types WHERE LOWER(name) = LOWER(${name}) LIMIT 1
|
|
580
|
+
`
|
|
581
|
+
return row?.id ?? null
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async getIssueTypeNames(): Promise<string[]> {
|
|
585
|
+
return (
|
|
586
|
+
await this.sql<{ name: string }[]>`SELECT name FROM jira_issue_types ORDER BY name`
|
|
587
|
+
).map((row) => row.name)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async resolveIssueId(lookup: string): Promise<string | null> {
|
|
591
|
+
const normalized = lookup.startsWith('jira:') ? lookup.slice('jira:'.length) : lookup
|
|
592
|
+
const [row] = await this.sql<{ id: string }[]>`
|
|
593
|
+
SELECT id FROM jira_issues WHERE id = ${normalized} OR key = ${normalized} LIMIT 1
|
|
594
|
+
`
|
|
595
|
+
return row?.id ?? null
|
|
596
|
+
}
|
|
597
|
+
}
|