@andypai/agent-kanban 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -17
- package/package.json +3 -2
- package/src/__tests__/api.test.ts +3 -3
- package/src/__tests__/index.test.ts +22 -2
- package/src/__tests__/jira-provider-read.test.ts +22 -0
- package/src/__tests__/jira-wiring.test.ts +47 -17
- package/src/__tests__/linear-provider-sync.test.ts +77 -0
- package/src/__tests__/postgres-jira-provider.test.ts +315 -0
- package/src/__tests__/postgres-linear-provider.test.ts +309 -0
- package/src/__tests__/postgres-local-provider.test.ts +129 -0
- package/src/__tests__/storage-config.test.ts +39 -0
- package/src/__tests__/sync-config.test.ts +32 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +65 -28
- package/src/mcp/errors.ts +1 -0
- package/src/provider-runtime.ts +110 -0
- package/src/providers/index.ts +16 -37
- package/src/providers/jira.ts +5 -2
- package/src/providers/linear.ts +3 -2
- package/src/providers/postgres-jira.ts +1188 -0
- package/src/providers/postgres-linear.ts +1088 -0
- package/src/providers/postgres-local.ts +611 -0
- package/src/server.ts +2 -3
- package/src/storage-config.ts +41 -0
- package/src/sync-config.ts +21 -0
- package/src/tracker-config.ts +104 -0
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
import type { Sql } from 'postgres'
|
|
2
|
+
|
|
3
|
+
import { ErrorCode, KanbanError } from '../errors'
|
|
4
|
+
import type {
|
|
5
|
+
ActivityEntry,
|
|
6
|
+
BoardBootstrap,
|
|
7
|
+
BoardConfig,
|
|
8
|
+
BoardMetrics,
|
|
9
|
+
BoardView,
|
|
10
|
+
Column,
|
|
11
|
+
Priority,
|
|
12
|
+
ProviderTeamInfo,
|
|
13
|
+
Task,
|
|
14
|
+
TaskComment,
|
|
15
|
+
} from '../types'
|
|
16
|
+
import { JIRA_CAPABILITIES } from './capabilities'
|
|
17
|
+
import { decodeColumnStatusIds, type JiraActivityRow, type JiraColumnRow } from './jira-cache'
|
|
18
|
+
import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
|
|
19
|
+
import { JiraClient, type JiraComment, type JiraIssue } from './jira-client'
|
|
20
|
+
import type { JiraProviderConfig } from './jira'
|
|
21
|
+
import { providerUpstreamError, unsupportedOperation } from './errors'
|
|
22
|
+
import type {
|
|
23
|
+
CreateTaskInput,
|
|
24
|
+
KanbanProvider,
|
|
25
|
+
ProviderContext,
|
|
26
|
+
ProviderSyncStatus,
|
|
27
|
+
TaskListFilters,
|
|
28
|
+
UpdateTaskInput,
|
|
29
|
+
} from './types'
|
|
30
|
+
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
|
|
31
|
+
import { headerLower, verifyHmacSha256, type WebhookRequest, type WebhookResult } from '../webhooks'
|
|
32
|
+
|
|
33
|
+
const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
|
|
34
|
+
|
|
35
|
+
function shouldRunFullReconcile(lastFullSyncAt: string | null, now: number): boolean {
|
|
36
|
+
if (!lastFullSyncAt) return true
|
|
37
|
+
const lastFullSyncAtMs = Date.parse(lastFullSyncAt)
|
|
38
|
+
if (!Number.isFinite(lastFullSyncAtMs)) return true
|
|
39
|
+
return now - lastFullSyncAtMs >= FULL_RECONCILE_INTERVAL_MS
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const CANONICAL_TO_JIRA_DEFAULT: Record<Priority, string> = {
|
|
43
|
+
urgent: 'Highest',
|
|
44
|
+
high: 'High',
|
|
45
|
+
medium: 'Medium',
|
|
46
|
+
low: 'Low',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface JiraIssueRow {
|
|
50
|
+
id: string
|
|
51
|
+
key: string
|
|
52
|
+
summary: string
|
|
53
|
+
description_text: string
|
|
54
|
+
status_id: string
|
|
55
|
+
priority_name: string
|
|
56
|
+
issue_type_name: string
|
|
57
|
+
assignee_account_id: string | null
|
|
58
|
+
assignee_name: string
|
|
59
|
+
labels: string
|
|
60
|
+
comment_count: number
|
|
61
|
+
project_key: string
|
|
62
|
+
url: string | null
|
|
63
|
+
created_at: string
|
|
64
|
+
updated_at: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface JiraSyncMeta {
|
|
68
|
+
projectKey: string | null
|
|
69
|
+
boardId: number | null
|
|
70
|
+
lastSyncAt: string | null
|
|
71
|
+
lastIssueUpdatedAt: string | null
|
|
72
|
+
lastFullSyncAt: string | null
|
|
73
|
+
lastWebhookAt: string | null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface JiraCacheConfig {
|
|
77
|
+
projectKey: string | null
|
|
78
|
+
users: Array<{ accountId: string; displayName: string }>
|
|
79
|
+
priorities: Array<{ id: string; name: string }>
|
|
80
|
+
issueTypes: Array<{ id: string; name: string }>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function mapPriorityNameToCanonical(name: string): Task['priority'] {
|
|
84
|
+
switch (name.trim().toLowerCase()) {
|
|
85
|
+
case 'highest':
|
|
86
|
+
return 'urgent'
|
|
87
|
+
case 'high':
|
|
88
|
+
return 'high'
|
|
89
|
+
case 'medium':
|
|
90
|
+
return 'medium'
|
|
91
|
+
default:
|
|
92
|
+
return 'low'
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseLabels(raw: string): string[] {
|
|
97
|
+
try {
|
|
98
|
+
const parsed: unknown = JSON.parse(raw)
|
|
99
|
+
return Array.isArray(parsed)
|
|
100
|
+
? parsed.filter((value): value is string => typeof value === 'string')
|
|
101
|
+
: []
|
|
102
|
+
} catch {
|
|
103
|
+
return []
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function taskFromRow(row: JiraIssueRow): Task {
|
|
108
|
+
return {
|
|
109
|
+
id: `jira:${row.id}`,
|
|
110
|
+
providerId: row.id,
|
|
111
|
+
externalRef: row.key,
|
|
112
|
+
url: row.url,
|
|
113
|
+
title: row.summary,
|
|
114
|
+
description: row.description_text,
|
|
115
|
+
column_id: row.status_id,
|
|
116
|
+
position: 0,
|
|
117
|
+
priority: mapPriorityNameToCanonical(row.priority_name),
|
|
118
|
+
assignee: row.assignee_name,
|
|
119
|
+
assignees: row.assignee_name ? [row.assignee_name] : [],
|
|
120
|
+
labels: parseLabels(row.labels),
|
|
121
|
+
comment_count: row.comment_count,
|
|
122
|
+
project: row.project_key,
|
|
123
|
+
metadata: '{}',
|
|
124
|
+
created_at: row.created_at,
|
|
125
|
+
updated_at: row.updated_at,
|
|
126
|
+
version: row.updated_at,
|
|
127
|
+
source_updated_at: row.updated_at,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class PostgresJiraProvider implements KanbanProvider {
|
|
132
|
+
readonly type = 'jira' as const
|
|
133
|
+
private readonly ready: Promise<void>
|
|
134
|
+
private readonly client: JiraClient
|
|
135
|
+
private readonly pollingSyncIntervalMs: number
|
|
136
|
+
|
|
137
|
+
constructor(
|
|
138
|
+
private readonly sql: Sql,
|
|
139
|
+
private readonly config: JiraProviderConfig,
|
|
140
|
+
client?: JiraClient,
|
|
141
|
+
) {
|
|
142
|
+
this.ready = this.ensureSchema()
|
|
143
|
+
this.pollingSyncIntervalMs = config.pollingSyncIntervalMs ?? DEFAULT_POLLING_SYNC_INTERVAL_MS
|
|
144
|
+
this.client =
|
|
145
|
+
client ??
|
|
146
|
+
new JiraClient({
|
|
147
|
+
baseUrl: config.baseUrl,
|
|
148
|
+
email: config.email,
|
|
149
|
+
apiToken: config.apiToken,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async initialize(): Promise<void> {
|
|
154
|
+
await this.ready
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async ensureSchema(): Promise<void> {
|
|
158
|
+
await this.sql`
|
|
159
|
+
CREATE TABLE IF NOT EXISTS jira_sync_meta (
|
|
160
|
+
key TEXT PRIMARY KEY,
|
|
161
|
+
value TEXT NOT NULL
|
|
162
|
+
)
|
|
163
|
+
`
|
|
164
|
+
await this.sql`
|
|
165
|
+
CREATE TABLE IF NOT EXISTS jira_columns (
|
|
166
|
+
id TEXT PRIMARY KEY,
|
|
167
|
+
name TEXT NOT NULL,
|
|
168
|
+
position INTEGER NOT NULL,
|
|
169
|
+
status_ids TEXT NOT NULL,
|
|
170
|
+
source TEXT NOT NULL CHECK(source IN ('board','status'))
|
|
171
|
+
)
|
|
172
|
+
`
|
|
173
|
+
await this.sql`
|
|
174
|
+
CREATE TABLE IF NOT EXISTS jira_users (
|
|
175
|
+
account_id TEXT PRIMARY KEY,
|
|
176
|
+
display_name TEXT NOT NULL,
|
|
177
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
178
|
+
updated_at TEXT NOT NULL
|
|
179
|
+
)
|
|
180
|
+
`
|
|
181
|
+
await this.sql`
|
|
182
|
+
CREATE TABLE IF NOT EXISTS jira_priorities (
|
|
183
|
+
id TEXT PRIMARY KEY,
|
|
184
|
+
name TEXT NOT NULL
|
|
185
|
+
)
|
|
186
|
+
`
|
|
187
|
+
await this.sql`
|
|
188
|
+
CREATE TABLE IF NOT EXISTS jira_issue_types (
|
|
189
|
+
id TEXT PRIMARY KEY,
|
|
190
|
+
name TEXT NOT NULL
|
|
191
|
+
)
|
|
192
|
+
`
|
|
193
|
+
await this.sql`
|
|
194
|
+
CREATE TABLE IF NOT EXISTS jira_activity (
|
|
195
|
+
issue_id TEXT NOT NULL,
|
|
196
|
+
history_id TEXT NOT NULL,
|
|
197
|
+
item_field TEXT NOT NULL,
|
|
198
|
+
from_value TEXT,
|
|
199
|
+
to_value TEXT,
|
|
200
|
+
created_at TEXT NOT NULL,
|
|
201
|
+
PRIMARY KEY (issue_id, history_id, item_field)
|
|
202
|
+
)
|
|
203
|
+
`
|
|
204
|
+
await this.sql`
|
|
205
|
+
CREATE TABLE IF NOT EXISTS jira_issues (
|
|
206
|
+
id TEXT PRIMARY KEY,
|
|
207
|
+
key TEXT NOT NULL UNIQUE,
|
|
208
|
+
summary TEXT NOT NULL,
|
|
209
|
+
description_text TEXT NOT NULL DEFAULT '',
|
|
210
|
+
status_id TEXT NOT NULL,
|
|
211
|
+
priority_name TEXT NOT NULL DEFAULT '',
|
|
212
|
+
issue_type_name TEXT NOT NULL DEFAULT '',
|
|
213
|
+
assignee_account_id TEXT,
|
|
214
|
+
assignee_name TEXT NOT NULL DEFAULT '',
|
|
215
|
+
labels TEXT NOT NULL DEFAULT '[]',
|
|
216
|
+
comment_count INTEGER NOT NULL DEFAULT 0,
|
|
217
|
+
project_key TEXT NOT NULL,
|
|
218
|
+
url TEXT,
|
|
219
|
+
created_at TEXT NOT NULL,
|
|
220
|
+
updated_at TEXT NOT NULL
|
|
221
|
+
)
|
|
222
|
+
`
|
|
223
|
+
await this.sql`CREATE INDEX IF NOT EXISTS idx_jira_issues_status_id ON jira_issues(status_id)`
|
|
224
|
+
await this.sql`CREATE INDEX IF NOT EXISTS idx_jira_issues_updated_at ON jira_issues(updated_at)`
|
|
225
|
+
await this.sql`
|
|
226
|
+
CREATE INDEX IF NOT EXISTS jira_activity_created_at_idx ON jira_activity(created_at DESC)
|
|
227
|
+
`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private async setMeta(key: string, value: string): Promise<void> {
|
|
231
|
+
await this.sql`
|
|
232
|
+
INSERT INTO jira_sync_meta (key, value)
|
|
233
|
+
VALUES (${key}, ${value})
|
|
234
|
+
ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value
|
|
235
|
+
`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async deleteMeta(key: string): Promise<void> {
|
|
239
|
+
await this.sql`DELETE FROM jira_sync_meta WHERE key = ${key}`
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async getMeta(key: string): Promise<string | null> {
|
|
243
|
+
const [row] = await this.sql<{ value: string }[]>`
|
|
244
|
+
SELECT value FROM jira_sync_meta WHERE key = ${key}
|
|
245
|
+
`
|
|
246
|
+
return row?.value ?? null
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async saveSyncMeta(meta: Partial<JiraSyncMeta>): Promise<void> {
|
|
250
|
+
const keys = [
|
|
251
|
+
'projectKey',
|
|
252
|
+
'boardId',
|
|
253
|
+
'lastSyncAt',
|
|
254
|
+
'lastIssueUpdatedAt',
|
|
255
|
+
'lastFullSyncAt',
|
|
256
|
+
'lastWebhookAt',
|
|
257
|
+
] as const
|
|
258
|
+
for (const key of keys) {
|
|
259
|
+
if (!Object.prototype.hasOwnProperty.call(meta, key)) continue
|
|
260
|
+
const value = meta[key]
|
|
261
|
+
if (value === null) {
|
|
262
|
+
await this.deleteMeta(key)
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
if (key === 'boardId') {
|
|
266
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
267
|
+
await this.setMeta(key, String(value))
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
if (typeof value === 'string') await this.setMeta(key, value)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async loadSyncMeta(): Promise<JiraSyncMeta> {
|
|
275
|
+
const boardIdRaw = await this.getMeta('boardId')
|
|
276
|
+
const boardId = boardIdRaw === null ? null : Number.parseInt(boardIdRaw, 10)
|
|
277
|
+
return {
|
|
278
|
+
projectKey: await this.getMeta('projectKey'),
|
|
279
|
+
boardId: boardId === null || Number.isNaN(boardId) ? null : boardId,
|
|
280
|
+
lastSyncAt: await this.getMeta('lastSyncAt'),
|
|
281
|
+
lastIssueUpdatedAt: await this.getMeta('lastIssueUpdatedAt'),
|
|
282
|
+
lastFullSyncAt: await this.getMeta('lastFullSyncAt'),
|
|
283
|
+
lastWebhookAt: await this.getMeta('lastWebhookAt'),
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private async saveTeamInfo(team: ProviderTeamInfo | null): Promise<void> {
|
|
288
|
+
if (team === null) {
|
|
289
|
+
await this.deleteMeta('team')
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
await this.setMeta('team', JSON.stringify(team))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async loadTeamInfo(): Promise<ProviderTeamInfo | null> {
|
|
296
|
+
const raw = await this.getMeta('team')
|
|
297
|
+
if (raw === null) return null
|
|
298
|
+
try {
|
|
299
|
+
const parsed = JSON.parse(raw) as ProviderTeamInfo
|
|
300
|
+
return typeof parsed.id === 'string' &&
|
|
301
|
+
typeof parsed.key === 'string' &&
|
|
302
|
+
typeof parsed.name === 'string'
|
|
303
|
+
? parsed
|
|
304
|
+
: null
|
|
305
|
+
} catch {
|
|
306
|
+
return null
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private async replaceColumns(
|
|
311
|
+
columns: Array<{
|
|
312
|
+
id: string
|
|
313
|
+
name: string
|
|
314
|
+
position: number
|
|
315
|
+
statusIds: string[]
|
|
316
|
+
source: 'board' | 'status'
|
|
317
|
+
}>,
|
|
318
|
+
): Promise<void> {
|
|
319
|
+
await this.sql.begin(async (tx) => {
|
|
320
|
+
await tx`DELETE FROM jira_columns`
|
|
321
|
+
for (const column of columns) {
|
|
322
|
+
await tx`
|
|
323
|
+
INSERT INTO jira_columns (id, name, position, status_ids, source)
|
|
324
|
+
VALUES (
|
|
325
|
+
${column.id},
|
|
326
|
+
${column.name},
|
|
327
|
+
${column.position},
|
|
328
|
+
${JSON.stringify(column.statusIds)},
|
|
329
|
+
${column.source}
|
|
330
|
+
)
|
|
331
|
+
`
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private async upsertUsers(
|
|
337
|
+
users: Array<{ accountId: string; displayName: string; active?: boolean }>,
|
|
338
|
+
): Promise<void> {
|
|
339
|
+
for (const user of users) {
|
|
340
|
+
await this.sql`
|
|
341
|
+
INSERT INTO jira_users (account_id, display_name, active, updated_at)
|
|
342
|
+
VALUES (${user.accountId}, ${user.displayName}, ${user.active === false ? 0 : 1}, ${new Date().toISOString()})
|
|
343
|
+
ON CONFLICT(account_id) DO UPDATE SET
|
|
344
|
+
display_name = EXCLUDED.display_name,
|
|
345
|
+
active = EXCLUDED.active,
|
|
346
|
+
updated_at = EXCLUDED.updated_at
|
|
347
|
+
`
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private async replacePriorities(priorities: Array<{ id: string; name: string }>): Promise<void> {
|
|
352
|
+
await this.sql.begin(async (tx) => {
|
|
353
|
+
await tx`DELETE FROM jira_priorities`
|
|
354
|
+
for (const priority of priorities) {
|
|
355
|
+
await tx`
|
|
356
|
+
INSERT INTO jira_priorities (id, name)
|
|
357
|
+
VALUES (${priority.id}, ${priority.name})
|
|
358
|
+
`
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private async replaceIssueTypes(types: Array<{ id: string; name: string }>): Promise<void> {
|
|
364
|
+
await this.sql.begin(async (tx) => {
|
|
365
|
+
await tx`DELETE FROM jira_issue_types`
|
|
366
|
+
for (const type of types) {
|
|
367
|
+
await tx`
|
|
368
|
+
INSERT INTO jira_issue_types (id, name)
|
|
369
|
+
VALUES (${type.id}, ${type.name})
|
|
370
|
+
`
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private async upsertIssues(
|
|
376
|
+
issues: Array<{
|
|
377
|
+
id: string
|
|
378
|
+
key: string
|
|
379
|
+
summary: string
|
|
380
|
+
descriptionText: string
|
|
381
|
+
statusId: string
|
|
382
|
+
priorityName?: string | null
|
|
383
|
+
issueTypeName?: string | null
|
|
384
|
+
assigneeAccountId?: string | null
|
|
385
|
+
assigneeName?: string | null
|
|
386
|
+
labels?: string[] | null
|
|
387
|
+
commentCount?: number | null
|
|
388
|
+
projectKey: string
|
|
389
|
+
url?: string | null
|
|
390
|
+
createdAt: string
|
|
391
|
+
updatedAt: string
|
|
392
|
+
}>,
|
|
393
|
+
): Promise<void> {
|
|
394
|
+
for (const issue of issues) {
|
|
395
|
+
await this.sql`
|
|
396
|
+
INSERT INTO jira_issues (
|
|
397
|
+
id, key, summary, description_text, status_id, priority_name, issue_type_name,
|
|
398
|
+
assignee_account_id, assignee_name, labels, comment_count, project_key, url, created_at, updated_at
|
|
399
|
+
) VALUES (
|
|
400
|
+
${issue.id}, ${issue.key}, ${issue.summary}, ${issue.descriptionText}, ${issue.statusId},
|
|
401
|
+
${issue.priorityName ?? ''}, ${issue.issueTypeName ?? ''}, ${issue.assigneeAccountId ?? null},
|
|
402
|
+
${issue.assigneeName ?? ''}, ${JSON.stringify(issue.labels ?? [])}, ${issue.commentCount ?? 0},
|
|
403
|
+
${issue.projectKey}, ${issue.url ?? null}, ${issue.createdAt}, ${issue.updatedAt}
|
|
404
|
+
)
|
|
405
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
406
|
+
key = EXCLUDED.key,
|
|
407
|
+
summary = EXCLUDED.summary,
|
|
408
|
+
description_text = EXCLUDED.description_text,
|
|
409
|
+
status_id = EXCLUDED.status_id,
|
|
410
|
+
priority_name = EXCLUDED.priority_name,
|
|
411
|
+
issue_type_name = EXCLUDED.issue_type_name,
|
|
412
|
+
assignee_account_id = EXCLUDED.assignee_account_id,
|
|
413
|
+
assignee_name = EXCLUDED.assignee_name,
|
|
414
|
+
labels = EXCLUDED.labels,
|
|
415
|
+
comment_count = EXCLUDED.comment_count,
|
|
416
|
+
project_key = EXCLUDED.project_key,
|
|
417
|
+
url = EXCLUDED.url,
|
|
418
|
+
created_at = EXCLUDED.created_at,
|
|
419
|
+
updated_at = EXCLUDED.updated_at
|
|
420
|
+
`
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private async deleteIssue(idOrKey: string): Promise<void> {
|
|
425
|
+
await this.sql`
|
|
426
|
+
DELETE FROM jira_activity
|
|
427
|
+
WHERE issue_id = ${idOrKey}
|
|
428
|
+
OR issue_id IN (SELECT id FROM jira_issues WHERE key = ${idOrKey})
|
|
429
|
+
`
|
|
430
|
+
await this.sql`DELETE FROM jira_issues WHERE id = ${idOrKey} OR key = ${idOrKey}`
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private async pruneIssuesMissingUpstream(
|
|
434
|
+
projectKey: string,
|
|
435
|
+
upstreamIssueIds: string[],
|
|
436
|
+
): Promise<void> {
|
|
437
|
+
if (upstreamIssueIds.length === 0) {
|
|
438
|
+
await this.sql`
|
|
439
|
+
DELETE FROM jira_activity
|
|
440
|
+
WHERE issue_id IN (SELECT id FROM jira_issues WHERE project_key = ${projectKey})
|
|
441
|
+
`
|
|
442
|
+
await this.sql`DELETE FROM jira_issues WHERE project_key = ${projectKey}`
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
await this.sql`
|
|
447
|
+
DELETE FROM jira_activity
|
|
448
|
+
WHERE issue_id IN (
|
|
449
|
+
SELECT id FROM jira_issues
|
|
450
|
+
WHERE project_key = ${projectKey}
|
|
451
|
+
AND NOT (id = ANY(${upstreamIssueIds}))
|
|
452
|
+
)
|
|
453
|
+
`
|
|
454
|
+
await this.sql`
|
|
455
|
+
DELETE FROM jira_issues
|
|
456
|
+
WHERE project_key = ${projectKey}
|
|
457
|
+
AND NOT (id = ANY(${upstreamIssueIds}))
|
|
458
|
+
`
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private async getColumns(): Promise<JiraColumnRow[]> {
|
|
462
|
+
await this.ready
|
|
463
|
+
return this.sql<JiraColumnRow[]>`SELECT * FROM jira_columns ORDER BY position, name`
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private async selectIssuesByStatusIds(statusIds: string[]): Promise<JiraIssueRow[]> {
|
|
467
|
+
if (statusIds.length === 0) return []
|
|
468
|
+
return this.sql<JiraIssueRow[]>`
|
|
469
|
+
SELECT * FROM jira_issues
|
|
470
|
+
WHERE status_id = ANY(${statusIds})
|
|
471
|
+
ORDER BY updated_at DESC, summary ASC
|
|
472
|
+
`
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private async getCachedBoard(): Promise<BoardView> {
|
|
476
|
+
const columns = await this.getColumns()
|
|
477
|
+
const boardColumns = []
|
|
478
|
+
for (const column of columns) {
|
|
479
|
+
const tasks = (await this.selectIssuesByStatusIds(decodeColumnStatusIds(column))).map(
|
|
480
|
+
taskFromRow,
|
|
481
|
+
)
|
|
482
|
+
boardColumns.push({
|
|
483
|
+
id: column.id,
|
|
484
|
+
name: column.name,
|
|
485
|
+
position: column.position,
|
|
486
|
+
color: null,
|
|
487
|
+
created_at: '',
|
|
488
|
+
updated_at: '',
|
|
489
|
+
tasks,
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
return { columns: boardColumns }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private async getCachedTask(lookup: string): Promise<Task | null> {
|
|
496
|
+
const normalized = lookup.startsWith('jira:') ? lookup.slice('jira:'.length) : lookup
|
|
497
|
+
const [row] = await this.sql<JiraIssueRow[]>`
|
|
498
|
+
SELECT * FROM jira_issues
|
|
499
|
+
WHERE id = ${normalized} OR key = ${normalized}
|
|
500
|
+
LIMIT 1
|
|
501
|
+
`
|
|
502
|
+
return row ? taskFromRow(row) : null
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private async adjustIssueCommentCount(idOrKey: string, delta: number): Promise<void> {
|
|
506
|
+
await this.sql`
|
|
507
|
+
UPDATE jira_issues
|
|
508
|
+
SET comment_count = GREATEST(0, comment_count + ${delta})
|
|
509
|
+
WHERE id = ${idOrKey} OR key = ${idOrKey}
|
|
510
|
+
`
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private async getCachedTasks(params?: { columnId?: string }): Promise<Task[]> {
|
|
514
|
+
if (params?.columnId !== undefined) {
|
|
515
|
+
const [columnRow] = await this.sql<Pick<JiraColumnRow, 'status_ids'>[]>`
|
|
516
|
+
SELECT status_ids FROM jira_columns WHERE id = ${params.columnId}
|
|
517
|
+
`
|
|
518
|
+
if (!columnRow) return []
|
|
519
|
+
return (await this.selectIssuesByStatusIds(decodeColumnStatusIds(columnRow))).map(taskFromRow)
|
|
520
|
+
}
|
|
521
|
+
return (
|
|
522
|
+
await this.sql<JiraIssueRow[]>`
|
|
523
|
+
SELECT * FROM jira_issues ORDER BY updated_at DESC, summary ASC
|
|
524
|
+
`
|
|
525
|
+
).map(taskFromRow)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private async getCachedConfig(): Promise<JiraCacheConfig> {
|
|
529
|
+
const users = (
|
|
530
|
+
await this.sql<{ account_id: string; display_name: string }[]>`
|
|
531
|
+
SELECT account_id, display_name
|
|
532
|
+
FROM jira_users
|
|
533
|
+
WHERE active = 1
|
|
534
|
+
ORDER BY display_name
|
|
535
|
+
`
|
|
536
|
+
).map((row) => ({ accountId: row.account_id, displayName: row.display_name }))
|
|
537
|
+
const priorities = await this.sql<Array<{ id: string; name: string }>>`
|
|
538
|
+
SELECT id, name FROM jira_priorities ORDER BY name
|
|
539
|
+
`
|
|
540
|
+
const issueTypes = await this.sql<Array<{ id: string; name: string }>>`
|
|
541
|
+
SELECT id, name FROM jira_issue_types ORDER BY name
|
|
542
|
+
`
|
|
543
|
+
return {
|
|
544
|
+
projectKey: await this.getMeta('projectKey'),
|
|
545
|
+
users,
|
|
546
|
+
priorities,
|
|
547
|
+
issueTypes,
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private async saveActivity(rows: JiraActivityRow[]): Promise<void> {
|
|
552
|
+
for (const row of rows) {
|
|
553
|
+
await this.sql`
|
|
554
|
+
INSERT INTO jira_activity (issue_id, history_id, item_field, from_value, to_value, created_at)
|
|
555
|
+
VALUES (${row.issue_id}, ${row.history_id}, ${row.item_field}, ${row.from_value}, ${row.to_value}, ${row.created_at})
|
|
556
|
+
ON CONFLICT(issue_id, history_id, item_field) DO NOTHING
|
|
557
|
+
`
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private async getCachedActivity(
|
|
562
|
+
params: { issueId?: string; limit?: number } = {},
|
|
563
|
+
): Promise<JiraActivityRow[]> {
|
|
564
|
+
const limit = params.limit ?? 100
|
|
565
|
+
if (params.issueId) {
|
|
566
|
+
return this.sql<JiraActivityRow[]>`
|
|
567
|
+
SELECT issue_id, history_id, item_field, from_value, to_value, created_at
|
|
568
|
+
FROM jira_activity
|
|
569
|
+
WHERE issue_id = ${params.issueId}
|
|
570
|
+
ORDER BY created_at DESC
|
|
571
|
+
LIMIT ${limit}
|
|
572
|
+
`
|
|
573
|
+
}
|
|
574
|
+
return this.sql<JiraActivityRow[]>`
|
|
575
|
+
SELECT issue_id, history_id, item_field, from_value, to_value, created_at
|
|
576
|
+
FROM jira_activity
|
|
577
|
+
ORDER BY created_at DESC
|
|
578
|
+
LIMIT ${limit}
|
|
579
|
+
`
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private async sync(force = false): Promise<void> {
|
|
583
|
+
await this.ready
|
|
584
|
+
const meta = await this.loadSyncMeta()
|
|
585
|
+
const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
|
|
586
|
+
const now = Date.now()
|
|
587
|
+
if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
|
|
588
|
+
const fullReconcile = force || shouldRunFullReconcile(meta.lastFullSyncAt, now)
|
|
589
|
+
|
|
590
|
+
const project = await this.client.getProject(this.config.projectKey)
|
|
591
|
+
await this.saveTeamInfo({ id: project.id, key: project.key, name: project.name })
|
|
592
|
+
|
|
593
|
+
if (this.config.boardId !== undefined) {
|
|
594
|
+
const boardCfg = await this.client.getBoardColumns(this.config.boardId)
|
|
595
|
+
const boardId = this.config.boardId
|
|
596
|
+
await this.replaceColumns(
|
|
597
|
+
boardCfg.columnConfig.columns.map((column, index) => ({
|
|
598
|
+
id: `board:${boardId}:${column.name}`,
|
|
599
|
+
name: column.name,
|
|
600
|
+
position: index,
|
|
601
|
+
statusIds: column.statuses.map((status) => status.id),
|
|
602
|
+
source: 'board' as const,
|
|
603
|
+
})),
|
|
604
|
+
)
|
|
605
|
+
} else {
|
|
606
|
+
const statusCats = await this.client.getProjectStatuses(project.key)
|
|
607
|
+
const seen = new Set<string>()
|
|
608
|
+
const uniqueStatuses: Array<{ id: string; name: string }> = []
|
|
609
|
+
for (const category of statusCats) {
|
|
610
|
+
for (const status of category.statuses) {
|
|
611
|
+
if (seen.has(status.id)) continue
|
|
612
|
+
seen.add(status.id)
|
|
613
|
+
uniqueStatuses.push({ id: status.id, name: status.name })
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
await this.replaceColumns(
|
|
617
|
+
uniqueStatuses.map((status, index) => ({
|
|
618
|
+
id: `status:${status.id}`,
|
|
619
|
+
name: status.name,
|
|
620
|
+
position: index,
|
|
621
|
+
statusIds: [status.id],
|
|
622
|
+
source: 'status' as const,
|
|
623
|
+
})),
|
|
624
|
+
)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const [users, priorities, issueTypes] = await Promise.all([
|
|
628
|
+
this.client.listAssignableUsers({
|
|
629
|
+
projectKey: project.key,
|
|
630
|
+
startAt: 0,
|
|
631
|
+
maxResults: 100,
|
|
632
|
+
}),
|
|
633
|
+
this.client.listPriorities(),
|
|
634
|
+
this.client.listIssueTypes({ projectId: project.id }),
|
|
635
|
+
])
|
|
636
|
+
await this.upsertUsers(
|
|
637
|
+
users.map((user) => ({
|
|
638
|
+
accountId: user.accountId,
|
|
639
|
+
displayName: user.displayName,
|
|
640
|
+
active: user.active ?? true,
|
|
641
|
+
})),
|
|
642
|
+
)
|
|
643
|
+
await this.replacePriorities(
|
|
644
|
+
priorities.map((priority) => ({ id: priority.id, name: priority.name })),
|
|
645
|
+
)
|
|
646
|
+
await this.replaceIssueTypes(
|
|
647
|
+
issueTypes.map((issueType) => ({ id: issueType.id, name: issueType.name })),
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
const since = fullReconcile ? null : meta.lastIssueUpdatedAt
|
|
651
|
+
const sinceClause = since ?? '1970-01-01 00:00'
|
|
652
|
+
const jql = `project = ${project.key} AND updated >= "${sinceClause}" ORDER BY updated ASC`
|
|
653
|
+
let startAt = 0
|
|
654
|
+
const maxResults = 100
|
|
655
|
+
let accumulated = 0
|
|
656
|
+
let total = Infinity
|
|
657
|
+
let newestUpdatedAt: string | null = meta.lastIssueUpdatedAt
|
|
658
|
+
const seenIssueIds = new Set<string>()
|
|
659
|
+
const issueFields = [
|
|
660
|
+
'summary',
|
|
661
|
+
'description',
|
|
662
|
+
'status',
|
|
663
|
+
'issuetype',
|
|
664
|
+
'priority',
|
|
665
|
+
'assignee',
|
|
666
|
+
'labels',
|
|
667
|
+
'comment',
|
|
668
|
+
'created',
|
|
669
|
+
'updated',
|
|
670
|
+
'project',
|
|
671
|
+
]
|
|
672
|
+
|
|
673
|
+
while (accumulated < total) {
|
|
674
|
+
const page = await this.client.listIssues({ jql, startAt, maxResults, fields: issueFields })
|
|
675
|
+
total = page.total
|
|
676
|
+
if (page.issues.length === 0) break
|
|
677
|
+
|
|
678
|
+
await this.upsertIssues(
|
|
679
|
+
page.issues.map((issue) => ({
|
|
680
|
+
id: issue.id,
|
|
681
|
+
key: issue.key,
|
|
682
|
+
summary: issue.fields.summary,
|
|
683
|
+
descriptionText: issue.fields.description
|
|
684
|
+
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
685
|
+
: '',
|
|
686
|
+
statusId: issue.fields.status.id,
|
|
687
|
+
priorityName: issue.fields.priority?.name ?? null,
|
|
688
|
+
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
689
|
+
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
690
|
+
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
691
|
+
labels: issue.fields.labels ?? [],
|
|
692
|
+
commentCount: issue.fields.comment?.total ?? 0,
|
|
693
|
+
projectKey: issue.fields.project?.key ?? project.key,
|
|
694
|
+
url: `${this.config.baseUrl}/browse/${issue.key}`,
|
|
695
|
+
createdAt: issue.fields.created,
|
|
696
|
+
updatedAt: issue.fields.updated,
|
|
697
|
+
})),
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
for (const issue of page.issues) {
|
|
701
|
+
if (fullReconcile) seenIssueIds.add(issue.id)
|
|
702
|
+
if (newestUpdatedAt === null || issue.fields.updated > newestUpdatedAt) {
|
|
703
|
+
newestUpdatedAt = issue.fields.updated
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
for (const issue of page.issues) {
|
|
708
|
+
await this.ingestIssueActivity(issue.id).catch((err) => {
|
|
709
|
+
console.warn(`[jira] activity fetch for ${issue.key} failed:`, err)
|
|
710
|
+
})
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
accumulated += page.issues.length
|
|
714
|
+
startAt += page.issues.length
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (fullReconcile) {
|
|
718
|
+
await this.pruneIssuesMissingUpstream(project.key, [...seenIssueIds])
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const nextMeta: Partial<JiraSyncMeta> = {
|
|
722
|
+
projectKey: project.key,
|
|
723
|
+
boardId: this.config.boardId ?? null,
|
|
724
|
+
lastSyncAt: new Date().toISOString(),
|
|
725
|
+
lastIssueUpdatedAt: newestUpdatedAt ?? new Date().toISOString(),
|
|
726
|
+
}
|
|
727
|
+
if (fullReconcile) nextMeta.lastFullSyncAt = nextMeta.lastSyncAt
|
|
728
|
+
await this.saveSyncMeta(nextMeta)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
private async resolveColumnId(input: string): Promise<string> {
|
|
732
|
+
const columns = await this.getColumns()
|
|
733
|
+
const byId = columns.find((column) => column.id === input)
|
|
734
|
+
if (byId) return byId.id
|
|
735
|
+
const lower = input.toLowerCase()
|
|
736
|
+
const byName = columns.find((column) => column.name.toLowerCase() === lower)
|
|
737
|
+
if (byName) return byName.id
|
|
738
|
+
const byStatus = columns.find((column) => decodeColumnStatusIds(column).includes(input))
|
|
739
|
+
if (byStatus) return byStatus.id
|
|
740
|
+
throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No Jira column matching '${input}'`)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
private async buildBoardConfig(): Promise<BoardConfig> {
|
|
744
|
+
const cache = await this.getCachedConfig()
|
|
745
|
+
const members = cache.users.map((user) => ({ name: user.displayName, role: 'human' as const }))
|
|
746
|
+
const projects = cache.projectKey ? [cache.projectKey] : []
|
|
747
|
+
const discoveredAssignees = (
|
|
748
|
+
await this.sql<{ assignee_name: string }[]>`
|
|
749
|
+
SELECT DISTINCT assignee_name FROM jira_issues WHERE assignee_name != '' ORDER BY assignee_name
|
|
750
|
+
`
|
|
751
|
+
).map((row) => row.assignee_name)
|
|
752
|
+
return {
|
|
753
|
+
members,
|
|
754
|
+
projects,
|
|
755
|
+
provider: 'jira',
|
|
756
|
+
discoveredAssignees,
|
|
757
|
+
discoveredProjects: projects.slice(),
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async syncCache(): Promise<void> {
|
|
762
|
+
await this.sync()
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async getSyncStatus(): Promise<ProviderSyncStatus> {
|
|
766
|
+
const meta = await this.loadSyncMeta()
|
|
767
|
+
return {
|
|
768
|
+
lastSyncAt: meta.lastSyncAt,
|
|
769
|
+
lastFullSyncAt: meta.lastFullSyncAt,
|
|
770
|
+
lastWebhookAt: meta.lastWebhookAt,
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async getContext(): Promise<ProviderContext> {
|
|
775
|
+
await this.sync()
|
|
776
|
+
return { provider: 'jira', capabilities: JIRA_CAPABILITIES, team: await this.loadTeamInfo() }
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async getBootstrap(): Promise<BoardBootstrap> {
|
|
780
|
+
await this.sync()
|
|
781
|
+
return {
|
|
782
|
+
provider: 'jira',
|
|
783
|
+
capabilities: JIRA_CAPABILITIES,
|
|
784
|
+
board: await this.getCachedBoard(),
|
|
785
|
+
config: await this.buildBoardConfig(),
|
|
786
|
+
metrics: null,
|
|
787
|
+
activity: [],
|
|
788
|
+
team: await this.loadTeamInfo(),
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async getBoard(): Promise<BoardView> {
|
|
793
|
+
await this.sync()
|
|
794
|
+
return this.getCachedBoard()
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async listColumns(): Promise<Column[]> {
|
|
798
|
+
await this.sync()
|
|
799
|
+
return (await this.getColumns()).map((row) => ({
|
|
800
|
+
id: row.id,
|
|
801
|
+
name: row.name,
|
|
802
|
+
position: row.position,
|
|
803
|
+
color: null,
|
|
804
|
+
created_at: '',
|
|
805
|
+
updated_at: '',
|
|
806
|
+
}))
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async listTasks(filters: TaskListFilters = {}): Promise<Task[]> {
|
|
810
|
+
await this.sync()
|
|
811
|
+
const columnId = filters.column ? await this.resolveColumnId(filters.column) : undefined
|
|
812
|
+
let tasks = await this.getCachedTasks(columnId ? { columnId } : undefined)
|
|
813
|
+
if (filters.priority) tasks = tasks.filter((task) => task.priority === filters.priority)
|
|
814
|
+
if (filters.assignee) tasks = tasks.filter((task) => task.assignee === filters.assignee)
|
|
815
|
+
if (filters.project) tasks = tasks.filter((task) => task.project === filters.project)
|
|
816
|
+
if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
|
|
817
|
+
if (filters.sort === 'updated')
|
|
818
|
+
tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
|
819
|
+
if (filters.limit) tasks = tasks.slice(0, filters.limit)
|
|
820
|
+
return tasks
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async getTask(idOrRef: string): Promise<Task> {
|
|
824
|
+
await this.sync()
|
|
825
|
+
const task = await this.getCachedTask(idOrRef)
|
|
826
|
+
if (!task) throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${idOrRef}'`)
|
|
827
|
+
return task
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private async resolveTaskByIdOrKey(idOrRef: string): Promise<Task> {
|
|
831
|
+
const task = await this.getCachedTask(idOrRef)
|
|
832
|
+
if (!task) throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${idOrRef}'`)
|
|
833
|
+
return task
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
private issueKeyFor(task: Task): string {
|
|
837
|
+
return task.externalRef ?? task.providerId ?? task.id.replace(/^jira:/, '')
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
private async resolveJiraPriorityName(canonical: Priority): Promise<string> {
|
|
841
|
+
const wanted = CANONICAL_TO_JIRA_DEFAULT[canonical]
|
|
842
|
+
const [row] = await this.sql<{ name: string }[]>`
|
|
843
|
+
SELECT name FROM jira_priorities WHERE LOWER(name) = LOWER(${wanted}) LIMIT 1
|
|
844
|
+
`
|
|
845
|
+
if (row) return row.name
|
|
846
|
+
const available = (
|
|
847
|
+
await this.sql<{ name: string }[]>`SELECT name FROM jira_priorities ORDER BY name`
|
|
848
|
+
).map((priority) => priority.name)
|
|
849
|
+
providerUpstreamError(
|
|
850
|
+
`Canonical priority '${canonical}' maps to Jira priority '${wanted}' which is not present in this tenant's priority catalog. Available Jira priorities: [${available
|
|
851
|
+
.map((name) => `"${name}"`)
|
|
852
|
+
.join(', ')}]`,
|
|
853
|
+
)
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
private async resolveAssigneeAccountId(displayName: string): Promise<string> {
|
|
857
|
+
const [row] = await this.sql<{ account_id: string }[]>`
|
|
858
|
+
SELECT account_id
|
|
859
|
+
FROM jira_users
|
|
860
|
+
WHERE active = 1 AND LOWER(display_name) = LOWER(${displayName})
|
|
861
|
+
LIMIT 1
|
|
862
|
+
`
|
|
863
|
+
if (row) return row.account_id
|
|
864
|
+
providerUpstreamError(
|
|
865
|
+
`Jira assignee '${displayName}' was not found in the cached active user list. Try 'kanban task list --assignee' to see cached names.`,
|
|
866
|
+
)
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
private async resolveIssueTypeId(name: string): Promise<string> {
|
|
870
|
+
const [row] = await this.sql<{ id: string }[]>`
|
|
871
|
+
SELECT id FROM jira_issue_types WHERE LOWER(name) = LOWER(${name}) LIMIT 1
|
|
872
|
+
`
|
|
873
|
+
if (row) return row.id
|
|
874
|
+
const available = (
|
|
875
|
+
await this.sql<{ name: string }[]>`SELECT name FROM jira_issue_types ORDER BY name`
|
|
876
|
+
).map((issueType) => issueType.name)
|
|
877
|
+
providerUpstreamError(
|
|
878
|
+
`Jira issue type '${name}' is not present in this project's issue-type catalog. Available types: [${available
|
|
879
|
+
.map((availableName) => `"${availableName}"`)
|
|
880
|
+
.join(', ')}]`,
|
|
881
|
+
)
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
private normalizeProjectField(input?: string): void {
|
|
885
|
+
if (!input) return
|
|
886
|
+
if (input === this.config.projectKey) return
|
|
887
|
+
unsupportedOperation(
|
|
888
|
+
`JiraProvider is pinned to project '${this.config.projectKey}'. A different project field ('${input}') is not supported.`,
|
|
889
|
+
)
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
private toTaskComment(task: Task, comment: JiraComment): TaskComment {
|
|
893
|
+
const timestamp = comment.updated ?? comment.created ?? task.updated_at
|
|
894
|
+
return {
|
|
895
|
+
id: comment.id,
|
|
896
|
+
task_id: task.id,
|
|
897
|
+
body: comment.body ? adfToPlainText(comment.body as AdfDocument) : '',
|
|
898
|
+
author: comment.author?.displayName ?? null,
|
|
899
|
+
created_at: comment.created ?? timestamp,
|
|
900
|
+
updated_at: timestamp,
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
private async ingestIssueActivity(issueId: string): Promise<void> {
|
|
905
|
+
const page = await this.client.getChangelog(issueId, { maxResults: 100 })
|
|
906
|
+
const rows: JiraActivityRow[] = []
|
|
907
|
+
for (const entry of page.values) {
|
|
908
|
+
for (const item of entry.items) {
|
|
909
|
+
rows.push({
|
|
910
|
+
issue_id: issueId,
|
|
911
|
+
history_id: entry.id,
|
|
912
|
+
item_field: item.field,
|
|
913
|
+
from_value: item.from ?? null,
|
|
914
|
+
to_value: item.to ?? null,
|
|
915
|
+
created_at: entry.created,
|
|
916
|
+
})
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
await this.saveActivity(rows)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async createTask(input: CreateTaskInput): Promise<Task> {
|
|
923
|
+
await this.sync()
|
|
924
|
+
this.normalizeProjectField(input.project)
|
|
925
|
+
const issueTypeName = this.config.defaultIssueType ?? 'Task'
|
|
926
|
+
const issueTypeId = await this.resolveIssueTypeId(issueTypeName)
|
|
927
|
+
const fields: Record<string, unknown> = {
|
|
928
|
+
project: { key: this.config.projectKey },
|
|
929
|
+
summary: input.title,
|
|
930
|
+
issuetype: { id: issueTypeId },
|
|
931
|
+
}
|
|
932
|
+
if (input.description !== undefined) fields['description'] = plainTextToAdf(input.description)
|
|
933
|
+
if (input.priority !== undefined) {
|
|
934
|
+
fields['priority'] = { name: await this.resolveJiraPriorityName(input.priority) }
|
|
935
|
+
}
|
|
936
|
+
if (input.assignee) {
|
|
937
|
+
fields['assignee'] = { accountId: await this.resolveAssigneeAccountId(input.assignee) }
|
|
938
|
+
}
|
|
939
|
+
const created = await this.client.createIssue({ fields })
|
|
940
|
+
await this.sync(true)
|
|
941
|
+
const fresh = await this.getCachedTask(created.key)
|
|
942
|
+
if (!fresh) {
|
|
943
|
+
providerUpstreamError(
|
|
944
|
+
`Jira issue ${created.key} was created but is not yet visible in the cache after sync.`,
|
|
945
|
+
)
|
|
946
|
+
}
|
|
947
|
+
return fresh
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
|
|
951
|
+
await this.sync()
|
|
952
|
+
this.normalizeProjectField(input.project)
|
|
953
|
+
if (input.metadata !== undefined)
|
|
954
|
+
unsupportedOperation('Jira mode does not support metadata updates')
|
|
955
|
+
const task = await this.resolveTaskByIdOrKey(idOrRef)
|
|
956
|
+
if (input.expectedVersion !== undefined && task.version !== input.expectedVersion) {
|
|
957
|
+
throw new KanbanError(
|
|
958
|
+
ErrorCode.CONFLICT,
|
|
959
|
+
`Jira issue ${task.externalRef ?? idOrRef} was updated remotely (expected version ${input.expectedVersion}, current ${task.version ?? 'unknown'})`,
|
|
960
|
+
)
|
|
961
|
+
}
|
|
962
|
+
const issueKey = this.issueKeyFor(task)
|
|
963
|
+
const fields: Record<string, unknown> = {}
|
|
964
|
+
if (input.title !== undefined) fields['summary'] = input.title
|
|
965
|
+
if (input.description !== undefined) fields['description'] = plainTextToAdf(input.description)
|
|
966
|
+
if (input.priority !== undefined) {
|
|
967
|
+
fields['priority'] = { name: await this.resolveJiraPriorityName(input.priority) }
|
|
968
|
+
}
|
|
969
|
+
if (input.assignee !== undefined) {
|
|
970
|
+
fields['assignee'] = input.assignee
|
|
971
|
+
? { accountId: await this.resolveAssigneeAccountId(input.assignee) }
|
|
972
|
+
: null
|
|
973
|
+
}
|
|
974
|
+
if (Object.keys(fields).length > 0) await this.client.updateIssue(issueKey, { fields })
|
|
975
|
+
await this.sync(true)
|
|
976
|
+
const fresh = await this.getCachedTask(issueKey)
|
|
977
|
+
if (!fresh) providerUpstreamError(`Jira issue ${issueKey} disappeared from cache after update.`)
|
|
978
|
+
return fresh
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async moveTask(idOrRef: string, column: string): Promise<Task> {
|
|
982
|
+
await this.sync()
|
|
983
|
+
const task = await this.resolveTaskByIdOrKey(idOrRef)
|
|
984
|
+
return this.moveTaskByKey(this.issueKeyFor(task), column)
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
private async moveTaskByKey(issueKey: string, column: string): Promise<Task> {
|
|
988
|
+
const columnId = await this.resolveColumnId(column)
|
|
989
|
+
const columnRow = (await this.getColumns()).find((candidate) => candidate.id === columnId)
|
|
990
|
+
if (!columnRow) {
|
|
991
|
+
throw new KanbanError(
|
|
992
|
+
ErrorCode.COLUMN_NOT_FOUND,
|
|
993
|
+
`Resolved column '${column}' but cache row missing`,
|
|
994
|
+
)
|
|
995
|
+
}
|
|
996
|
+
const statusIds = decodeColumnStatusIds(columnRow)
|
|
997
|
+
if (statusIds.length === 0) {
|
|
998
|
+
providerUpstreamError(`Column '${columnRow.name}' has no mapped Jira statuses.`)
|
|
999
|
+
}
|
|
1000
|
+
const targetStatusId = statusIds[0]!
|
|
1001
|
+
const { transitions } = await this.client.getTransitions(issueKey)
|
|
1002
|
+
const match = transitions.find((transition) => transition.to.id === targetStatusId)
|
|
1003
|
+
if (!match) {
|
|
1004
|
+
const currentStatusId = (await this.getCachedTask(issueKey))?.column_id ?? '<unknown>'
|
|
1005
|
+
providerUpstreamError(
|
|
1006
|
+
`Cannot transition Jira issue ${issueKey} (current status id ${currentStatusId}) to column '${columnRow.name}' (target status id ${targetStatusId}). Available transitions: [${transitions
|
|
1007
|
+
.map((transition) => `"${transition.name}"`)
|
|
1008
|
+
.join(', ')}]`,
|
|
1009
|
+
)
|
|
1010
|
+
}
|
|
1011
|
+
await this.client.transitionIssue(issueKey, match.id)
|
|
1012
|
+
await this.sync(true)
|
|
1013
|
+
const fresh = await this.getCachedTask(issueKey)
|
|
1014
|
+
if (!fresh) providerUpstreamError(`Jira issue ${issueKey} missing from cache after transition.`)
|
|
1015
|
+
return fresh
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async deleteTask(_idOrRef: string): Promise<Task> {
|
|
1019
|
+
unsupportedOperation('Task deletion is not supported in Jira mode')
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
async listComments(idOrRef: string): Promise<TaskComment[]> {
|
|
1023
|
+
await this.sync()
|
|
1024
|
+
const task = await this.resolveTaskByIdOrKey(idOrRef)
|
|
1025
|
+
const issueKey = this.issueKeyFor(task)
|
|
1026
|
+
const comments: JiraComment[] = []
|
|
1027
|
+
let startAt = 0
|
|
1028
|
+
|
|
1029
|
+
while (true) {
|
|
1030
|
+
const page = await this.client.getComments(issueKey, { startAt, maxResults: 100 })
|
|
1031
|
+
comments.push(...page.comments)
|
|
1032
|
+
startAt += page.comments.length
|
|
1033
|
+
if (comments.length >= page.total || page.comments.length === 0) break
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
return comments.map((comment) => this.toTaskComment(task, comment))
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
|
|
1040
|
+
await this.sync()
|
|
1041
|
+
const task = await this.resolveTaskByIdOrKey(idOrRef)
|
|
1042
|
+
const comment = await this.client.getComment(this.issueKeyFor(task), commentId)
|
|
1043
|
+
return this.toTaskComment(task, comment)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
async comment(idOrRef: string, body: string): Promise<TaskComment> {
|
|
1047
|
+
await this.sync()
|
|
1048
|
+
const task = await this.resolveTaskByIdOrKey(idOrRef)
|
|
1049
|
+
const created = await this.client.addComment(this.issueKeyFor(task), {
|
|
1050
|
+
body: plainTextToAdf(body),
|
|
1051
|
+
})
|
|
1052
|
+
await this.adjustIssueCommentCount(task.providerId || task.externalRef || task.id, 1)
|
|
1053
|
+
return this.toTaskComment(task, created)
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
|
|
1057
|
+
await this.sync()
|
|
1058
|
+
const task = await this.resolveTaskByIdOrKey(idOrRef)
|
|
1059
|
+
const updated = await this.client.updateComment(this.issueKeyFor(task), commentId, {
|
|
1060
|
+
body: plainTextToAdf(body),
|
|
1061
|
+
})
|
|
1062
|
+
return this.toTaskComment(task, updated)
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]> {
|
|
1066
|
+
await this.sync()
|
|
1067
|
+
const lookupIssueId = taskId ? await this.resolveIssueIdFromTaskId(taskId) : undefined
|
|
1068
|
+
const rows = await this.getCachedActivity({
|
|
1069
|
+
...(lookupIssueId !== undefined ? { issueId: lookupIssueId } : {}),
|
|
1070
|
+
limit: limit ?? 100,
|
|
1071
|
+
})
|
|
1072
|
+
return Promise.all(rows.map((row) => this.activityRowToEntry(row)))
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
private async resolveIssueIdFromTaskId(taskId: string): Promise<string | undefined> {
|
|
1076
|
+
const normalized = taskId.startsWith('jira:') ? taskId.slice('jira:'.length) : taskId
|
|
1077
|
+
const [row] = await this.sql<{ id: string }[]>`
|
|
1078
|
+
SELECT id FROM jira_issues WHERE id = ${normalized} OR key = ${normalized} LIMIT 1
|
|
1079
|
+
`
|
|
1080
|
+
return row?.id
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
private async activityRowToEntry(row: JiraActivityRow): Promise<ActivityEntry> {
|
|
1084
|
+
const action: ActivityEntry['action'] = row.item_field === 'status' ? 'moved' : 'updated'
|
|
1085
|
+
let fromCol = row.from_value
|
|
1086
|
+
let toCol = row.to_value
|
|
1087
|
+
if (row.item_field === 'status') {
|
|
1088
|
+
fromCol = row.from_value
|
|
1089
|
+
? ((await this.statusIdToColumnId(row.from_value)) ?? row.from_value)
|
|
1090
|
+
: null
|
|
1091
|
+
toCol = row.to_value ? ((await this.statusIdToColumnId(row.to_value)) ?? row.to_value) : null
|
|
1092
|
+
}
|
|
1093
|
+
return {
|
|
1094
|
+
id: `jira-activity:${row.issue_id}:${row.history_id}:${row.item_field}`,
|
|
1095
|
+
task_id: `jira:${row.issue_id}`,
|
|
1096
|
+
action,
|
|
1097
|
+
field_changed: row.item_field,
|
|
1098
|
+
old_value: fromCol,
|
|
1099
|
+
new_value: toCol,
|
|
1100
|
+
timestamp: row.created_at,
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
private async statusIdToColumnId(statusId: string): Promise<string | undefined> {
|
|
1105
|
+
for (const column of await this.getColumns()) {
|
|
1106
|
+
if (decodeColumnStatusIds(column).includes(statusId)) return column.id
|
|
1107
|
+
}
|
|
1108
|
+
return undefined
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
async getMetrics(): Promise<BoardMetrics> {
|
|
1112
|
+
unsupportedOperation('Metrics are not available in Jira mode')
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
async getConfig(): Promise<BoardConfig> {
|
|
1116
|
+
await this.sync()
|
|
1117
|
+
return this.buildBoardConfig()
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
async patchConfig(_input: Partial<BoardConfig>): Promise<BoardConfig> {
|
|
1121
|
+
unsupportedOperation('Config mutation is not supported in Jira mode')
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
|
|
1125
|
+
const secret = process.env['JIRA_WEBHOOK_SECRET']
|
|
1126
|
+
if (secret) {
|
|
1127
|
+
const sig = headerLower(payload.headers, 'x-hub-signature-256')
|
|
1128
|
+
if (!verifyHmacSha256(secret, payload.rawBody, sig)) {
|
|
1129
|
+
return { handled: false, unauthorized: true, message: 'Invalid signature' }
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
let body: { webhookEvent?: string; issue?: JiraIssue } = {}
|
|
1133
|
+
try {
|
|
1134
|
+
body = JSON.parse(payload.rawBody) as typeof body
|
|
1135
|
+
} catch {
|
|
1136
|
+
return { handled: false, message: 'Invalid JSON body' }
|
|
1137
|
+
}
|
|
1138
|
+
const event = body.webhookEvent ?? ''
|
|
1139
|
+
const issue = body.issue
|
|
1140
|
+
if (!issue) return { handled: false, message: `No issue in payload (${event})` }
|
|
1141
|
+
|
|
1142
|
+
if (event === 'jira:issue_deleted') {
|
|
1143
|
+
await this.deleteIssue(issue.id)
|
|
1144
|
+
await this.saveSyncMeta({ lastWebhookAt: new Date().toISOString() })
|
|
1145
|
+
return { handled: true }
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
if (event === 'jira:issue_created' || event === 'jira:issue_updated') {
|
|
1149
|
+
const projectKey = issue.fields.project?.key
|
|
1150
|
+
if (projectKey !== this.config.projectKey) {
|
|
1151
|
+
return {
|
|
1152
|
+
handled: false,
|
|
1153
|
+
message: `Ignoring issue from project '${projectKey ?? 'unknown'}'`,
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
await this.upsertIssues([
|
|
1157
|
+
{
|
|
1158
|
+
id: issue.id,
|
|
1159
|
+
key: issue.key,
|
|
1160
|
+
summary: issue.fields.summary,
|
|
1161
|
+
descriptionText: issue.fields.description
|
|
1162
|
+
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
1163
|
+
: '',
|
|
1164
|
+
statusId: issue.fields.status.id,
|
|
1165
|
+
priorityName: issue.fields.priority?.name ?? null,
|
|
1166
|
+
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
1167
|
+
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
1168
|
+
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
1169
|
+
labels: issue.fields.labels ?? [],
|
|
1170
|
+
commentCount: issue.fields.comment?.total ?? 0,
|
|
1171
|
+
projectKey,
|
|
1172
|
+
url: `${this.config.baseUrl}/browse/${issue.key}`,
|
|
1173
|
+
createdAt: issue.fields.created,
|
|
1174
|
+
updatedAt: issue.fields.updated,
|
|
1175
|
+
},
|
|
1176
|
+
])
|
|
1177
|
+
if (event === 'jira:issue_updated') {
|
|
1178
|
+
await this.ingestIssueActivity(issue.id).catch((err) => {
|
|
1179
|
+
console.warn(`[jira] activity fetch for webhook issue ${issue.key} failed:`, err)
|
|
1180
|
+
})
|
|
1181
|
+
}
|
|
1182
|
+
await this.saveSyncMeta({ lastWebhookAt: new Date().toISOString() })
|
|
1183
|
+
return { handled: true }
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
return { handled: false, message: `Unsupported event: ${event}` }
|
|
1187
|
+
}
|
|
1188
|
+
}
|