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