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