@andypai/agent-kanban 0.6.0 → 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-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 +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-core.ts +921 -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 +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 -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 +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,35 +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 {
|
|
24
|
-
JiraClient,
|
|
25
|
-
decideJiraPagination,
|
|
26
|
-
normalizeJiraLabels,
|
|
27
|
-
type JiraComment,
|
|
28
|
-
type JiraIssue,
|
|
29
|
-
} from './jira-client'
|
|
2
|
+
import type { BoardView, ProviderTeamInfo, Task } from '../types'
|
|
3
|
+
import type { JiraClient } from './jira-client'
|
|
30
4
|
import {
|
|
31
5
|
adjustJiraIssueCommentCount,
|
|
32
|
-
decodeColumnStatusIds,
|
|
33
6
|
deleteJiraIssue,
|
|
34
7
|
getCachedActivity,
|
|
35
8
|
getCachedBoard,
|
|
@@ -38,8 +11,6 @@ import {
|
|
|
38
11
|
getCachedTask,
|
|
39
12
|
getCachedTasks,
|
|
40
13
|
initJiraCacheSchema,
|
|
41
|
-
jiraBoardColumnRows,
|
|
42
|
-
resolveJiraColumnId,
|
|
43
14
|
loadJiraSyncMeta,
|
|
44
15
|
loadTeamInfo,
|
|
45
16
|
pruneJiraIssuesMissingUpstream,
|
|
@@ -52,799 +23,180 @@ import {
|
|
|
52
23
|
upsertJiraIssues,
|
|
53
24
|
upsertJiraUsers,
|
|
54
25
|
type JiraActivityRow,
|
|
26
|
+
type JiraCacheConfig,
|
|
27
|
+
type JiraColumnRow,
|
|
55
28
|
type JiraSyncMeta,
|
|
56
29
|
} from './jira-cache'
|
|
57
|
-
import type
|
|
58
|
-
CreateTaskInput,
|
|
59
|
-
KanbanProvider,
|
|
60
|
-
ProviderContext,
|
|
61
|
-
ProviderSyncStatus,
|
|
62
|
-
TaskListFilters,
|
|
63
|
-
UpdateTaskInput,
|
|
64
|
-
} from './types'
|
|
65
|
-
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
|
|
30
|
+
import { JiraProviderCore, type JiraCachePort, type JiraProviderConfig } from './jira-core'
|
|
66
31
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
function shouldRunFullReconcile(lastFullSyncAt: string | null, now: number): boolean {
|
|
70
|
-
if (!lastFullSyncAt) return true
|
|
71
|
-
const lastFullSyncAtMs = Date.parse(lastFullSyncAt)
|
|
72
|
-
if (!Number.isFinite(lastFullSyncAtMs)) return true
|
|
73
|
-
return now - lastFullSyncAtMs >= FULL_RECONCILE_INTERVAL_MS
|
|
74
|
-
}
|
|
32
|
+
export type { JiraProviderConfig } from './jira-core'
|
|
75
33
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
medium: 'Medium',
|
|
84
|
-
low: 'Low',
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export interface JiraProviderConfig {
|
|
88
|
-
baseUrl: string
|
|
89
|
-
email: string
|
|
90
|
-
apiToken: string
|
|
91
|
-
projectKey: string
|
|
92
|
-
boardId?: number
|
|
93
|
-
defaultIssueType?: string
|
|
94
|
-
pollingSyncIntervalMs?: number
|
|
95
|
-
}
|
|
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()
|
|
96
41
|
|
|
97
|
-
|
|
98
|
-
readonly type = 'jira' as const
|
|
99
|
-
private readonly client: JiraClient
|
|
100
|
-
private readonly pollingSyncIntervalMs: number
|
|
42
|
+
constructor(private readonly db: Database) {}
|
|
101
43
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
private readonly config: JiraProviderConfig,
|
|
105
|
-
client?: JiraClient,
|
|
106
|
-
) {
|
|
107
|
-
initJiraCacheSchema(db)
|
|
108
|
-
this.pollingSyncIntervalMs = config.pollingSyncIntervalMs ?? DEFAULT_POLLING_SYNC_INTERVAL_MS
|
|
109
|
-
this.client =
|
|
110
|
-
client ??
|
|
111
|
-
new JiraClient({
|
|
112
|
-
baseUrl: config.baseUrl,
|
|
113
|
-
email: config.email,
|
|
114
|
-
apiToken: config.apiToken,
|
|
115
|
-
})
|
|
44
|
+
async loadSyncMeta(): Promise<JiraSyncMeta> {
|
|
45
|
+
return loadJiraSyncMeta(this.db)
|
|
116
46
|
}
|
|
117
47
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const now = Date.now()
|
|
122
|
-
if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
|
|
123
|
-
// `force` only bypasses the poll throttle; it must NOT imply a full
|
|
124
|
-
// 1970-based reconcile, which re-fetches every issue plus a per-issue
|
|
125
|
-
// changelog call (~minutes). Writes get read-after-write freshness from
|
|
126
|
-
// hydrateIssueByKey instead, so a forced sync stays a cheap delta.
|
|
127
|
-
const fullReconcile = shouldRunFullReconcile(meta.lastFullSyncAt, now)
|
|
128
|
-
|
|
129
|
-
// 1. Resolve project.
|
|
130
|
-
const project = await this.client.getProject(this.config.projectKey)
|
|
131
|
-
saveTeamInfo(this.db, { id: project.id, key: project.key, name: project.name })
|
|
132
|
-
|
|
133
|
-
// 2. Columns: board path OR status fallback path.
|
|
134
|
-
if (this.config.boardId !== undefined) {
|
|
135
|
-
const boardCfg = await this.client.getBoardColumns(this.config.boardId)
|
|
136
|
-
const boardId = this.config.boardId
|
|
137
|
-
const rows = jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns)
|
|
138
|
-
replaceJiraColumns(this.db, rows)
|
|
139
|
-
} else {
|
|
140
|
-
const statusCats = await this.client.getProjectStatuses(project.key)
|
|
141
|
-
const seen = new Set<string>()
|
|
142
|
-
const uniqueStatuses: Array<{ id: string; name: string }> = []
|
|
143
|
-
for (const cat of statusCats) {
|
|
144
|
-
for (const s of cat.statuses) {
|
|
145
|
-
if (seen.has(s.id)) continue
|
|
146
|
-
seen.add(s.id)
|
|
147
|
-
uniqueStatuses.push({ id: s.id, name: s.name })
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
const rows = uniqueStatuses.map((s, i) => ({
|
|
151
|
-
id: `status:${s.id}`,
|
|
152
|
-
name: s.name,
|
|
153
|
-
position: i,
|
|
154
|
-
statusIds: [s.id],
|
|
155
|
-
source: 'status' as const,
|
|
156
|
-
}))
|
|
157
|
-
replaceJiraColumns(this.db, rows)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// 3. Catalogs: users + priorities + issue types in parallel.
|
|
161
|
-
// NOTE: listAssignableUsers is capped at 100 in T04; tenants with more
|
|
162
|
-
// assignable users are truncated. Pagination is out of scope for this pass.
|
|
163
|
-
const [users, priorities, issueTypes] = await Promise.all([
|
|
164
|
-
this.client.listAssignableUsers({
|
|
165
|
-
projectKey: project.key,
|
|
166
|
-
startAt: 0,
|
|
167
|
-
maxResults: 100,
|
|
168
|
-
}),
|
|
169
|
-
this.client.listPriorities(),
|
|
170
|
-
this.client.listIssueTypes({ projectId: project.id }),
|
|
171
|
-
])
|
|
172
|
-
upsertJiraUsers(
|
|
173
|
-
this.db,
|
|
174
|
-
users.map((u) => ({
|
|
175
|
-
accountId: u.accountId,
|
|
176
|
-
displayName: u.displayName,
|
|
177
|
-
active: u.active ?? true,
|
|
178
|
-
})),
|
|
179
|
-
)
|
|
180
|
-
replaceJiraPriorities(
|
|
181
|
-
this.db,
|
|
182
|
-
priorities.map((p) => ({ id: p.id, name: p.name })),
|
|
183
|
-
)
|
|
184
|
-
replaceJiraIssueTypes(
|
|
185
|
-
this.db,
|
|
186
|
-
issueTypes.map((t) => ({ id: t.id, name: t.name })),
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
// 4. Delta issue fetch (paginated).
|
|
190
|
-
const since = fullReconcile ? null : meta.lastIssueUpdatedAt
|
|
191
|
-
const sinceClause = since ?? '1970-01-01 00:00'
|
|
192
|
-
const jql = `project = ${project.key} AND updated >= "${sinceClause}" ORDER BY updated ASC`
|
|
193
|
-
|
|
194
|
-
const maxResults = 100
|
|
195
|
-
let newestUpdatedAt: string | null = meta.lastIssueUpdatedAt
|
|
196
|
-
const seenIssueIds = new Set<string>()
|
|
197
|
-
const issueFields = [
|
|
198
|
-
'summary',
|
|
199
|
-
'description',
|
|
200
|
-
'status',
|
|
201
|
-
'issuetype',
|
|
202
|
-
'priority',
|
|
203
|
-
'assignee',
|
|
204
|
-
'labels',
|
|
205
|
-
'comment',
|
|
206
|
-
'created',
|
|
207
|
-
'updated',
|
|
208
|
-
'project',
|
|
209
|
-
]
|
|
210
|
-
// /rest/api/3/search/jql paginates by an opaque nextPageToken and omits
|
|
211
|
-
// `total`, so we follow the cursor until the server reports `isLast` or stops
|
|
212
|
-
// handing back a token. The previous total/startAt loop fetched only the first
|
|
213
|
-
// page (oldest 100 by updated ASC) once `total` came back undefined, so every
|
|
214
|
-
// issue beyond the first page — including newly-created tickets on a project
|
|
215
|
-
// with >100 issues — was never cached. seenPageTokens guards against a server
|
|
216
|
-
// that repeats a cursor, which would otherwise spin this loop forever and hang
|
|
217
|
-
// the poll cycle; an empty page is not treated as terminal because a non-last
|
|
218
|
-
// page may legitimately carry a token with zero issues.
|
|
219
|
-
const seenPageTokens = new Set<string>()
|
|
220
|
-
let nextPageToken: string | undefined
|
|
221
|
-
let firstPage = true
|
|
222
|
-
let paginationComplete = false
|
|
223
|
-
while (firstPage || nextPageToken !== undefined) {
|
|
224
|
-
firstPage = false
|
|
225
|
-
const page = await this.client.listIssues({
|
|
226
|
-
jql,
|
|
227
|
-
startAt: 0,
|
|
228
|
-
maxResults,
|
|
229
|
-
fields: issueFields,
|
|
230
|
-
nextPageToken,
|
|
231
|
-
})
|
|
48
|
+
async saveSyncMeta(meta: Partial<JiraSyncMeta>): Promise<void> {
|
|
49
|
+
saveJiraSyncMeta(this.db, meta)
|
|
50
|
+
}
|
|
232
51
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
id: issue.id,
|
|
237
|
-
key: issue.key,
|
|
238
|
-
summary: issue.fields.summary,
|
|
239
|
-
descriptionText: issue.fields.description
|
|
240
|
-
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
241
|
-
: '',
|
|
242
|
-
statusId: issue.fields.status.id,
|
|
243
|
-
priorityName: issue.fields.priority?.name ?? null,
|
|
244
|
-
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
245
|
-
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
246
|
-
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
247
|
-
labels: issue.fields.labels ?? [],
|
|
248
|
-
commentCount: issue.fields.comment?.total ?? 0,
|
|
249
|
-
projectKey: issue.fields.project?.key ?? project.key,
|
|
250
|
-
url: `${this.config.baseUrl}/browse/${issue.key}`,
|
|
251
|
-
createdAt: issue.fields.created,
|
|
252
|
-
updatedAt: issue.fields.updated,
|
|
253
|
-
})),
|
|
254
|
-
)
|
|
52
|
+
async loadTeamInfo(): Promise<ProviderTeamInfo | null> {
|
|
53
|
+
return loadTeamInfo(this.db)
|
|
54
|
+
}
|
|
255
55
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
newestUpdatedAt = issue.fields.updated
|
|
260
|
-
}
|
|
261
|
-
}
|
|
56
|
+
async saveTeamInfo(team: ProviderTeamInfo | null): Promise<void> {
|
|
57
|
+
saveTeamInfo(this.db, team)
|
|
58
|
+
}
|
|
262
59
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
+
}
|
|
274
72
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
281
|
-
paginationComplete = decision.complete
|
|
282
|
-
if (!paginationComplete) {
|
|
283
|
-
// The server stopped advancing the cursor before reporting a definitive
|
|
284
|
-
// end (stalled/contradictory). seenIssueIds is only a partial scan, so we
|
|
285
|
-
// must not treat it as authoritative for pruning below.
|
|
286
|
-
console.warn(
|
|
287
|
-
`[jira] search/jql scan ended without a definitive last page; treating as incomplete`,
|
|
288
|
-
)
|
|
289
|
-
}
|
|
290
|
-
break
|
|
291
|
-
}
|
|
73
|
+
async upsertUsers(
|
|
74
|
+
users: Array<{ accountId: string; displayName: string; active?: boolean }>,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
upsertJiraUsers(this.db, users)
|
|
77
|
+
}
|
|
292
78
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
// exist upstream on pages we never fetched — and the metadata advance.
|
|
300
|
-
return
|
|
301
|
-
}
|
|
79
|
+
async replacePriorities(
|
|
80
|
+
priorities: Array<{ id: string; name: string }>,
|
|
81
|
+
prune: boolean,
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
replaceJiraPriorities(this.db, priorities, prune)
|
|
84
|
+
}
|
|
302
85
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
86
|
+
async replaceIssueTypes(
|
|
87
|
+
types: Array<{ id: string; name: string }>,
|
|
88
|
+
prune: boolean,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
replaceJiraIssueTypes(this.db, types, prune)
|
|
91
|
+
}
|
|
309
92
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
projectKey: project.key,
|
|
313
|
-
boardId: this.config.boardId ?? null,
|
|
314
|
-
lastSyncAt: new Date().toISOString(),
|
|
315
|
-
lastIssueUpdatedAt: newestUpdatedAt ?? new Date().toISOString(),
|
|
316
|
-
}
|
|
317
|
-
if (fullReconcile) {
|
|
318
|
-
nextMeta.lastFullSyncAt = nextMeta.lastSyncAt
|
|
319
|
-
}
|
|
320
|
-
saveJiraSyncMeta(this.db, nextMeta)
|
|
93
|
+
async upsertIssues(issues: Parameters<JiraCachePort['upsertIssues']>[0]): Promise<void> {
|
|
94
|
+
upsertJiraIssues(this.db, issues)
|
|
321
95
|
}
|
|
322
96
|
|
|
323
|
-
|
|
324
|
-
|
|
97
|
+
async deleteIssue(idOrKey: string): Promise<void> {
|
|
98
|
+
deleteJiraIssue(this.db, idOrKey)
|
|
325
99
|
}
|
|
326
100
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const members = cache.users.map((u) => ({
|
|
330
|
-
name: u.displayName,
|
|
331
|
-
role: 'human' as const,
|
|
332
|
-
}))
|
|
333
|
-
const projects = cache.projectKey ? [cache.projectKey] : []
|
|
334
|
-
const discoveredAssignees = (
|
|
335
|
-
this.db
|
|
336
|
-
.query("SELECT DISTINCT assignee_name FROM jira_issues WHERE assignee_name != ''")
|
|
337
|
-
.all() as { assignee_name: string }[]
|
|
338
|
-
)
|
|
339
|
-
.map((r) => r.assignee_name)
|
|
340
|
-
.sort()
|
|
341
|
-
const discoveredProjects = projects.slice()
|
|
342
|
-
return {
|
|
343
|
-
members,
|
|
344
|
-
projects,
|
|
345
|
-
provider: 'jira',
|
|
346
|
-
discoveredAssignees,
|
|
347
|
-
discoveredProjects,
|
|
348
|
-
}
|
|
101
|
+
async pruneIssuesMissingUpstream(projectKey: string, upstreamIssueIds: string[]): Promise<void> {
|
|
102
|
+
pruneJiraIssuesMissingUpstream(this.db, projectKey, upstreamIssueIds)
|
|
349
103
|
}
|
|
350
104
|
|
|
351
|
-
async
|
|
352
|
-
|
|
105
|
+
async adjustIssueCommentCount(idOrKey: string, delta: number): Promise<void> {
|
|
106
|
+
adjustJiraIssueCommentCount(this.db, idOrKey, delta)
|
|
353
107
|
}
|
|
354
108
|
|
|
355
|
-
async
|
|
356
|
-
|
|
357
|
-
return {
|
|
358
|
-
lastSyncAt: meta.lastSyncAt,
|
|
359
|
-
lastFullSyncAt: meta.lastFullSyncAt,
|
|
360
|
-
lastWebhookAt: meta.lastWebhookAt,
|
|
361
|
-
}
|
|
109
|
+
async saveActivity(rows: JiraActivityRow[]): Promise<void> {
|
|
110
|
+
saveJiraActivity(this.db, rows)
|
|
362
111
|
}
|
|
363
112
|
|
|
364
|
-
async
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
team: loadTeamInfo(this.db),
|
|
370
|
-
}
|
|
113
|
+
async getCachedActivity(params?: {
|
|
114
|
+
issueId?: string
|
|
115
|
+
limit?: number
|
|
116
|
+
}): Promise<JiraActivityRow[]> {
|
|
117
|
+
return getCachedActivity(this.db, params)
|
|
371
118
|
}
|
|
372
119
|
|
|
373
|
-
async
|
|
374
|
-
|
|
375
|
-
return {
|
|
376
|
-
provider: 'jira',
|
|
377
|
-
capabilities: JIRA_CAPABILITIES,
|
|
378
|
-
board: getCachedBoard(this.db),
|
|
379
|
-
config: await this.buildBoardConfig(),
|
|
380
|
-
metrics: null,
|
|
381
|
-
activity: [],
|
|
382
|
-
team: loadTeamInfo(this.db),
|
|
383
|
-
}
|
|
120
|
+
async getColumns(): Promise<JiraColumnRow[]> {
|
|
121
|
+
return getCachedColumns(this.db)
|
|
384
122
|
}
|
|
385
123
|
|
|
386
|
-
async
|
|
387
|
-
await this.sync()
|
|
124
|
+
async getCachedBoard(): Promise<BoardView> {
|
|
388
125
|
return getCachedBoard(this.db)
|
|
389
126
|
}
|
|
390
127
|
|
|
391
|
-
async
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
color: null,
|
|
398
|
-
created_at: '',
|
|
399
|
-
updated_at: '',
|
|
400
|
-
}))
|
|
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)
|
|
401
134
|
}
|
|
402
135
|
|
|
403
|
-
async
|
|
404
|
-
|
|
405
|
-
const columnId = filters.column ? this.resolveColumnId(filters.column) : undefined
|
|
406
|
-
let tasks = getCachedTasks(this.db, columnId ? { columnId } : undefined)
|
|
407
|
-
if (filters.priority) tasks = tasks.filter((t) => t.priority === filters.priority)
|
|
408
|
-
if (filters.assignee) tasks = tasks.filter((t) => t.assignee === filters.assignee)
|
|
409
|
-
if (filters.project) tasks = tasks.filter((t) => t.project === filters.project)
|
|
410
|
-
if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
|
|
411
|
-
if (filters.sort === 'updated')
|
|
412
|
-
tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
|
413
|
-
if (filters.limit) tasks = tasks.slice(0, filters.limit)
|
|
414
|
-
return tasks
|
|
136
|
+
async getCachedConfig(): Promise<JiraCacheConfig> {
|
|
137
|
+
return getCachedConfig(this.db)
|
|
415
138
|
}
|
|
416
139
|
|
|
417
|
-
async
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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()
|
|
424
148
|
}
|
|
425
149
|
|
|
426
|
-
|
|
427
|
-
const wanted = CANONICAL_TO_JIRA_DEFAULT[canonical]
|
|
150
|
+
async findPriorityName(wanted: string): Promise<string | null> {
|
|
428
151
|
const row = this.db
|
|
429
152
|
.query('SELECT name FROM jira_priorities WHERE LOWER(name) = LOWER($name) LIMIT 1')
|
|
430
153
|
.get({ $name: wanted }) as { name: string } | null
|
|
431
|
-
|
|
432
|
-
|
|
154
|
+
return row?.name ?? null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async getPriorityNames(): Promise<string[]> {
|
|
158
|
+
return (
|
|
433
159
|
this.db.query('SELECT name FROM jira_priorities ORDER BY name').all() as { name: string }[]
|
|
434
160
|
).map((r) => r.name)
|
|
435
|
-
providerUpstreamError(
|
|
436
|
-
`Canonical priority '${canonical}' maps to Jira priority '${wanted}' which is not present in this tenant's priority catalog. Available Jira priorities: [${available
|
|
437
|
-
.map((n) => `"${n}"`)
|
|
438
|
-
.join(', ')}]`,
|
|
439
|
-
)
|
|
440
161
|
}
|
|
441
162
|
|
|
442
|
-
|
|
443
|
-
// resolver is only invoked for non-empty displayName values.
|
|
444
|
-
// Jira Cloud REST only accepts accountId for assignee writes; we never
|
|
445
|
-
// write `emailAddress`.
|
|
446
|
-
private resolveAssigneeAccountId(displayName: string): string {
|
|
163
|
+
async findActiveAssigneeAccountId(displayName: string): Promise<string | null> {
|
|
447
164
|
const row = this.db
|
|
448
165
|
.query(
|
|
449
166
|
'SELECT account_id FROM jira_users WHERE active = 1 AND LOWER(display_name) = LOWER($name) LIMIT 1',
|
|
450
167
|
)
|
|
451
168
|
.get({ $name: displayName }) as { account_id: string } | null
|
|
452
|
-
|
|
453
|
-
providerUpstreamError(
|
|
454
|
-
`Jira assignee '${displayName}' was not found in the cached active user list. Try 'kanban task list --assignee' to see cached names.`,
|
|
455
|
-
)
|
|
169
|
+
return row?.account_id ?? null
|
|
456
170
|
}
|
|
457
171
|
|
|
458
|
-
|
|
172
|
+
async findIssueTypeId(name: string): Promise<string | null> {
|
|
459
173
|
const row = this.db
|
|
460
174
|
.query('SELECT id FROM jira_issue_types WHERE LOWER(name) = LOWER($name) LIMIT 1')
|
|
461
175
|
.get({ $name: name }) as { id: string } | null
|
|
462
|
-
|
|
463
|
-
const available = (
|
|
464
|
-
this.db.query('SELECT name FROM jira_issue_types ORDER BY name').all() as { name: string }[]
|
|
465
|
-
).map((r) => r.name)
|
|
466
|
-
providerUpstreamError(
|
|
467
|
-
`Jira issue type '${name}' is not present in this project's issue-type catalog. Available types: [${available
|
|
468
|
-
.map((n) => `"${n}"`)
|
|
469
|
-
.join(', ')}]`,
|
|
470
|
-
)
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
private normalizeProjectField(input?: string): void {
|
|
474
|
-
if (!input) return
|
|
475
|
-
if (input === this.config.projectKey) return
|
|
476
|
-
unsupportedOperation(
|
|
477
|
-
`JiraProvider is pinned to project '${this.config.projectKey}'. A different project field ('${input}') is not supported.`,
|
|
478
|
-
)
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
private resolveTaskByIdOrKey(idOrRef: string): Task {
|
|
482
|
-
const task = getCachedTask(this.db, idOrRef)
|
|
483
|
-
if (!task) {
|
|
484
|
-
throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${idOrRef}'`)
|
|
485
|
-
}
|
|
486
|
-
return task
|
|
176
|
+
return row?.id ?? null
|
|
487
177
|
}
|
|
488
178
|
|
|
489
|
-
|
|
490
|
-
return
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
private toTaskComment(task: Task, comment: JiraComment): TaskComment {
|
|
494
|
-
const timestamp = comment.updated ?? comment.created ?? task.updated_at
|
|
495
|
-
return {
|
|
496
|
-
id: comment.id,
|
|
497
|
-
task_id: task.id,
|
|
498
|
-
body: comment.body ? adfToPlainText(comment.body as AdfDocument) : '',
|
|
499
|
-
author: comment.author?.displayName ?? null,
|
|
500
|
-
created_at: comment.created ?? timestamp,
|
|
501
|
-
updated_at: timestamp,
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Read-after-write via the direct issue endpoint. Unlike JQL search (used by
|
|
506
|
-
// sync()), GET /issue/{key} has no search-index lag, so a just-created or
|
|
507
|
-
// just-transitioned issue is reflected immediately. This replaces the previous
|
|
508
|
-
// "create/transition then sync(true) then getCachedTask" pattern, which raced
|
|
509
|
-
// the search index (creates reported "not yet visible"; a move's new status
|
|
510
|
-
// sometimes didn't land) and forced a full whole-project reconcile per write.
|
|
511
|
-
private async hydrateIssueByKey(key: string): Promise<Task> {
|
|
512
|
-
// getIssue throws on a missing key (404), so reaching this method means the
|
|
513
|
-
// issue exists upstream. A null read-back would therefore be a genuine cache
|
|
514
|
-
// anomaly (the upsert above did not land), not an ordinary not-found — surface
|
|
515
|
-
// it rather than threading an unreachable null through every caller.
|
|
516
|
-
const issue = await this.client.getIssue(key)
|
|
517
|
-
upsertJiraIssues(this.db, [
|
|
518
|
-
{
|
|
519
|
-
id: issue.id,
|
|
520
|
-
key: issue.key,
|
|
521
|
-
summary: issue.fields.summary,
|
|
522
|
-
descriptionText: issue.fields.description
|
|
523
|
-
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
524
|
-
: '',
|
|
525
|
-
statusId: issue.fields.status.id,
|
|
526
|
-
priorityName: issue.fields.priority?.name ?? null,
|
|
527
|
-
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
528
|
-
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
529
|
-
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
530
|
-
labels: issue.fields.labels ?? [],
|
|
531
|
-
commentCount: issue.fields.comment?.total ?? 0,
|
|
532
|
-
projectKey: issue.fields.project?.key ?? this.config.projectKey,
|
|
533
|
-
url: `${this.config.baseUrl}/browse/${issue.key}`,
|
|
534
|
-
createdAt: issue.fields.created,
|
|
535
|
-
updatedAt: issue.fields.updated,
|
|
536
|
-
},
|
|
537
|
-
])
|
|
538
|
-
// Ingest the changelog like the sync loop does, so a just-applied transition
|
|
539
|
-
// is recorded in jira_activity immediately (backs getActivity and the
|
|
540
|
-
// poll-based `moved` trigger) rather than waiting for the next unthrottled
|
|
541
|
-
// sync. Best-effort: activity must not fail the mutation.
|
|
542
|
-
await this.ingestIssueActivity(issue.id).catch((err) => {
|
|
543
|
-
console.warn(`[jira] activity fetch for ${issue.key} failed:`, err)
|
|
544
|
-
})
|
|
545
|
-
const task = getCachedTask(this.db, key)
|
|
546
|
-
if (!task) {
|
|
547
|
-
providerUpstreamError(
|
|
548
|
-
`Jira issue ${key} was hydrated from GET /issue but is missing from the cache.`,
|
|
549
|
-
)
|
|
550
|
-
}
|
|
551
|
-
return task
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
async createTask(input: CreateTaskInput): Promise<Task> {
|
|
555
|
-
await this.sync()
|
|
556
|
-
this.normalizeProjectField(input.project)
|
|
557
|
-
const issueTypeName = this.config.defaultIssueType ?? 'Task'
|
|
558
|
-
const issueTypeId = this.resolveIssueTypeId(issueTypeName)
|
|
559
|
-
const fields: Record<string, unknown> = {
|
|
560
|
-
project: { key: this.config.projectKey },
|
|
561
|
-
summary: input.title,
|
|
562
|
-
issuetype: { id: issueTypeId },
|
|
563
|
-
}
|
|
564
|
-
if (input.description !== undefined) {
|
|
565
|
-
fields['description'] = plainTextToAdf(input.description)
|
|
566
|
-
}
|
|
567
|
-
if (input.priority !== undefined) {
|
|
568
|
-
fields['priority'] = { name: this.resolveJiraPriorityName(input.priority) }
|
|
569
|
-
}
|
|
570
|
-
if (input.assignee) {
|
|
571
|
-
fields['assignee'] = {
|
|
572
|
-
accountId: this.resolveAssigneeAccountId(input.assignee),
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
const labels = normalizeJiraLabels(input.labels)
|
|
576
|
-
if (labels.length > 0) fields['labels'] = labels
|
|
577
|
-
// Column at create-time is intentionally unsupported in Jira mode: new
|
|
578
|
-
// issues land in the project workflow's default start state. Use
|
|
579
|
-
// `moveTask` after create to change status.
|
|
580
|
-
const created = await this.client.createIssue({ fields })
|
|
581
|
-
return this.hydrateIssueByKey(created.key)
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
|
|
585
|
-
await this.sync()
|
|
586
|
-
this.normalizeProjectField(input.project)
|
|
587
|
-
if (input.metadata !== undefined) {
|
|
588
|
-
unsupportedOperation('Jira mode does not support metadata updates')
|
|
589
|
-
}
|
|
590
|
-
const task = this.resolveTaskByIdOrKey(idOrRef)
|
|
591
|
-
if (input.expectedVersion !== undefined && task.version !== input.expectedVersion) {
|
|
592
|
-
throw new KanbanError(
|
|
593
|
-
ErrorCode.CONFLICT,
|
|
594
|
-
`Jira issue ${task.externalRef ?? idOrRef} was updated remotely (expected version ${input.expectedVersion}, current ${task.version ?? 'unknown'})`,
|
|
595
|
-
)
|
|
596
|
-
}
|
|
597
|
-
const issueKey = this.issueKeyFor(task)
|
|
598
|
-
const fields: Record<string, unknown> = {}
|
|
599
|
-
if (input.title !== undefined) fields['summary'] = input.title
|
|
600
|
-
if (input.description !== undefined) {
|
|
601
|
-
fields['description'] = plainTextToAdf(input.description)
|
|
602
|
-
}
|
|
603
|
-
if (input.priority !== undefined) {
|
|
604
|
-
fields['priority'] = { name: this.resolveJiraPriorityName(input.priority) }
|
|
605
|
-
}
|
|
606
|
-
if (input.assignee !== undefined) {
|
|
607
|
-
// Empty-string sentinel (or null) clears the assignee. Jira PUT body
|
|
608
|
-
// explicitly sends null to unassign; undefined would be stripped.
|
|
609
|
-
fields['assignee'] = input.assignee
|
|
610
|
-
? { accountId: this.resolveAssigneeAccountId(input.assignee) }
|
|
611
|
-
: null
|
|
612
|
-
}
|
|
613
|
-
if (Object.keys(fields).length > 0) {
|
|
614
|
-
await this.client.updateIssue(issueKey, { fields })
|
|
615
|
-
}
|
|
616
|
-
return this.hydrateIssueByKey(issueKey)
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
async moveTask(idOrRef: string, column: string): Promise<Task> {
|
|
620
|
-
await this.sync()
|
|
621
|
-
const task = this.resolveTaskByIdOrKey(idOrRef)
|
|
622
|
-
return this.moveTaskByKey(this.issueKeyFor(task), column)
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
private async moveTaskByKey(issueKey: string, column: string): Promise<Task> {
|
|
626
|
-
const columnId = this.resolveColumnId(column)
|
|
627
|
-
const columnRow = getCachedColumns(this.db).find((c) => c.id === columnId)
|
|
628
|
-
if (!columnRow) {
|
|
629
|
-
throw new KanbanError(
|
|
630
|
-
ErrorCode.COLUMN_NOT_FOUND,
|
|
631
|
-
`Resolved column '${column}' but cache row missing`,
|
|
632
|
-
)
|
|
633
|
-
}
|
|
634
|
-
const statusIds = decodeColumnStatusIds(columnRow)
|
|
635
|
-
if (statusIds.length === 0) {
|
|
636
|
-
providerUpstreamError(`Column '${columnRow.name}' has no mapped Jira statuses.`)
|
|
637
|
-
}
|
|
638
|
-
// First-mapped-status deterministic choice: board columns can map to
|
|
639
|
-
// multiple Jira statuses; we transition to statusIds[0]. Operators who
|
|
640
|
-
// want a different target must reorder the board column's statuses in Jira.
|
|
641
|
-
const targetStatusId = statusIds[0]!
|
|
642
|
-
const { transitions } = await this.client.getTransitions(issueKey)
|
|
643
|
-
const match = transitions.find((t) => t.to.id === targetStatusId)
|
|
644
|
-
if (!match) {
|
|
645
|
-
const currentStatusId = getCachedTask(this.db, issueKey)?.column_id ?? '<unknown>'
|
|
646
|
-
providerUpstreamError(
|
|
647
|
-
`Cannot transition Jira issue ${issueKey} (current status id ${currentStatusId}) to column '${columnRow.name}' (target status id ${targetStatusId}). Available transitions: [${transitions
|
|
648
|
-
.map((t) => `"${t.name}"`)
|
|
649
|
-
.join(', ')}]`,
|
|
650
|
-
)
|
|
651
|
-
}
|
|
652
|
-
await this.client.transitionIssue(issueKey, match.id)
|
|
653
|
-
return this.hydrateIssueByKey(issueKey)
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
async deleteTask(_idOrRef: string): Promise<Task> {
|
|
657
|
-
unsupportedOperation('Task deletion is not supported in Jira mode')
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
async listComments(idOrRef: string): Promise<TaskComment[]> {
|
|
661
|
-
await this.sync()
|
|
662
|
-
const task = this.resolveTaskByIdOrKey(idOrRef)
|
|
663
|
-
const issueKey = this.issueKeyFor(task)
|
|
664
|
-
const comments: JiraComment[] = []
|
|
665
|
-
let startAt = 0
|
|
666
|
-
|
|
667
|
-
while (true) {
|
|
668
|
-
const page = await this.client.getComments(issueKey, { startAt, maxResults: 100 })
|
|
669
|
-
comments.push(...page.comments)
|
|
670
|
-
startAt += page.comments.length
|
|
671
|
-
if (comments.length >= page.total || page.comments.length === 0) break
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
return comments.map((comment) => this.toTaskComment(task, comment))
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
|
|
678
|
-
await this.sync()
|
|
679
|
-
const task = this.resolveTaskByIdOrKey(idOrRef)
|
|
680
|
-
const comment = await this.client.getComment(this.issueKeyFor(task), commentId)
|
|
681
|
-
return this.toTaskComment(task, comment)
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
async comment(idOrRef: string, body: string): Promise<TaskComment> {
|
|
685
|
-
await this.sync()
|
|
686
|
-
const task = this.resolveTaskByIdOrKey(idOrRef)
|
|
687
|
-
const created = await this.client.addComment(this.issueKeyFor(task), {
|
|
688
|
-
body: plainTextToAdf(body),
|
|
689
|
-
})
|
|
690
|
-
adjustJiraIssueCommentCount(this.db, task.providerId || task.externalRef || task.id, 1)
|
|
691
|
-
return this.toTaskComment(task, created)
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
|
|
695
|
-
await this.sync()
|
|
696
|
-
const task = this.resolveTaskByIdOrKey(idOrRef)
|
|
697
|
-
const updated = await this.client.updateComment(this.issueKeyFor(task), commentId, {
|
|
698
|
-
body: plainTextToAdf(body),
|
|
699
|
-
})
|
|
700
|
-
return this.toTaskComment(task, updated)
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
async getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]> {
|
|
704
|
-
await this.sync()
|
|
705
|
-
const lookupIssueId = taskId ? this.resolveIssueIdFromTaskId(taskId) : undefined
|
|
706
|
-
const rows = getCachedActivity(this.db, {
|
|
707
|
-
...(lookupIssueId !== undefined ? { issueId: lookupIssueId } : {}),
|
|
708
|
-
limit: limit ?? 100,
|
|
709
|
-
})
|
|
710
|
-
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)
|
|
711
183
|
}
|
|
712
184
|
|
|
713
|
-
|
|
714
|
-
const normalized =
|
|
185
|
+
async resolveIssueId(lookup: string): Promise<string | null> {
|
|
186
|
+
const normalized = lookup.startsWith('jira:') ? lookup.slice('jira:'.length) : lookup
|
|
715
187
|
const row = this.db
|
|
716
188
|
.query<
|
|
717
189
|
{ id: string },
|
|
718
190
|
Record<string, string>
|
|
719
|
-
>(
|
|
191
|
+
>('SELECT id FROM jira_issues WHERE id = $lookup OR key = $lookup LIMIT 1')
|
|
720
192
|
.get({ $lookup: normalized })
|
|
721
|
-
return row?.id
|
|
193
|
+
return row?.id ?? null
|
|
722
194
|
}
|
|
195
|
+
}
|
|
723
196
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
// status name for unmapped rows so we never drop activity silently.
|
|
729
|
-
const action: ActivityEntry['action'] = row.item_field === 'status' ? 'moved' : 'updated'
|
|
730
|
-
let fromCol = row.from_value
|
|
731
|
-
let toCol = row.to_value
|
|
732
|
-
if (row.item_field === 'status') {
|
|
733
|
-
fromCol = row.from_value ? (this.statusIdToColumnId(row.from_value) ?? row.from_value) : null
|
|
734
|
-
toCol = row.to_value ? (this.statusIdToColumnId(row.to_value) ?? row.to_value) : null
|
|
735
|
-
}
|
|
736
|
-
return {
|
|
737
|
-
id: `jira-activity:${row.issue_id}:${row.history_id}:${row.item_field}`,
|
|
738
|
-
task_id: `jira:${row.issue_id}`,
|
|
739
|
-
action,
|
|
740
|
-
field_changed: row.item_field,
|
|
741
|
-
old_value: fromCol,
|
|
742
|
-
new_value: toCol,
|
|
743
|
-
timestamp: row.created_at,
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
private statusIdToColumnId(statusId: string): string | undefined {
|
|
748
|
-
const cols = getCachedColumns(this.db)
|
|
749
|
-
for (const col of cols) {
|
|
750
|
-
if (decodeColumnStatusIds(col).includes(statusId)) return col.id
|
|
751
|
-
}
|
|
752
|
-
return undefined
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
private async ingestIssueActivity(issueId: string): Promise<void> {
|
|
756
|
-
const page = await this.client.getChangelog(issueId, { maxResults: 100 })
|
|
757
|
-
const rows: JiraActivityRow[] = []
|
|
758
|
-
for (const entry of page.values) {
|
|
759
|
-
for (const item of entry.items) {
|
|
760
|
-
rows.push({
|
|
761
|
-
issue_id: issueId,
|
|
762
|
-
history_id: entry.id,
|
|
763
|
-
item_field: item.field,
|
|
764
|
-
from_value: item.from ?? null,
|
|
765
|
-
to_value: item.to ?? null,
|
|
766
|
-
created_at: entry.created,
|
|
767
|
-
})
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
saveJiraActivity(this.db, rows)
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
async getMetrics(): Promise<BoardMetrics> {
|
|
774
|
-
unsupportedOperation('Metrics are not available in Jira mode')
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
async getConfig(): Promise<BoardConfig> {
|
|
778
|
-
await this.sync()
|
|
779
|
-
return this.buildBoardConfig()
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
async patchConfig(_input: Partial<BoardConfig>): Promise<BoardConfig> {
|
|
783
|
-
unsupportedOperation('Config mutation is not supported in Jira mode')
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
|
|
787
|
-
const secret = process.env['JIRA_WEBHOOK_SECRET']
|
|
788
|
-
if (secret) {
|
|
789
|
-
const sig = headerLower(payload.headers, 'x-hub-signature')
|
|
790
|
-
if (!verifySha256HmacSignatureHeader(secret, payload.rawBody, sig)) {
|
|
791
|
-
return { handled: false, unauthorized: true, message: 'Invalid signature' }
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
let body: { webhookEvent?: string; issue?: JiraIssue } = {}
|
|
795
|
-
try {
|
|
796
|
-
body = JSON.parse(payload.rawBody) as typeof body
|
|
797
|
-
} catch {
|
|
798
|
-
return { handled: false, message: 'Invalid JSON body' }
|
|
799
|
-
}
|
|
800
|
-
const event = body.webhookEvent ?? ''
|
|
801
|
-
const issue = body.issue
|
|
802
|
-
if (!issue) return { handled: false, message: `No issue in payload (${event})` }
|
|
803
|
-
|
|
804
|
-
if (event === 'jira:issue_deleted') {
|
|
805
|
-
deleteJiraIssue(this.db, issue.id)
|
|
806
|
-
saveJiraSyncMeta(this.db, { lastWebhookAt: new Date().toISOString() })
|
|
807
|
-
return { handled: true }
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
if (event === 'jira:issue_created' || event === 'jira:issue_updated') {
|
|
811
|
-
const projectKey = issue.fields.project?.key
|
|
812
|
-
if (projectKey !== this.config.projectKey) {
|
|
813
|
-
return {
|
|
814
|
-
handled: false,
|
|
815
|
-
message: `Ignoring issue from project '${projectKey ?? 'unknown'}'`,
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
upsertJiraIssues(this.db, [
|
|
819
|
-
{
|
|
820
|
-
id: issue.id,
|
|
821
|
-
key: issue.key,
|
|
822
|
-
summary: issue.fields.summary,
|
|
823
|
-
descriptionText: issue.fields.description
|
|
824
|
-
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
825
|
-
: '',
|
|
826
|
-
statusId: issue.fields.status.id,
|
|
827
|
-
priorityName: issue.fields.priority?.name ?? null,
|
|
828
|
-
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
829
|
-
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
830
|
-
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
831
|
-
labels: issue.fields.labels ?? [],
|
|
832
|
-
commentCount: issue.fields.comment?.total ?? 0,
|
|
833
|
-
projectKey,
|
|
834
|
-
url: `${this.config.baseUrl}/browse/${issue.key}`,
|
|
835
|
-
createdAt: issue.fields.created,
|
|
836
|
-
updatedAt: issue.fields.updated,
|
|
837
|
-
},
|
|
838
|
-
])
|
|
839
|
-
if (event === 'jira:issue_updated') {
|
|
840
|
-
await this.ingestIssueActivity(issue.id).catch((err) => {
|
|
841
|
-
console.warn(`[jira] activity fetch for webhook issue ${issue.key} failed:`, err)
|
|
842
|
-
})
|
|
843
|
-
}
|
|
844
|
-
saveJiraSyncMeta(this.db, { lastWebhookAt: new Date().toISOString() })
|
|
845
|
-
return { handled: true }
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
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)
|
|
849
201
|
}
|
|
850
202
|
}
|