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