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