@andypai/agent-kanban 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -19
- package/package.json +3 -2
- package/src/__tests__/activity.test.ts +12 -0
- package/src/__tests__/api.test.ts +4 -2
- package/src/__tests__/board-utils.test.ts +16 -3
- package/src/__tests__/column-roles.test.ts +34 -0
- package/src/__tests__/commands/board.test.ts +7 -0
- package/src/__tests__/conflict.test.ts +11 -1
- package/src/__tests__/db.test.ts +70 -0
- package/src/__tests__/index.test.ts +55 -0
- package/src/__tests__/jira-cache.test.ts +56 -0
- package/src/__tests__/jira-client.test.ts +98 -1
- package/src/__tests__/jira-jql.test.ts +51 -0
- package/src/__tests__/jira-provider-mutations.test.ts +84 -59
- package/src/__tests__/jira-provider-read.test.ts +227 -20
- package/src/__tests__/mcp-server.test.ts +2 -2
- package/src/__tests__/metrics.test.ts +37 -1
- package/src/__tests__/postgres-jira-provider.test.ts +155 -0
- package/src/__tests__/postgres-local-provider.test.ts +90 -1
- package/src/__tests__/server.test.ts +126 -0
- package/src/__tests__/use-cases.test.ts +77 -0
- package/src/__tests__/webhooks.test.ts +75 -22
- package/src/api.ts +58 -36
- package/src/column-roles.ts +52 -0
- package/src/commands/board.ts +4 -4
- package/src/db.ts +145 -114
- package/src/errors.ts +1 -0
- package/src/index.ts +84 -33
- package/src/mcp/core.ts +8 -7
- package/src/metrics.ts +48 -23
- package/src/provider-runtime.ts +9 -2
- package/src/providers/index.ts +4 -1
- package/src/providers/jira-cache.ts +23 -6
- package/src/providers/jira-client.ts +70 -4
- package/src/providers/jira-core.ts +921 -0
- package/src/providers/jira-jql.ts +48 -0
- package/src/providers/jira.ts +111 -677
- package/src/providers/linear-cache.ts +5 -3
- package/src/providers/linear-core.ts +688 -0
- package/src/providers/linear.ts +70 -554
- package/src/providers/postgres-jira-cache.ts +597 -0
- package/src/providers/postgres-jira.ts +15 -1188
- package/src/providers/postgres-linear-cache.ts +500 -0
- package/src/providers/postgres-linear.ts +22 -1088
- package/src/providers/postgres-local.ts +181 -74
- package/src/providers/types.ts +71 -2
- package/src/server.ts +118 -32
- package/src/use-cases.ts +139 -0
- package/src/webhooks.ts +26 -0
- package/ui/dist/assets/index-65LcV0R7.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-CFhtfqCn.js +0 -40
package/src/providers/jira.ts
CHANGED
|
@@ -1,29 +1,8 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite'
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
4
|
-
ActivityEntry,
|
|
5
|
-
BoardBootstrap,
|
|
6
|
-
BoardConfig,
|
|
7
|
-
BoardMetrics,
|
|
8
|
-
BoardView,
|
|
9
|
-
Column,
|
|
10
|
-
Priority,
|
|
11
|
-
TaskComment,
|
|
12
|
-
Task,
|
|
13
|
-
} from '../types'
|
|
14
|
-
import {
|
|
15
|
-
headerLower,
|
|
16
|
-
verifySha256HmacSignatureHeader,
|
|
17
|
-
type WebhookRequest,
|
|
18
|
-
type WebhookResult,
|
|
19
|
-
} from '../webhooks'
|
|
20
|
-
import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
|
|
21
|
-
import { JIRA_CAPABILITIES } from './capabilities'
|
|
22
|
-
import { providerUpstreamError, unsupportedOperation } from './errors'
|
|
23
|
-
import { JiraClient, normalizeJiraLabels, type JiraComment, type JiraIssue } from './jira-client'
|
|
2
|
+
import type { BoardView, ProviderTeamInfo, Task } from '../types'
|
|
3
|
+
import type { JiraClient } from './jira-client'
|
|
24
4
|
import {
|
|
25
5
|
adjustJiraIssueCommentCount,
|
|
26
|
-
decodeColumnStatusIds,
|
|
27
6
|
deleteJiraIssue,
|
|
28
7
|
getCachedActivity,
|
|
29
8
|
getCachedBoard,
|
|
@@ -32,8 +11,6 @@ import {
|
|
|
32
11
|
getCachedTask,
|
|
33
12
|
getCachedTasks,
|
|
34
13
|
initJiraCacheSchema,
|
|
35
|
-
jiraBoardColumnRows,
|
|
36
|
-
resolveJiraColumnId,
|
|
37
14
|
loadJiraSyncMeta,
|
|
38
15
|
loadTeamInfo,
|
|
39
16
|
pruneJiraIssuesMissingUpstream,
|
|
@@ -46,723 +23,180 @@ import {
|
|
|
46
23
|
upsertJiraIssues,
|
|
47
24
|
upsertJiraUsers,
|
|
48
25
|
type JiraActivityRow,
|
|
26
|
+
type JiraCacheConfig,
|
|
27
|
+
type JiraColumnRow,
|
|
49
28
|
type JiraSyncMeta,
|
|
50
29
|
} from './jira-cache'
|
|
51
|
-
import type
|
|
52
|
-
CreateTaskInput,
|
|
53
|
-
KanbanProvider,
|
|
54
|
-
ProviderContext,
|
|
55
|
-
ProviderSyncStatus,
|
|
56
|
-
TaskListFilters,
|
|
57
|
-
UpdateTaskInput,
|
|
58
|
-
} from './types'
|
|
59
|
-
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
|
|
30
|
+
import { JiraProviderCore, type JiraCachePort, type JiraProviderConfig } from './jira-core'
|
|
60
31
|
|
|
61
|
-
|
|
32
|
+
export type { JiraProviderConfig } from './jira-core'
|
|
62
33
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
34
|
+
/**
|
|
35
|
+
* SQLite implementation of the JiraCachePort. Wraps the synchronous bun:sqlite
|
|
36
|
+
* free functions in jira-cache.ts as awaitable methods so the shared
|
|
37
|
+
* JiraProviderCore can drive both SQLite and Postgres uniformly.
|
|
38
|
+
*/
|
|
39
|
+
class SqliteJiraCache implements JiraCachePort {
|
|
40
|
+
readonly ready = Promise.resolve()
|
|
69
41
|
|
|
70
|
-
|
|
71
|
-
// priorities; the write path looks up the resolved name (case-insensitive)
|
|
72
|
-
// in the cached `jira_priorities` table, so renames that preserve the default
|
|
73
|
-
// casing still resolve.
|
|
74
|
-
const CANONICAL_TO_JIRA_DEFAULT: Record<Priority, string> = {
|
|
75
|
-
urgent: 'Highest',
|
|
76
|
-
high: 'High',
|
|
77
|
-
medium: 'Medium',
|
|
78
|
-
low: 'Low',
|
|
79
|
-
}
|
|
42
|
+
constructor(private readonly db: Database) {}
|
|
80
43
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
email: string
|
|
84
|
-
apiToken: string
|
|
85
|
-
projectKey: string
|
|
86
|
-
boardId?: number
|
|
87
|
-
defaultIssueType?: string
|
|
88
|
-
pollingSyncIntervalMs?: number
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export class JiraProvider implements KanbanProvider {
|
|
92
|
-
readonly type = 'jira' as const
|
|
93
|
-
private readonly client: JiraClient
|
|
94
|
-
private readonly pollingSyncIntervalMs: number
|
|
95
|
-
|
|
96
|
-
constructor(
|
|
97
|
-
private readonly db: Database,
|
|
98
|
-
private readonly config: JiraProviderConfig,
|
|
99
|
-
client?: JiraClient,
|
|
100
|
-
) {
|
|
101
|
-
initJiraCacheSchema(db)
|
|
102
|
-
this.pollingSyncIntervalMs = config.pollingSyncIntervalMs ?? DEFAULT_POLLING_SYNC_INTERVAL_MS
|
|
103
|
-
this.client =
|
|
104
|
-
client ??
|
|
105
|
-
new JiraClient({
|
|
106
|
-
baseUrl: config.baseUrl,
|
|
107
|
-
email: config.email,
|
|
108
|
-
apiToken: config.apiToken,
|
|
109
|
-
})
|
|
44
|
+
async loadSyncMeta(): Promise<JiraSyncMeta> {
|
|
45
|
+
return loadJiraSyncMeta(this.db)
|
|
110
46
|
}
|
|
111
47
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const now = Date.now()
|
|
116
|
-
if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
|
|
117
|
-
const fullReconcile = force || shouldRunFullReconcile(meta.lastFullSyncAt, now)
|
|
118
|
-
|
|
119
|
-
// 1. Resolve project.
|
|
120
|
-
const project = await this.client.getProject(this.config.projectKey)
|
|
121
|
-
saveTeamInfo(this.db, { id: project.id, key: project.key, name: project.name })
|
|
122
|
-
|
|
123
|
-
// 2. Columns: board path OR status fallback path.
|
|
124
|
-
if (this.config.boardId !== undefined) {
|
|
125
|
-
const boardCfg = await this.client.getBoardColumns(this.config.boardId)
|
|
126
|
-
const boardId = this.config.boardId
|
|
127
|
-
const rows = jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns)
|
|
128
|
-
replaceJiraColumns(this.db, rows)
|
|
129
|
-
} else {
|
|
130
|
-
const statusCats = await this.client.getProjectStatuses(project.key)
|
|
131
|
-
const seen = new Set<string>()
|
|
132
|
-
const uniqueStatuses: Array<{ id: string; name: string }> = []
|
|
133
|
-
for (const cat of statusCats) {
|
|
134
|
-
for (const s of cat.statuses) {
|
|
135
|
-
if (seen.has(s.id)) continue
|
|
136
|
-
seen.add(s.id)
|
|
137
|
-
uniqueStatuses.push({ id: s.id, name: s.name })
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
const rows = uniqueStatuses.map((s, i) => ({
|
|
141
|
-
id: `status:${s.id}`,
|
|
142
|
-
name: s.name,
|
|
143
|
-
position: i,
|
|
144
|
-
statusIds: [s.id],
|
|
145
|
-
source: 'status' as const,
|
|
146
|
-
}))
|
|
147
|
-
replaceJiraColumns(this.db, rows)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// 3. Catalogs: users + priorities + issue types in parallel.
|
|
151
|
-
// NOTE: listAssignableUsers is capped at 100 in T04; tenants with more
|
|
152
|
-
// assignable users are truncated. Pagination is out of scope for this pass.
|
|
153
|
-
const [users, priorities, issueTypes] = await Promise.all([
|
|
154
|
-
this.client.listAssignableUsers({
|
|
155
|
-
projectKey: project.key,
|
|
156
|
-
startAt: 0,
|
|
157
|
-
maxResults: 100,
|
|
158
|
-
}),
|
|
159
|
-
this.client.listPriorities(),
|
|
160
|
-
this.client.listIssueTypes({ projectId: project.id }),
|
|
161
|
-
])
|
|
162
|
-
upsertJiraUsers(
|
|
163
|
-
this.db,
|
|
164
|
-
users.map((u) => ({
|
|
165
|
-
accountId: u.accountId,
|
|
166
|
-
displayName: u.displayName,
|
|
167
|
-
active: u.active ?? true,
|
|
168
|
-
})),
|
|
169
|
-
)
|
|
170
|
-
replaceJiraPriorities(
|
|
171
|
-
this.db,
|
|
172
|
-
priorities.map((p) => ({ id: p.id, name: p.name })),
|
|
173
|
-
)
|
|
174
|
-
replaceJiraIssueTypes(
|
|
175
|
-
this.db,
|
|
176
|
-
issueTypes.map((t) => ({ id: t.id, name: t.name })),
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
// 4. Delta issue fetch (paginated).
|
|
180
|
-
const since = fullReconcile ? null : meta.lastIssueUpdatedAt
|
|
181
|
-
const sinceClause = since ?? '1970-01-01 00:00'
|
|
182
|
-
const jql = `project = ${project.key} AND updated >= "${sinceClause}" ORDER BY updated ASC`
|
|
48
|
+
async saveSyncMeta(meta: Partial<JiraSyncMeta>): Promise<void> {
|
|
49
|
+
saveJiraSyncMeta(this.db, meta)
|
|
50
|
+
}
|
|
183
51
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
let total = Infinity
|
|
188
|
-
let newestUpdatedAt: string | null = meta.lastIssueUpdatedAt
|
|
189
|
-
const seenIssueIds = new Set<string>()
|
|
190
|
-
const issueFields = [
|
|
191
|
-
'summary',
|
|
192
|
-
'description',
|
|
193
|
-
'status',
|
|
194
|
-
'issuetype',
|
|
195
|
-
'priority',
|
|
196
|
-
'assignee',
|
|
197
|
-
'labels',
|
|
198
|
-
'comment',
|
|
199
|
-
'created',
|
|
200
|
-
'updated',
|
|
201
|
-
'project',
|
|
202
|
-
]
|
|
203
|
-
// Terminates when accumulated reaches total, or when the server returns
|
|
204
|
-
// an empty page (defensive against buggy servers not advancing startAt).
|
|
205
|
-
while (accumulated < total) {
|
|
206
|
-
const page = await this.client.listIssues({ jql, startAt, maxResults, fields: issueFields })
|
|
207
|
-
total = page.total
|
|
208
|
-
if (page.issues.length === 0) break
|
|
52
|
+
async loadTeamInfo(): Promise<ProviderTeamInfo | null> {
|
|
53
|
+
return loadTeamInfo(this.db)
|
|
54
|
+
}
|
|
209
55
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
id: issue.id,
|
|
214
|
-
key: issue.key,
|
|
215
|
-
summary: issue.fields.summary,
|
|
216
|
-
descriptionText: issue.fields.description
|
|
217
|
-
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
218
|
-
: '',
|
|
219
|
-
statusId: issue.fields.status.id,
|
|
220
|
-
priorityName: issue.fields.priority?.name ?? null,
|
|
221
|
-
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
222
|
-
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
223
|
-
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
224
|
-
labels: issue.fields.labels ?? [],
|
|
225
|
-
commentCount: issue.fields.comment?.total ?? 0,
|
|
226
|
-
projectKey: issue.fields.project?.key ?? project.key,
|
|
227
|
-
url: `${this.config.baseUrl}/browse/${issue.key}`,
|
|
228
|
-
createdAt: issue.fields.created,
|
|
229
|
-
updatedAt: issue.fields.updated,
|
|
230
|
-
})),
|
|
231
|
-
)
|
|
56
|
+
async saveTeamInfo(team: ProviderTeamInfo | null): Promise<void> {
|
|
57
|
+
saveTeamInfo(this.db, team)
|
|
58
|
+
}
|
|
232
59
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
60
|
+
async replaceColumns(
|
|
61
|
+
columns: Array<{
|
|
62
|
+
id: string
|
|
63
|
+
name: string
|
|
64
|
+
position: number
|
|
65
|
+
statusIds: string[]
|
|
66
|
+
source: 'board' | 'status'
|
|
67
|
+
}>,
|
|
68
|
+
prune: boolean,
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
replaceJiraColumns(this.db, columns, prune)
|
|
71
|
+
}
|
|
239
72
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
await this.ingestIssueActivity(issue.id).catch((err) => {
|
|
246
|
-
// Activity is best-effort; the main sync shouldn't fail if
|
|
247
|
-
// one changelog call 404s or rate-limits.
|
|
248
|
-
console.warn(`[jira] activity fetch for ${issue.key} failed:`, err)
|
|
249
|
-
})
|
|
250
|
-
}
|
|
73
|
+
async upsertUsers(
|
|
74
|
+
users: Array<{ accountId: string; displayName: string; active?: boolean }>,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
upsertJiraUsers(this.db, users)
|
|
77
|
+
}
|
|
251
78
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
79
|
+
async replacePriorities(
|
|
80
|
+
priorities: Array<{ id: string; name: string }>,
|
|
81
|
+
prune: boolean,
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
replaceJiraPriorities(this.db, priorities, prune)
|
|
84
|
+
}
|
|
255
85
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
86
|
+
async replaceIssueTypes(
|
|
87
|
+
types: Array<{ id: string; name: string }>,
|
|
88
|
+
prune: boolean,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
replaceJiraIssueTypes(this.db, types, prune)
|
|
91
|
+
}
|
|
259
92
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
projectKey: project.key,
|
|
263
|
-
boardId: this.config.boardId ?? null,
|
|
264
|
-
lastSyncAt: new Date().toISOString(),
|
|
265
|
-
lastIssueUpdatedAt: newestUpdatedAt ?? new Date().toISOString(),
|
|
266
|
-
}
|
|
267
|
-
if (fullReconcile) {
|
|
268
|
-
nextMeta.lastFullSyncAt = nextMeta.lastSyncAt
|
|
269
|
-
}
|
|
270
|
-
saveJiraSyncMeta(this.db, nextMeta)
|
|
93
|
+
async upsertIssues(issues: Parameters<JiraCachePort['upsertIssues']>[0]): Promise<void> {
|
|
94
|
+
upsertJiraIssues(this.db, issues)
|
|
271
95
|
}
|
|
272
96
|
|
|
273
|
-
|
|
274
|
-
|
|
97
|
+
async deleteIssue(idOrKey: string): Promise<void> {
|
|
98
|
+
deleteJiraIssue(this.db, idOrKey)
|
|
275
99
|
}
|
|
276
100
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const members = cache.users.map((u) => ({
|
|
280
|
-
name: u.displayName,
|
|
281
|
-
role: 'human' as const,
|
|
282
|
-
}))
|
|
283
|
-
const projects = cache.projectKey ? [cache.projectKey] : []
|
|
284
|
-
const discoveredAssignees = (
|
|
285
|
-
this.db
|
|
286
|
-
.query("SELECT DISTINCT assignee_name FROM jira_issues WHERE assignee_name != ''")
|
|
287
|
-
.all() as { assignee_name: string }[]
|
|
288
|
-
)
|
|
289
|
-
.map((r) => r.assignee_name)
|
|
290
|
-
.sort()
|
|
291
|
-
const discoveredProjects = projects.slice()
|
|
292
|
-
return {
|
|
293
|
-
members,
|
|
294
|
-
projects,
|
|
295
|
-
provider: 'jira',
|
|
296
|
-
discoveredAssignees,
|
|
297
|
-
discoveredProjects,
|
|
298
|
-
}
|
|
101
|
+
async pruneIssuesMissingUpstream(projectKey: string, upstreamIssueIds: string[]): Promise<void> {
|
|
102
|
+
pruneJiraIssuesMissingUpstream(this.db, projectKey, upstreamIssueIds)
|
|
299
103
|
}
|
|
300
104
|
|
|
301
|
-
async
|
|
302
|
-
|
|
105
|
+
async adjustIssueCommentCount(idOrKey: string, delta: number): Promise<void> {
|
|
106
|
+
adjustJiraIssueCommentCount(this.db, idOrKey, delta)
|
|
303
107
|
}
|
|
304
108
|
|
|
305
|
-
async
|
|
306
|
-
|
|
307
|
-
return {
|
|
308
|
-
lastSyncAt: meta.lastSyncAt,
|
|
309
|
-
lastFullSyncAt: meta.lastFullSyncAt,
|
|
310
|
-
lastWebhookAt: meta.lastWebhookAt,
|
|
311
|
-
}
|
|
109
|
+
async saveActivity(rows: JiraActivityRow[]): Promise<void> {
|
|
110
|
+
saveJiraActivity(this.db, rows)
|
|
312
111
|
}
|
|
313
112
|
|
|
314
|
-
async
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
team: loadTeamInfo(this.db),
|
|
320
|
-
}
|
|
113
|
+
async getCachedActivity(params?: {
|
|
114
|
+
issueId?: string
|
|
115
|
+
limit?: number
|
|
116
|
+
}): Promise<JiraActivityRow[]> {
|
|
117
|
+
return getCachedActivity(this.db, params)
|
|
321
118
|
}
|
|
322
119
|
|
|
323
|
-
async
|
|
324
|
-
|
|
325
|
-
return {
|
|
326
|
-
provider: 'jira',
|
|
327
|
-
capabilities: JIRA_CAPABILITIES,
|
|
328
|
-
board: getCachedBoard(this.db),
|
|
329
|
-
config: await this.buildBoardConfig(),
|
|
330
|
-
metrics: null,
|
|
331
|
-
activity: [],
|
|
332
|
-
team: loadTeamInfo(this.db),
|
|
333
|
-
}
|
|
120
|
+
async getColumns(): Promise<JiraColumnRow[]> {
|
|
121
|
+
return getCachedColumns(this.db)
|
|
334
122
|
}
|
|
335
123
|
|
|
336
|
-
async
|
|
337
|
-
await this.sync()
|
|
124
|
+
async getCachedBoard(): Promise<BoardView> {
|
|
338
125
|
return getCachedBoard(this.db)
|
|
339
126
|
}
|
|
340
127
|
|
|
341
|
-
async
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
color: null,
|
|
348
|
-
created_at: '',
|
|
349
|
-
updated_at: '',
|
|
350
|
-
}))
|
|
128
|
+
async getCachedTask(lookup: string): Promise<Task | null> {
|
|
129
|
+
return getCachedTask(this.db, lookup)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async getCachedTasks(params?: { columnId?: string }): Promise<Task[]> {
|
|
133
|
+
return getCachedTasks(this.db, params)
|
|
351
134
|
}
|
|
352
135
|
|
|
353
|
-
async
|
|
354
|
-
|
|
355
|
-
const columnId = filters.column ? this.resolveColumnId(filters.column) : undefined
|
|
356
|
-
let tasks = getCachedTasks(this.db, columnId ? { columnId } : undefined)
|
|
357
|
-
if (filters.priority) tasks = tasks.filter((t) => t.priority === filters.priority)
|
|
358
|
-
if (filters.assignee) tasks = tasks.filter((t) => t.assignee === filters.assignee)
|
|
359
|
-
if (filters.project) tasks = tasks.filter((t) => t.project === filters.project)
|
|
360
|
-
if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
|
|
361
|
-
if (filters.sort === 'updated')
|
|
362
|
-
tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
|
363
|
-
if (filters.limit) tasks = tasks.slice(0, filters.limit)
|
|
364
|
-
return tasks
|
|
136
|
+
async getCachedConfig(): Promise<JiraCacheConfig> {
|
|
137
|
+
return getCachedConfig(this.db)
|
|
365
138
|
}
|
|
366
139
|
|
|
367
|
-
async
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
140
|
+
async getDiscoveredAssignees(): Promise<string[]> {
|
|
141
|
+
return (
|
|
142
|
+
this.db
|
|
143
|
+
.query("SELECT DISTINCT assignee_name FROM jira_issues WHERE assignee_name != ''")
|
|
144
|
+
.all() as { assignee_name: string }[]
|
|
145
|
+
)
|
|
146
|
+
.map((r) => r.assignee_name)
|
|
147
|
+
.sort()
|
|
374
148
|
}
|
|
375
149
|
|
|
376
|
-
|
|
377
|
-
const wanted = CANONICAL_TO_JIRA_DEFAULT[canonical]
|
|
150
|
+
async findPriorityName(wanted: string): Promise<string | null> {
|
|
378
151
|
const row = this.db
|
|
379
152
|
.query('SELECT name FROM jira_priorities WHERE LOWER(name) = LOWER($name) LIMIT 1')
|
|
380
153
|
.get({ $name: wanted }) as { name: string } | null
|
|
381
|
-
|
|
382
|
-
|
|
154
|
+
return row?.name ?? null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async getPriorityNames(): Promise<string[]> {
|
|
158
|
+
return (
|
|
383
159
|
this.db.query('SELECT name FROM jira_priorities ORDER BY name').all() as { name: string }[]
|
|
384
160
|
).map((r) => r.name)
|
|
385
|
-
providerUpstreamError(
|
|
386
|
-
`Canonical priority '${canonical}' maps to Jira priority '${wanted}' which is not present in this tenant's priority catalog. Available Jira priorities: [${available
|
|
387
|
-
.map((n) => `"${n}"`)
|
|
388
|
-
.join(', ')}]`,
|
|
389
|
-
)
|
|
390
161
|
}
|
|
391
162
|
|
|
392
|
-
|
|
393
|
-
// resolver is only invoked for non-empty displayName values.
|
|
394
|
-
// Jira Cloud REST only accepts accountId for assignee writes; we never
|
|
395
|
-
// write `emailAddress`.
|
|
396
|
-
private resolveAssigneeAccountId(displayName: string): string {
|
|
163
|
+
async findActiveAssigneeAccountId(displayName: string): Promise<string | null> {
|
|
397
164
|
const row = this.db
|
|
398
165
|
.query(
|
|
399
166
|
'SELECT account_id FROM jira_users WHERE active = 1 AND LOWER(display_name) = LOWER($name) LIMIT 1',
|
|
400
167
|
)
|
|
401
168
|
.get({ $name: displayName }) as { account_id: string } | null
|
|
402
|
-
|
|
403
|
-
providerUpstreamError(
|
|
404
|
-
`Jira assignee '${displayName}' was not found in the cached active user list. Try 'kanban task list --assignee' to see cached names.`,
|
|
405
|
-
)
|
|
169
|
+
return row?.account_id ?? null
|
|
406
170
|
}
|
|
407
171
|
|
|
408
|
-
|
|
172
|
+
async findIssueTypeId(name: string): Promise<string | null> {
|
|
409
173
|
const row = this.db
|
|
410
174
|
.query('SELECT id FROM jira_issue_types WHERE LOWER(name) = LOWER($name) LIMIT 1')
|
|
411
175
|
.get({ $name: name }) as { id: string } | null
|
|
412
|
-
|
|
413
|
-
const available = (
|
|
414
|
-
this.db.query('SELECT name FROM jira_issue_types ORDER BY name').all() as { name: string }[]
|
|
415
|
-
).map((r) => r.name)
|
|
416
|
-
providerUpstreamError(
|
|
417
|
-
`Jira issue type '${name}' is not present in this project's issue-type catalog. Available types: [${available
|
|
418
|
-
.map((n) => `"${n}"`)
|
|
419
|
-
.join(', ')}]`,
|
|
420
|
-
)
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
private normalizeProjectField(input?: string): void {
|
|
424
|
-
if (!input) return
|
|
425
|
-
if (input === this.config.projectKey) return
|
|
426
|
-
unsupportedOperation(
|
|
427
|
-
`JiraProvider is pinned to project '${this.config.projectKey}'. A different project field ('${input}') is not supported.`,
|
|
428
|
-
)
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
private resolveTaskByIdOrKey(idOrRef: string): Task {
|
|
432
|
-
const task = getCachedTask(this.db, idOrRef)
|
|
433
|
-
if (!task) {
|
|
434
|
-
throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${idOrRef}'`)
|
|
435
|
-
}
|
|
436
|
-
return task
|
|
176
|
+
return row?.id ?? null
|
|
437
177
|
}
|
|
438
178
|
|
|
439
|
-
|
|
440
|
-
return
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
private toTaskComment(task: Task, comment: JiraComment): TaskComment {
|
|
444
|
-
const timestamp = comment.updated ?? comment.created ?? task.updated_at
|
|
445
|
-
return {
|
|
446
|
-
id: comment.id,
|
|
447
|
-
task_id: task.id,
|
|
448
|
-
body: comment.body ? adfToPlainText(comment.body as AdfDocument) : '',
|
|
449
|
-
author: comment.author?.displayName ?? null,
|
|
450
|
-
created_at: comment.created ?? timestamp,
|
|
451
|
-
updated_at: timestamp,
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
async createTask(input: CreateTaskInput): Promise<Task> {
|
|
456
|
-
await this.sync()
|
|
457
|
-
this.normalizeProjectField(input.project)
|
|
458
|
-
const issueTypeName = this.config.defaultIssueType ?? 'Task'
|
|
459
|
-
const issueTypeId = this.resolveIssueTypeId(issueTypeName)
|
|
460
|
-
const fields: Record<string, unknown> = {
|
|
461
|
-
project: { key: this.config.projectKey },
|
|
462
|
-
summary: input.title,
|
|
463
|
-
issuetype: { id: issueTypeId },
|
|
464
|
-
}
|
|
465
|
-
if (input.description !== undefined) {
|
|
466
|
-
fields['description'] = plainTextToAdf(input.description)
|
|
467
|
-
}
|
|
468
|
-
if (input.priority !== undefined) {
|
|
469
|
-
fields['priority'] = { name: this.resolveJiraPriorityName(input.priority) }
|
|
470
|
-
}
|
|
471
|
-
if (input.assignee) {
|
|
472
|
-
fields['assignee'] = {
|
|
473
|
-
accountId: this.resolveAssigneeAccountId(input.assignee),
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
const labels = normalizeJiraLabels(input.labels)
|
|
477
|
-
if (labels.length > 0) fields['labels'] = labels
|
|
478
|
-
// Column at create-time is intentionally unsupported in Jira mode: new
|
|
479
|
-
// issues land in the project workflow's default start state. Use
|
|
480
|
-
// `moveTask` after create to change status.
|
|
481
|
-
const created = await this.client.createIssue({ fields })
|
|
482
|
-
await this.sync(true)
|
|
483
|
-
const fresh = getCachedTask(this.db, created.key)
|
|
484
|
-
if (!fresh) {
|
|
485
|
-
providerUpstreamError(
|
|
486
|
-
`Jira issue ${created.key} was created but is not yet visible in the cache after sync.`,
|
|
487
|
-
)
|
|
488
|
-
}
|
|
489
|
-
return fresh
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
|
|
493
|
-
await this.sync()
|
|
494
|
-
this.normalizeProjectField(input.project)
|
|
495
|
-
if (input.metadata !== undefined) {
|
|
496
|
-
unsupportedOperation('Jira mode does not support metadata updates')
|
|
497
|
-
}
|
|
498
|
-
const task = this.resolveTaskByIdOrKey(idOrRef)
|
|
499
|
-
if (input.expectedVersion !== undefined && task.version !== input.expectedVersion) {
|
|
500
|
-
throw new KanbanError(
|
|
501
|
-
ErrorCode.CONFLICT,
|
|
502
|
-
`Jira issue ${task.externalRef ?? idOrRef} was updated remotely (expected version ${input.expectedVersion}, current ${task.version ?? 'unknown'})`,
|
|
503
|
-
)
|
|
504
|
-
}
|
|
505
|
-
const issueKey = this.issueKeyFor(task)
|
|
506
|
-
const fields: Record<string, unknown> = {}
|
|
507
|
-
if (input.title !== undefined) fields['summary'] = input.title
|
|
508
|
-
if (input.description !== undefined) {
|
|
509
|
-
fields['description'] = plainTextToAdf(input.description)
|
|
510
|
-
}
|
|
511
|
-
if (input.priority !== undefined) {
|
|
512
|
-
fields['priority'] = { name: this.resolveJiraPriorityName(input.priority) }
|
|
513
|
-
}
|
|
514
|
-
if (input.assignee !== undefined) {
|
|
515
|
-
// Empty-string sentinel (or null) clears the assignee. Jira PUT body
|
|
516
|
-
// explicitly sends null to unassign; undefined would be stripped.
|
|
517
|
-
fields['assignee'] = input.assignee
|
|
518
|
-
? { accountId: this.resolveAssigneeAccountId(input.assignee) }
|
|
519
|
-
: null
|
|
520
|
-
}
|
|
521
|
-
if (Object.keys(fields).length > 0) {
|
|
522
|
-
await this.client.updateIssue(issueKey, { fields })
|
|
523
|
-
}
|
|
524
|
-
await this.sync(true)
|
|
525
|
-
const fresh = getCachedTask(this.db, issueKey)
|
|
526
|
-
if (!fresh) {
|
|
527
|
-
providerUpstreamError(`Jira issue ${issueKey} disappeared from cache after update.`)
|
|
528
|
-
}
|
|
529
|
-
return fresh
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
async moveTask(idOrRef: string, column: string): Promise<Task> {
|
|
533
|
-
await this.sync()
|
|
534
|
-
const task = this.resolveTaskByIdOrKey(idOrRef)
|
|
535
|
-
return this.moveTaskByKey(this.issueKeyFor(task), column)
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
private async moveTaskByKey(issueKey: string, column: string): Promise<Task> {
|
|
539
|
-
const columnId = this.resolveColumnId(column)
|
|
540
|
-
const columnRow = getCachedColumns(this.db).find((c) => c.id === columnId)
|
|
541
|
-
if (!columnRow) {
|
|
542
|
-
throw new KanbanError(
|
|
543
|
-
ErrorCode.COLUMN_NOT_FOUND,
|
|
544
|
-
`Resolved column '${column}' but cache row missing`,
|
|
545
|
-
)
|
|
546
|
-
}
|
|
547
|
-
const statusIds = decodeColumnStatusIds(columnRow)
|
|
548
|
-
if (statusIds.length === 0) {
|
|
549
|
-
providerUpstreamError(`Column '${columnRow.name}' has no mapped Jira statuses.`)
|
|
550
|
-
}
|
|
551
|
-
// First-mapped-status deterministic choice: board columns can map to
|
|
552
|
-
// multiple Jira statuses; we transition to statusIds[0]. Operators who
|
|
553
|
-
// want a different target must reorder the board column's statuses in Jira.
|
|
554
|
-
const targetStatusId = statusIds[0]!
|
|
555
|
-
const { transitions } = await this.client.getTransitions(issueKey)
|
|
556
|
-
const match = transitions.find((t) => t.to.id === targetStatusId)
|
|
557
|
-
if (!match) {
|
|
558
|
-
const currentStatusId = getCachedTask(this.db, issueKey)?.column_id ?? '<unknown>'
|
|
559
|
-
providerUpstreamError(
|
|
560
|
-
`Cannot transition Jira issue ${issueKey} (current status id ${currentStatusId}) to column '${columnRow.name}' (target status id ${targetStatusId}). Available transitions: [${transitions
|
|
561
|
-
.map((t) => `"${t.name}"`)
|
|
562
|
-
.join(', ')}]`,
|
|
563
|
-
)
|
|
564
|
-
}
|
|
565
|
-
await this.client.transitionIssue(issueKey, match.id)
|
|
566
|
-
await this.sync(true)
|
|
567
|
-
const fresh = getCachedTask(this.db, issueKey)
|
|
568
|
-
if (!fresh) {
|
|
569
|
-
providerUpstreamError(`Jira issue ${issueKey} missing from cache after transition.`)
|
|
570
|
-
}
|
|
571
|
-
return fresh
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
async deleteTask(_idOrRef: string): Promise<Task> {
|
|
575
|
-
unsupportedOperation('Task deletion is not supported in Jira mode')
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
async listComments(idOrRef: string): Promise<TaskComment[]> {
|
|
579
|
-
await this.sync()
|
|
580
|
-
const task = this.resolveTaskByIdOrKey(idOrRef)
|
|
581
|
-
const issueKey = this.issueKeyFor(task)
|
|
582
|
-
const comments: JiraComment[] = []
|
|
583
|
-
let startAt = 0
|
|
584
|
-
|
|
585
|
-
while (true) {
|
|
586
|
-
const page = await this.client.getComments(issueKey, { startAt, maxResults: 100 })
|
|
587
|
-
comments.push(...page.comments)
|
|
588
|
-
startAt += page.comments.length
|
|
589
|
-
if (comments.length >= page.total || page.comments.length === 0) break
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
return comments.map((comment) => this.toTaskComment(task, comment))
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
|
|
596
|
-
await this.sync()
|
|
597
|
-
const task = this.resolveTaskByIdOrKey(idOrRef)
|
|
598
|
-
const comment = await this.client.getComment(this.issueKeyFor(task), commentId)
|
|
599
|
-
return this.toTaskComment(task, comment)
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
async comment(idOrRef: string, body: string): Promise<TaskComment> {
|
|
603
|
-
await this.sync()
|
|
604
|
-
const task = this.resolveTaskByIdOrKey(idOrRef)
|
|
605
|
-
const created = await this.client.addComment(this.issueKeyFor(task), {
|
|
606
|
-
body: plainTextToAdf(body),
|
|
607
|
-
})
|
|
608
|
-
adjustJiraIssueCommentCount(this.db, task.providerId || task.externalRef || task.id, 1)
|
|
609
|
-
return this.toTaskComment(task, created)
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
|
|
613
|
-
await this.sync()
|
|
614
|
-
const task = this.resolveTaskByIdOrKey(idOrRef)
|
|
615
|
-
const updated = await this.client.updateComment(this.issueKeyFor(task), commentId, {
|
|
616
|
-
body: plainTextToAdf(body),
|
|
617
|
-
})
|
|
618
|
-
return this.toTaskComment(task, updated)
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
async getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]> {
|
|
622
|
-
await this.sync()
|
|
623
|
-
const lookupIssueId = taskId ? this.resolveIssueIdFromTaskId(taskId) : undefined
|
|
624
|
-
const rows = getCachedActivity(this.db, {
|
|
625
|
-
...(lookupIssueId !== undefined ? { issueId: lookupIssueId } : {}),
|
|
626
|
-
limit: limit ?? 100,
|
|
627
|
-
})
|
|
628
|
-
return rows.map((row) => this.activityRowToEntry(row))
|
|
179
|
+
async getIssueTypeNames(): Promise<string[]> {
|
|
180
|
+
return (
|
|
181
|
+
this.db.query('SELECT name FROM jira_issue_types ORDER BY name').all() as { name: string }[]
|
|
182
|
+
).map((r) => r.name)
|
|
629
183
|
}
|
|
630
184
|
|
|
631
|
-
|
|
632
|
-
const normalized =
|
|
185
|
+
async resolveIssueId(lookup: string): Promise<string | null> {
|
|
186
|
+
const normalized = lookup.startsWith('jira:') ? lookup.slice('jira:'.length) : lookup
|
|
633
187
|
const row = this.db
|
|
634
188
|
.query<
|
|
635
189
|
{ id: string },
|
|
636
190
|
Record<string, string>
|
|
637
|
-
>(
|
|
191
|
+
>('SELECT id FROM jira_issues WHERE id = $lookup OR key = $lookup LIMIT 1')
|
|
638
192
|
.get({ $lookup: normalized })
|
|
639
|
-
return row?.id
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
private activityRowToEntry(row: JiraActivityRow): ActivityEntry {
|
|
643
|
-
// Map status field items to the same 'moved' shape the local provider
|
|
644
|
-
// emits, so dispatch's collector can trigger uniformly. Translate status
|
|
645
|
-
// ids into column ids via the cached column mapping; fall back to the raw
|
|
646
|
-
// status name for unmapped rows so we never drop activity silently.
|
|
647
|
-
const action: ActivityEntry['action'] = row.item_field === 'status' ? 'moved' : 'updated'
|
|
648
|
-
let fromCol = row.from_value
|
|
649
|
-
let toCol = row.to_value
|
|
650
|
-
if (row.item_field === 'status') {
|
|
651
|
-
fromCol = row.from_value ? (this.statusIdToColumnId(row.from_value) ?? row.from_value) : null
|
|
652
|
-
toCol = row.to_value ? (this.statusIdToColumnId(row.to_value) ?? row.to_value) : null
|
|
653
|
-
}
|
|
654
|
-
return {
|
|
655
|
-
id: `jira-activity:${row.issue_id}:${row.history_id}:${row.item_field}`,
|
|
656
|
-
task_id: `jira:${row.issue_id}`,
|
|
657
|
-
action,
|
|
658
|
-
field_changed: row.item_field,
|
|
659
|
-
old_value: fromCol,
|
|
660
|
-
new_value: toCol,
|
|
661
|
-
timestamp: row.created_at,
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
private statusIdToColumnId(statusId: string): string | undefined {
|
|
666
|
-
const cols = getCachedColumns(this.db)
|
|
667
|
-
for (const col of cols) {
|
|
668
|
-
if (decodeColumnStatusIds(col).includes(statusId)) return col.id
|
|
669
|
-
}
|
|
670
|
-
return undefined
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
private async ingestIssueActivity(issueId: string): Promise<void> {
|
|
674
|
-
const page = await this.client.getChangelog(issueId, { maxResults: 100 })
|
|
675
|
-
const rows: JiraActivityRow[] = []
|
|
676
|
-
for (const entry of page.values) {
|
|
677
|
-
for (const item of entry.items) {
|
|
678
|
-
rows.push({
|
|
679
|
-
issue_id: issueId,
|
|
680
|
-
history_id: entry.id,
|
|
681
|
-
item_field: item.field,
|
|
682
|
-
from_value: item.from ?? null,
|
|
683
|
-
to_value: item.to ?? null,
|
|
684
|
-
created_at: entry.created,
|
|
685
|
-
})
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
saveJiraActivity(this.db, rows)
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
async getMetrics(): Promise<BoardMetrics> {
|
|
692
|
-
unsupportedOperation('Metrics are not available in Jira mode')
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
async getConfig(): Promise<BoardConfig> {
|
|
696
|
-
await this.sync()
|
|
697
|
-
return this.buildBoardConfig()
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
async patchConfig(_input: Partial<BoardConfig>): Promise<BoardConfig> {
|
|
701
|
-
unsupportedOperation('Config mutation is not supported in Jira mode')
|
|
193
|
+
return row?.id ?? null
|
|
702
194
|
}
|
|
195
|
+
}
|
|
703
196
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
if (!verifySha256HmacSignatureHeader(secret, payload.rawBody, sig)) {
|
|
709
|
-
return { handled: false, unauthorized: true, message: 'Invalid signature' }
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
let body: { webhookEvent?: string; issue?: JiraIssue } = {}
|
|
713
|
-
try {
|
|
714
|
-
body = JSON.parse(payload.rawBody) as typeof body
|
|
715
|
-
} catch {
|
|
716
|
-
return { handled: false, message: 'Invalid JSON body' }
|
|
717
|
-
}
|
|
718
|
-
const event = body.webhookEvent ?? ''
|
|
719
|
-
const issue = body.issue
|
|
720
|
-
if (!issue) return { handled: false, message: `No issue in payload (${event})` }
|
|
721
|
-
|
|
722
|
-
if (event === 'jira:issue_deleted') {
|
|
723
|
-
deleteJiraIssue(this.db, issue.id)
|
|
724
|
-
saveJiraSyncMeta(this.db, { lastWebhookAt: new Date().toISOString() })
|
|
725
|
-
return { handled: true }
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
if (event === 'jira:issue_created' || event === 'jira:issue_updated') {
|
|
729
|
-
const projectKey = issue.fields.project?.key
|
|
730
|
-
if (projectKey !== this.config.projectKey) {
|
|
731
|
-
return {
|
|
732
|
-
handled: false,
|
|
733
|
-
message: `Ignoring issue from project '${projectKey ?? 'unknown'}'`,
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
upsertJiraIssues(this.db, [
|
|
737
|
-
{
|
|
738
|
-
id: issue.id,
|
|
739
|
-
key: issue.key,
|
|
740
|
-
summary: issue.fields.summary,
|
|
741
|
-
descriptionText: issue.fields.description
|
|
742
|
-
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
743
|
-
: '',
|
|
744
|
-
statusId: issue.fields.status.id,
|
|
745
|
-
priorityName: issue.fields.priority?.name ?? null,
|
|
746
|
-
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
747
|
-
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
748
|
-
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
749
|
-
labels: issue.fields.labels ?? [],
|
|
750
|
-
commentCount: issue.fields.comment?.total ?? 0,
|
|
751
|
-
projectKey,
|
|
752
|
-
url: `${this.config.baseUrl}/browse/${issue.key}`,
|
|
753
|
-
createdAt: issue.fields.created,
|
|
754
|
-
updatedAt: issue.fields.updated,
|
|
755
|
-
},
|
|
756
|
-
])
|
|
757
|
-
if (event === 'jira:issue_updated') {
|
|
758
|
-
await this.ingestIssueActivity(issue.id).catch((err) => {
|
|
759
|
-
console.warn(`[jira] activity fetch for webhook issue ${issue.key} failed:`, err)
|
|
760
|
-
})
|
|
761
|
-
}
|
|
762
|
-
saveJiraSyncMeta(this.db, { lastWebhookAt: new Date().toISOString() })
|
|
763
|
-
return { handled: true }
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
return { handled: false, message: `Unsupported event: ${event}` }
|
|
197
|
+
export class JiraProvider extends JiraProviderCore {
|
|
198
|
+
constructor(db: Database, config: JiraProviderConfig, client?: JiraClient) {
|
|
199
|
+
initJiraCacheSchema(db)
|
|
200
|
+
super(new SqliteJiraCache(db), config, client)
|
|
767
201
|
}
|
|
768
202
|
}
|