@andypai/agent-kanban 0.1.0 → 0.3.0
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 +89 -22
- package/package.json +4 -2
- package/src/__tests__/activity.test.ts +15 -9
- package/src/__tests__/api.test.ts +96 -0
- package/src/__tests__/board-utils.test.ts +100 -0
- package/src/__tests__/commands/board.test.ts +6 -13
- package/src/__tests__/conflict.test.ts +64 -0
- package/src/__tests__/index.test.ts +233 -56
- package/src/__tests__/jira-adf.test.ts +168 -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 +493 -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__/server.test.ts +298 -0
- package/src/__tests__/webhooks.test.ts +604 -0
- package/src/activity.ts +1 -11
- package/src/api.ts +154 -19
- package/src/commands/board.ts +1 -11
- package/src/commands/mcp.ts +87 -0
- package/src/db.ts +115 -3
- package/src/errors.ts +2 -0
- package/src/id.ts +1 -1
- package/src/index.ts +72 -18
- 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/providers/capabilities.ts +15 -0
- package/src/providers/index.ts +31 -1
- 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 +778 -0
- package/src/providers/linear-cache.ts +249 -70
- package/src/providers/linear-client.ts +256 -13
- package/src/providers/linear.ts +337 -14
- package/src/providers/local.ts +68 -17
- package/src/providers/types.ts +18 -2
- package/src/server.ts +139 -11
- package/src/tunnel.ts +79 -0
- package/src/types.ts +18 -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 +8 -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-DEnUD0fq.css +0 -1
- package/ui/dist/assets/index-DMRjw1nI.js +0 -40
package/src/providers/linear.ts
CHANGED
|
@@ -6,34 +6,62 @@ import type {
|
|
|
6
6
|
BoardConfig,
|
|
7
7
|
BoardMetrics,
|
|
8
8
|
Column,
|
|
9
|
+
TaskComment,
|
|
9
10
|
Task,
|
|
10
11
|
} from '../types.ts'
|
|
12
|
+
import {
|
|
13
|
+
headerLower,
|
|
14
|
+
verifyHmacSha256,
|
|
15
|
+
type WebhookRequest,
|
|
16
|
+
type WebhookResult,
|
|
17
|
+
} from '../webhooks.ts'
|
|
11
18
|
import { LINEAR_CAPABILITIES } from './capabilities.ts'
|
|
12
19
|
import {
|
|
20
|
+
adjustLinearIssueCommentCount,
|
|
21
|
+
deleteLinearIssue,
|
|
13
22
|
getCachedBoard,
|
|
14
23
|
getCachedColumns,
|
|
15
24
|
getCachedConfig,
|
|
25
|
+
getCachedLinearActivity,
|
|
16
26
|
getCachedTask,
|
|
17
27
|
getCachedTasks,
|
|
18
28
|
initLinearCacheSchema,
|
|
19
29
|
loadSyncMeta,
|
|
30
|
+
pruneLinearIssues,
|
|
20
31
|
replaceStates,
|
|
32
|
+
saveLinearActivity,
|
|
21
33
|
saveSyncMeta,
|
|
22
34
|
upsertIssues,
|
|
23
35
|
upsertProjects,
|
|
24
36
|
upsertUsers,
|
|
37
|
+
type LinearActivityRow,
|
|
25
38
|
} from './linear-cache.ts'
|
|
26
|
-
import { LinearClient } from './linear-client.ts'
|
|
39
|
+
import { LinearClient, type LinearComment } from './linear-client.ts'
|
|
27
40
|
import { unsupportedOperation } from './errors.ts'
|
|
28
41
|
import type {
|
|
29
42
|
CreateTaskInput,
|
|
30
43
|
KanbanProvider,
|
|
31
44
|
ProviderContext,
|
|
45
|
+
ProviderSyncStatus,
|
|
32
46
|
TaskListFilters,
|
|
33
47
|
UpdateTaskInput,
|
|
34
48
|
} from './types.ts'
|
|
35
49
|
|
|
36
50
|
const SYNC_INTERVAL_MS = 30_000
|
|
51
|
+
const FULL_RECONCILIATION_INTERVAL_MS = 5 * 60_000
|
|
52
|
+
|
|
53
|
+
function parseTimestamp(value: string | null | undefined): number {
|
|
54
|
+
if (!value) return 0
|
|
55
|
+
const parsed = Date.parse(value)
|
|
56
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function maxTimestamp(a: string | null | undefined, b: string | null | undefined): string | null {
|
|
60
|
+
const aMs = parseTimestamp(a)
|
|
61
|
+
const bMs = parseTimestamp(b)
|
|
62
|
+
if (!aMs && !bMs) return null
|
|
63
|
+
return aMs >= bMs ? (a ?? null) : (b ?? null)
|
|
64
|
+
}
|
|
37
65
|
|
|
38
66
|
function toLinearPriority(priority: Task['priority'] | undefined): number | undefined {
|
|
39
67
|
switch (priority) {
|
|
@@ -63,18 +91,40 @@ export class LinearProvider implements KanbanProvider {
|
|
|
63
91
|
this.client = new LinearClient(apiKey)
|
|
64
92
|
}
|
|
65
93
|
|
|
94
|
+
private resolvedTeamId(): string {
|
|
95
|
+
return loadSyncMeta(this.db).team?.id ?? this.teamId
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async getConfiguredTeam(): Promise<{ id: string; key: string; name: string }> {
|
|
99
|
+
const metaTeam = loadSyncMeta(this.db).team
|
|
100
|
+
if (metaTeam) return metaTeam
|
|
101
|
+
|
|
102
|
+
const team = await this.client.getTeam(this.teamId)
|
|
103
|
+
const configuredTeam = { id: team.id, key: team.key, name: team.name }
|
|
104
|
+
saveSyncMeta(this.db, { team: configuredTeam })
|
|
105
|
+
return configuredTeam
|
|
106
|
+
}
|
|
107
|
+
|
|
66
108
|
private async sync(force = false): Promise<void> {
|
|
67
109
|
const meta = loadSyncMeta(this.db)
|
|
68
|
-
const lastSyncAtMs =
|
|
69
|
-
|
|
110
|
+
const lastSyncAtMs = parseTimestamp(meta.lastSyncAt)
|
|
111
|
+
const lastFullSyncAtMs = parseTimestamp(meta.lastFullSyncAt)
|
|
112
|
+
const now = Date.now()
|
|
113
|
+
if (!force && lastSyncAtMs && now - lastSyncAtMs < SYNC_INTERVAL_MS) return
|
|
70
114
|
|
|
71
|
-
const
|
|
72
|
-
|
|
115
|
+
const shouldFullSync =
|
|
116
|
+
force ||
|
|
117
|
+
!lastFullSyncAtMs ||
|
|
118
|
+
!meta.lastIssueUpdatedAt ||
|
|
119
|
+
now - lastFullSyncAtMs >= FULL_RECONCILIATION_INTERVAL_MS
|
|
120
|
+
|
|
121
|
+
const team = await this.client.getTeam(this.teamId)
|
|
122
|
+
const [users, projects, issues] = await Promise.all([
|
|
73
123
|
this.client.listUsers(),
|
|
74
124
|
this.client.listProjects(),
|
|
75
125
|
this.client.listIssues(
|
|
76
|
-
|
|
77
|
-
|
|
126
|
+
team.id,
|
|
127
|
+
shouldFullSync ? undefined : (meta.lastIssueUpdatedAt ?? undefined),
|
|
78
128
|
),
|
|
79
129
|
])
|
|
80
130
|
|
|
@@ -96,27 +146,93 @@ export class LinearProvider implements KanbanProvider {
|
|
|
96
146
|
stateId: issue.state.id,
|
|
97
147
|
stateName: issue.state.name,
|
|
98
148
|
statePosition: issue.state.position,
|
|
149
|
+
labels: issue.labels ?? [],
|
|
150
|
+
commentCount: issue.commentCount,
|
|
99
151
|
url: issue.url ?? null,
|
|
100
152
|
createdAt: issue.createdAt,
|
|
101
153
|
updatedAt: issue.updatedAt,
|
|
102
154
|
})),
|
|
103
155
|
)
|
|
156
|
+
if (shouldFullSync) {
|
|
157
|
+
pruneLinearIssues(
|
|
158
|
+
this.db,
|
|
159
|
+
issues.map((issue) => issue.id),
|
|
160
|
+
)
|
|
161
|
+
}
|
|
104
162
|
|
|
105
|
-
const newestIssueTimestamp =
|
|
163
|
+
const newestIssueTimestamp = maxTimestamp(
|
|
164
|
+
meta.lastIssueUpdatedAt,
|
|
106
165
|
issues.length > 0
|
|
107
166
|
? issues.reduce(
|
|
108
167
|
(latest, issue) => (issue.updatedAt > latest ? issue.updatedAt : latest),
|
|
109
168
|
issues[0]!.updatedAt,
|
|
110
169
|
)
|
|
111
|
-
:
|
|
170
|
+
: null,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
// Best-effort changelog ingest; failures don't fail the main sync.
|
|
174
|
+
await this.ingestTeamHistory(
|
|
175
|
+
issues.map((issue) => issue.id),
|
|
176
|
+
meta.lastIssueUpdatedAt,
|
|
177
|
+
).catch((err) => {
|
|
178
|
+
console.warn('[linear] issueHistory ingest failed:', err)
|
|
179
|
+
})
|
|
112
180
|
|
|
181
|
+
const syncedAt = new Date().toISOString()
|
|
113
182
|
saveSyncMeta(this.db, {
|
|
114
183
|
team: { id: team.id, key: team.key, name: team.name },
|
|
115
|
-
lastSyncAt:
|
|
116
|
-
|
|
184
|
+
lastSyncAt: syncedAt,
|
|
185
|
+
lastFullSyncAt: shouldFullSync ? syncedAt : undefined,
|
|
186
|
+
lastIssueUpdatedAt: newestIssueTimestamp ?? syncedAt,
|
|
117
187
|
})
|
|
118
188
|
}
|
|
119
189
|
|
|
190
|
+
private async ingestTeamHistory(issueIds: string[], sinceIso: string | null): Promise<void> {
|
|
191
|
+
if (issueIds.length === 0) return
|
|
192
|
+
const concurrency = 5
|
|
193
|
+
for (let i = 0; i < issueIds.length; i += concurrency) {
|
|
194
|
+
const batch = issueIds.slice(i, i + concurrency)
|
|
195
|
+
const results = await Promise.all(
|
|
196
|
+
batch.map((issueId) => this.fetchIssueHistory(issueId, sinceIso)),
|
|
197
|
+
)
|
|
198
|
+
const rows = results.flat()
|
|
199
|
+
if (rows.length > 0) saveLinearActivity(this.db, rows)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async fetchIssueHistory(
|
|
204
|
+
issueId: string,
|
|
205
|
+
sinceIso: string | null,
|
|
206
|
+
): Promise<LinearActivityRow[]> {
|
|
207
|
+
const rows: LinearActivityRow[] = []
|
|
208
|
+
let cursor: string | null = null
|
|
209
|
+
for (let page = 0; page < 10; page++) {
|
|
210
|
+
const batch = await this.client.listIssueHistory({ issueId, first: 50, after: cursor })
|
|
211
|
+
let reachedKnown = false
|
|
212
|
+
for (const node of batch.nodes) {
|
|
213
|
+
// Linear returns history newest-first; once we hit an entry we've already ingested,
|
|
214
|
+
// every subsequent page is older still, so break out of pagination entirely.
|
|
215
|
+
if (sinceIso && node.createdAt <= sinceIso) {
|
|
216
|
+
reachedKnown = true
|
|
217
|
+
break
|
|
218
|
+
}
|
|
219
|
+
if (!node.fromState && !node.toState) continue
|
|
220
|
+
rows.push({
|
|
221
|
+
issue_id: issueId,
|
|
222
|
+
history_id: node.id,
|
|
223
|
+
item_field: 'state',
|
|
224
|
+
from_value: node.fromState?.id ?? null,
|
|
225
|
+
to_value: node.toState?.id ?? null,
|
|
226
|
+
created_at: node.createdAt,
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
if (reachedKnown) break
|
|
230
|
+
if (!batch.pageInfo.hasNextPage || !batch.pageInfo.endCursor) break
|
|
231
|
+
cursor = batch.pageInfo.endCursor
|
|
232
|
+
}
|
|
233
|
+
return rows
|
|
234
|
+
}
|
|
235
|
+
|
|
120
236
|
private resolveTask(idOrRef: string): Task {
|
|
121
237
|
const task = getCachedTask(this.db, idOrRef)
|
|
122
238
|
if (!task) {
|
|
@@ -155,6 +271,30 @@ export class LinearProvider implements KanbanProvider {
|
|
|
155
271
|
return row?.id
|
|
156
272
|
}
|
|
157
273
|
|
|
274
|
+
private toTaskComment(task: Task, comment: LinearComment): TaskComment {
|
|
275
|
+
return {
|
|
276
|
+
id: comment.id,
|
|
277
|
+
task_id: task.id,
|
|
278
|
+
body: comment.body,
|
|
279
|
+
author: comment.user?.displayName || comment.user?.name || null,
|
|
280
|
+
created_at: comment.createdAt,
|
|
281
|
+
updated_at: comment.updatedAt,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async syncCache(): Promise<void> {
|
|
286
|
+
await this.sync()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async getSyncStatus(): Promise<ProviderSyncStatus> {
|
|
290
|
+
const meta = loadSyncMeta(this.db)
|
|
291
|
+
return {
|
|
292
|
+
lastSyncAt: meta.lastSyncAt,
|
|
293
|
+
lastFullSyncAt: meta.lastFullSyncAt,
|
|
294
|
+
lastWebhookAt: meta.lastWebhookAt,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
158
298
|
async getContext(): Promise<ProviderContext> {
|
|
159
299
|
await this.sync()
|
|
160
300
|
const meta = loadSyncMeta(this.db)
|
|
@@ -214,7 +354,7 @@ export class LinearProvider implements KanbanProvider {
|
|
|
214
354
|
await this.sync()
|
|
215
355
|
const state = input.column ? this.resolveState(input.column) : undefined
|
|
216
356
|
const result = await this.client.createIssue({
|
|
217
|
-
teamId: this.
|
|
357
|
+
teamId: this.resolvedTeamId(),
|
|
218
358
|
stateId: state?.id,
|
|
219
359
|
title: input.title,
|
|
220
360
|
description: input.description,
|
|
@@ -240,6 +380,8 @@ export class LinearProvider implements KanbanProvider {
|
|
|
240
380
|
stateId: issue.state.id,
|
|
241
381
|
stateName: issue.state.name,
|
|
242
382
|
statePosition: issue.state.position,
|
|
383
|
+
labels: issue.labels ?? [],
|
|
384
|
+
commentCount: issue.commentCount,
|
|
243
385
|
url: issue.url ?? null,
|
|
244
386
|
createdAt: issue.createdAt,
|
|
245
387
|
updatedAt: issue.updatedAt,
|
|
@@ -251,6 +393,12 @@ export class LinearProvider implements KanbanProvider {
|
|
|
251
393
|
async updateTask(idOrRef: string, input: UpdateTaskInput) {
|
|
252
394
|
await this.sync()
|
|
253
395
|
const task = this.resolveTask(idOrRef)
|
|
396
|
+
if (input.expectedVersion !== undefined && task.version !== input.expectedVersion) {
|
|
397
|
+
throw new KanbanError(
|
|
398
|
+
ErrorCode.CONFLICT,
|
|
399
|
+
`Linear issue ${task.externalRef ?? idOrRef} was updated remotely (expected version ${input.expectedVersion}, current ${task.version ?? 'unknown'})`,
|
|
400
|
+
)
|
|
401
|
+
}
|
|
254
402
|
const updateInput: Record<string, unknown> = {}
|
|
255
403
|
if (input.title !== undefined) updateInput['title'] = input.title
|
|
256
404
|
if (input.description !== undefined) updateInput['description'] = input.description
|
|
@@ -286,8 +434,75 @@ export class LinearProvider implements KanbanProvider {
|
|
|
286
434
|
unsupportedOperation('Task deletion is not supported in Linear mode')
|
|
287
435
|
}
|
|
288
436
|
|
|
289
|
-
async
|
|
290
|
-
|
|
437
|
+
async listComments(idOrRef: string): Promise<TaskComment[]> {
|
|
438
|
+
await this.sync()
|
|
439
|
+
const task = this.resolveTask(idOrRef)
|
|
440
|
+
const comments = await this.client.listComments(task.providerId || task.id)
|
|
441
|
+
return comments.map((comment) => this.toTaskComment(task, comment))
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
|
|
445
|
+
await this.sync()
|
|
446
|
+
const task = this.resolveTask(idOrRef)
|
|
447
|
+
const comment = await this.client.getComment(commentId)
|
|
448
|
+
return this.toTaskComment(task, comment)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async comment(idOrRef: string, body: string): Promise<TaskComment> {
|
|
452
|
+
await this.sync()
|
|
453
|
+
const task = this.resolveTask(idOrRef)
|
|
454
|
+
const result = await this.client.commentCreate(task.providerId || task.id, body)
|
|
455
|
+
if (!result.success || !result.comment) {
|
|
456
|
+
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear comment creation failed')
|
|
457
|
+
}
|
|
458
|
+
adjustLinearIssueCommentCount(this.db, task.providerId || task.id, 1)
|
|
459
|
+
return this.toTaskComment(task, result.comment)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
|
|
463
|
+
await this.sync()
|
|
464
|
+
const task = this.resolveTask(idOrRef)
|
|
465
|
+
const result = await this.client.commentUpdate(commentId, body)
|
|
466
|
+
if (!result.success || !result.comment) {
|
|
467
|
+
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear comment update failed')
|
|
468
|
+
}
|
|
469
|
+
return this.toTaskComment(task, result.comment)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]> {
|
|
473
|
+
await this.sync()
|
|
474
|
+
const issueId = taskId ? this.resolveIssueIdFromTaskId(taskId) : undefined
|
|
475
|
+
const rows = getCachedLinearActivity(this.db, {
|
|
476
|
+
...(issueId !== undefined ? { issueId } : {}),
|
|
477
|
+
limit: limit ?? 100,
|
|
478
|
+
})
|
|
479
|
+
return rows.map((row) => this.activityRowToEntry(row))
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private resolveIssueIdFromTaskId(taskId: string): string | undefined {
|
|
483
|
+
const normalized = taskId.startsWith('linear:') ? taskId.slice('linear:'.length) : taskId
|
|
484
|
+
const row = this.db
|
|
485
|
+
.query<
|
|
486
|
+
{ id: string },
|
|
487
|
+
Record<string, string>
|
|
488
|
+
>(`SELECT id FROM linear_issues WHERE id = $lookup OR identifier = $lookup LIMIT 1`)
|
|
489
|
+
.get({ $lookup: normalized })
|
|
490
|
+
return row?.id
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private activityRowToEntry(row: LinearActivityRow): ActivityEntry {
|
|
494
|
+
// fromState/toState already reference state ids which agent-kanban
|
|
495
|
+
// surfaces 1:1 as column ids (see linear_states/getCachedColumns),
|
|
496
|
+
// so no lookup is needed here.
|
|
497
|
+
return {
|
|
498
|
+
id: `linear-activity:${row.issue_id}:${row.history_id}:${row.item_field}`,
|
|
499
|
+
task_id: `linear:${row.issue_id}`,
|
|
500
|
+
action: row.item_field === 'state' ? 'moved' : 'updated',
|
|
501
|
+
field_changed: row.item_field,
|
|
502
|
+
old_value: row.from_value,
|
|
503
|
+
new_value: row.to_value,
|
|
504
|
+
timestamp: row.created_at,
|
|
505
|
+
}
|
|
291
506
|
}
|
|
292
507
|
|
|
293
508
|
async getMetrics(): Promise<BoardMetrics> {
|
|
@@ -302,4 +517,112 @@ export class LinearProvider implements KanbanProvider {
|
|
|
302
517
|
async patchConfig(_input: Partial<BoardConfig>): Promise<BoardConfig> {
|
|
303
518
|
unsupportedOperation('Config mutation is not supported in Linear mode')
|
|
304
519
|
}
|
|
520
|
+
|
|
521
|
+
async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
|
|
522
|
+
const secret = process.env['LINEAR_WEBHOOK_SECRET']
|
|
523
|
+
if (secret) {
|
|
524
|
+
const sig = headerLower(payload.headers, 'linear-signature')
|
|
525
|
+
if (!verifyHmacSha256(secret, payload.rawBody, sig)) {
|
|
526
|
+
return { handled: false, unauthorized: true, message: 'Invalid signature' }
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
let body: {
|
|
530
|
+
action?: 'create' | 'update' | 'remove'
|
|
531
|
+
type?: string
|
|
532
|
+
data?: {
|
|
533
|
+
id: string
|
|
534
|
+
identifier?: string
|
|
535
|
+
title?: string
|
|
536
|
+
description?: string | null
|
|
537
|
+
priority?: number | null
|
|
538
|
+
url?: string | null
|
|
539
|
+
createdAt?: string
|
|
540
|
+
updatedAt?: string
|
|
541
|
+
assignee?: { id: string; name?: string | null } | null
|
|
542
|
+
assigneeId?: string | null
|
|
543
|
+
project?: { id: string; name: string } | null
|
|
544
|
+
projectId?: string | null
|
|
545
|
+
state?: { id: string; name: string; position?: number } | null
|
|
546
|
+
stateId?: string | null
|
|
547
|
+
team?: { id?: string | null; key?: string | null } | null
|
|
548
|
+
teamId?: string | null
|
|
549
|
+
labels?: Array<{ id: string; name: string }> | null
|
|
550
|
+
commentCount?: number | null
|
|
551
|
+
}
|
|
552
|
+
} = {}
|
|
553
|
+
try {
|
|
554
|
+
body = JSON.parse(payload.rawBody) as typeof body
|
|
555
|
+
} catch {
|
|
556
|
+
return { handled: false, message: 'Invalid JSON body' }
|
|
557
|
+
}
|
|
558
|
+
if (body.type !== 'Issue') {
|
|
559
|
+
return { handled: false, message: `Ignoring ${body.type ?? 'unknown'} event` }
|
|
560
|
+
}
|
|
561
|
+
const data = body.data
|
|
562
|
+
if (!data) return { handled: false, message: 'No data in payload' }
|
|
563
|
+
|
|
564
|
+
if (body.action === 'remove') {
|
|
565
|
+
deleteLinearIssue(this.db, data.id)
|
|
566
|
+
saveSyncMeta(this.db, { lastWebhookAt: new Date().toISOString() })
|
|
567
|
+
return { handled: true }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (body.action === 'create' || body.action === 'update') {
|
|
571
|
+
const configuredTeam = await this.getConfiguredTeam()
|
|
572
|
+
const payloadTeamId = data.team?.id ?? data.teamId ?? null
|
|
573
|
+
if (payloadTeamId && payloadTeamId !== configuredTeam.id) {
|
|
574
|
+
return {
|
|
575
|
+
handled: false,
|
|
576
|
+
message: `Ignoring issue from team '${payloadTeamId}'`,
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (!payloadTeamId) {
|
|
581
|
+
const issueTeam = await this.client.getIssueTeam(data.id)
|
|
582
|
+
if (!issueTeam) {
|
|
583
|
+
return {
|
|
584
|
+
handled: false,
|
|
585
|
+
message: `Ignoring issue '${data.id}' because its team could not be verified`,
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (issueTeam.id !== configuredTeam.id) {
|
|
589
|
+
return {
|
|
590
|
+
handled: false,
|
|
591
|
+
message: `Ignoring issue from team '${issueTeam.key}'`,
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (!data.identifier || !data.title || !data.createdAt || !data.updatedAt) {
|
|
597
|
+
return { handled: false, message: 'Missing required issue fields' }
|
|
598
|
+
}
|
|
599
|
+
const stateId = data.state?.id ?? data.stateId ?? null
|
|
600
|
+
if (!stateId) return { handled: false, message: 'Missing state id' }
|
|
601
|
+
upsertIssues(this.db, [
|
|
602
|
+
{
|
|
603
|
+
id: data.id,
|
|
604
|
+
identifier: data.identifier,
|
|
605
|
+
title: data.title,
|
|
606
|
+
description: data.description ?? '',
|
|
607
|
+
priority: data.priority ?? 0,
|
|
608
|
+
assigneeId: data.assignee?.id ?? data.assigneeId ?? null,
|
|
609
|
+
assigneeName: data.assignee?.name ?? null,
|
|
610
|
+
projectId: data.project?.id ?? data.projectId ?? null,
|
|
611
|
+
projectName: data.project?.name ?? null,
|
|
612
|
+
stateId,
|
|
613
|
+
stateName: data.state?.name ?? '',
|
|
614
|
+
statePosition: data.state?.position ?? 0,
|
|
615
|
+
labels: (data.labels ?? []).map((l) => l.name),
|
|
616
|
+
commentCount: data.commentCount,
|
|
617
|
+
url: data.url ?? null,
|
|
618
|
+
createdAt: data.createdAt,
|
|
619
|
+
updatedAt: data.updatedAt,
|
|
620
|
+
},
|
|
621
|
+
])
|
|
622
|
+
saveSyncMeta(this.db, { lastWebhookAt: new Date().toISOString() })
|
|
623
|
+
return { handled: true }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return { handled: false, message: `Unsupported action: ${body.action}` }
|
|
627
|
+
}
|
|
305
628
|
}
|
package/src/providers/local.ts
CHANGED
|
@@ -2,23 +2,31 @@ import type { Database } from 'bun:sqlite'
|
|
|
2
2
|
import { listActivity } from '../activity.ts'
|
|
3
3
|
import { getConfigPath, loadConfig, saveConfig } from '../config.ts'
|
|
4
4
|
import {
|
|
5
|
+
addComment,
|
|
6
|
+
countComments,
|
|
7
|
+
countCommentsByTask,
|
|
5
8
|
addTask,
|
|
6
9
|
deleteTask,
|
|
7
10
|
getBoardView,
|
|
11
|
+
getComment as getTaskComment,
|
|
8
12
|
getDbPath,
|
|
9
13
|
getTask,
|
|
14
|
+
listComments as listTaskComments,
|
|
10
15
|
listColumns,
|
|
11
16
|
listTasks,
|
|
12
17
|
moveTask,
|
|
18
|
+
updateComment as updateTaskComment,
|
|
13
19
|
updateTask,
|
|
14
20
|
} from '../db.ts'
|
|
15
21
|
import { getBoardMetrics, getDiscoveredAssignees, getDiscoveredProjects } from '../metrics.ts'
|
|
16
|
-
import type { BoardBootstrap, BoardConfig, Task } from '../types.ts'
|
|
22
|
+
import type { BoardBootstrap, BoardConfig, Task, TaskComment } from '../types.ts'
|
|
23
|
+
import { ErrorCode, KanbanError } from '../errors.ts'
|
|
17
24
|
import { LOCAL_CAPABILITIES } from './capabilities.ts'
|
|
18
25
|
import type {
|
|
19
26
|
CreateTaskInput,
|
|
20
27
|
KanbanProvider,
|
|
21
28
|
ProviderContext,
|
|
29
|
+
ProviderSyncStatus,
|
|
22
30
|
TaskListFilters,
|
|
23
31
|
UpdateTaskInput,
|
|
24
32
|
} from './types.ts'
|
|
@@ -37,15 +45,6 @@ function buildLocalConfig(
|
|
|
37
45
|
}
|
|
38
46
|
}
|
|
39
47
|
|
|
40
|
-
function enrichTask(task: Task): Task {
|
|
41
|
-
return {
|
|
42
|
-
...task,
|
|
43
|
-
providerId: task.id,
|
|
44
|
-
externalRef: task.id,
|
|
45
|
-
url: null,
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
48
|
export class LocalProvider implements KanbanProvider {
|
|
50
49
|
readonly type = 'local' as const
|
|
51
50
|
|
|
@@ -54,6 +53,22 @@ export class LocalProvider implements KanbanProvider {
|
|
|
54
53
|
private readonly dbPath = getDbPath(),
|
|
55
54
|
) {}
|
|
56
55
|
|
|
56
|
+
private enrichTask(task: Task, commentCount?: number): Task {
|
|
57
|
+
const revision = task.revision ?? 0
|
|
58
|
+
const assignees = task.assignee ? [task.assignee] : []
|
|
59
|
+
return {
|
|
60
|
+
...task,
|
|
61
|
+
providerId: task.id,
|
|
62
|
+
externalRef: task.id,
|
|
63
|
+
url: null,
|
|
64
|
+
assignees,
|
|
65
|
+
labels: [],
|
|
66
|
+
comment_count: commentCount ?? countComments(this.db, task.id),
|
|
67
|
+
version: String(revision),
|
|
68
|
+
source_updated_at: null,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
async getContext(): Promise<ProviderContext> {
|
|
58
73
|
return {
|
|
59
74
|
provider: this.type,
|
|
@@ -77,10 +92,11 @@ export class LocalProvider implements KanbanProvider {
|
|
|
77
92
|
|
|
78
93
|
async getBoard() {
|
|
79
94
|
const board = getBoardView(this.db)
|
|
95
|
+
const counts = countCommentsByTask(this.db)
|
|
80
96
|
return {
|
|
81
97
|
columns: board.columns.map((column) => ({
|
|
82
98
|
...column,
|
|
83
|
-
tasks: column.tasks.map(enrichTask),
|
|
99
|
+
tasks: column.tasks.map((task) => this.enrichTask(task, counts.get(task.id) ?? 0)),
|
|
84
100
|
})),
|
|
85
101
|
}
|
|
86
102
|
}
|
|
@@ -90,27 +106,58 @@ export class LocalProvider implements KanbanProvider {
|
|
|
90
106
|
}
|
|
91
107
|
|
|
92
108
|
async listTasks(filters: TaskListFilters = {}) {
|
|
93
|
-
|
|
109
|
+
const counts = countCommentsByTask(this.db)
|
|
110
|
+
return listTasks(this.db, filters).map((task) =>
|
|
111
|
+
this.enrichTask(task, counts.get(task.id) ?? 0),
|
|
112
|
+
)
|
|
94
113
|
}
|
|
95
114
|
|
|
96
115
|
async getTask(idOrRef: string) {
|
|
97
|
-
return enrichTask(getTask(this.db, idOrRef))
|
|
116
|
+
return this.enrichTask(getTask(this.db, idOrRef))
|
|
98
117
|
}
|
|
99
118
|
|
|
100
119
|
async createTask(input: CreateTaskInput) {
|
|
101
|
-
return enrichTask(addTask(this.db, input.title, input))
|
|
120
|
+
return this.enrichTask(addTask(this.db, input.title, input))
|
|
102
121
|
}
|
|
103
122
|
|
|
104
123
|
async updateTask(idOrRef: string, input: UpdateTaskInput) {
|
|
105
|
-
|
|
124
|
+
if (input.expectedVersion !== undefined) {
|
|
125
|
+
const current = getTask(this.db, idOrRef)
|
|
126
|
+
const currentVersion = String(current.revision ?? 0)
|
|
127
|
+
if (currentVersion !== input.expectedVersion) {
|
|
128
|
+
throw new KanbanError(
|
|
129
|
+
ErrorCode.CONFLICT,
|
|
130
|
+
`Task ${idOrRef} was modified since you loaded it (expected version ${input.expectedVersion}, current ${currentVersion})`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const updates: Omit<UpdateTaskInput, 'expectedVersion'> = { ...input }
|
|
135
|
+
delete (updates as UpdateTaskInput).expectedVersion
|
|
136
|
+
return this.enrichTask(updateTask(this.db, idOrRef, updates))
|
|
106
137
|
}
|
|
107
138
|
|
|
108
139
|
async moveTask(idOrRef: string, column: string) {
|
|
109
|
-
return enrichTask(moveTask(this.db, idOrRef, column))
|
|
140
|
+
return this.enrichTask(moveTask(this.db, idOrRef, column))
|
|
110
141
|
}
|
|
111
142
|
|
|
112
143
|
async deleteTask(idOrRef: string) {
|
|
113
|
-
return enrichTask(deleteTask(this.db, idOrRef))
|
|
144
|
+
return this.enrichTask(deleteTask(this.db, idOrRef))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async listComments(idOrRef: string): Promise<TaskComment[]> {
|
|
148
|
+
return listTaskComments(this.db, idOrRef)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
|
|
152
|
+
return getTaskComment(this.db, idOrRef, commentId)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async comment(idOrRef: string, body: string): Promise<TaskComment> {
|
|
156
|
+
return addComment(this.db, idOrRef, body)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
|
|
160
|
+
return updateTaskComment(this.db, idOrRef, commentId, body)
|
|
114
161
|
}
|
|
115
162
|
|
|
116
163
|
async getActivity(limit?: number, taskId?: string) {
|
|
@@ -132,4 +179,8 @@ export class LocalProvider implements KanbanProvider {
|
|
|
132
179
|
saveConfig(getConfigPath(this.dbPath), config)
|
|
133
180
|
return this.getConfig()
|
|
134
181
|
}
|
|
182
|
+
|
|
183
|
+
async getSyncStatus(): Promise<ProviderSyncStatus | null> {
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
135
186
|
}
|
package/src/providers/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WebhookRequest, WebhookResult } from '../webhooks.ts'
|
|
1
2
|
import type {
|
|
2
3
|
ActivityEntry,
|
|
3
4
|
BoardBootstrap,
|
|
@@ -8,6 +9,7 @@ import type {
|
|
|
8
9
|
Priority,
|
|
9
10
|
ProviderCapabilities,
|
|
10
11
|
ProviderTeamInfo,
|
|
12
|
+
TaskComment,
|
|
11
13
|
Task,
|
|
12
14
|
} from '../types.ts'
|
|
13
15
|
|
|
@@ -37,16 +39,23 @@ export interface UpdateTaskInput {
|
|
|
37
39
|
assignee?: string
|
|
38
40
|
project?: string
|
|
39
41
|
metadata?: string
|
|
42
|
+
expectedVersion?: string
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
export interface ProviderContext {
|
|
43
|
-
provider: 'local' | 'linear'
|
|
46
|
+
provider: 'local' | 'linear' | 'jira'
|
|
44
47
|
capabilities: ProviderCapabilities
|
|
45
48
|
team: ProviderTeamInfo | null
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
export interface ProviderSyncStatus {
|
|
52
|
+
lastSyncAt: string | null
|
|
53
|
+
lastFullSyncAt: string | null
|
|
54
|
+
lastWebhookAt: string | null
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
export interface KanbanProvider {
|
|
49
|
-
readonly type: 'local' | 'linear'
|
|
58
|
+
readonly type: 'local' | 'linear' | 'jira'
|
|
50
59
|
|
|
51
60
|
getContext(): Promise<ProviderContext>
|
|
52
61
|
getBootstrap(): Promise<BoardBootstrap>
|
|
@@ -58,8 +67,15 @@ export interface KanbanProvider {
|
|
|
58
67
|
updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task>
|
|
59
68
|
moveTask(idOrRef: string, column: string): Promise<Task>
|
|
60
69
|
deleteTask(idOrRef: string): Promise<Task>
|
|
70
|
+
listComments(idOrRef: string): Promise<TaskComment[]>
|
|
71
|
+
getComment(idOrRef: string, commentId: string): Promise<TaskComment>
|
|
72
|
+
comment(idOrRef: string, body: string): Promise<TaskComment>
|
|
73
|
+
updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment>
|
|
61
74
|
getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]>
|
|
62
75
|
getMetrics(): Promise<BoardMetrics>
|
|
63
76
|
getConfig(): Promise<BoardConfig>
|
|
64
77
|
patchConfig(input: Partial<BoardConfig>): Promise<BoardConfig>
|
|
78
|
+
syncCache?(): Promise<void>
|
|
79
|
+
getSyncStatus?(): Promise<ProviderSyncStatus | null>
|
|
80
|
+
handleWebhook?(payload: WebhookRequest): Promise<WebhookResult>
|
|
65
81
|
}
|