@andypai/agent-kanban 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -19
- package/package.json +3 -2
- package/src/__tests__/activity.test.ts +12 -0
- package/src/__tests__/api.test.ts +4 -2
- package/src/__tests__/board-utils.test.ts +16 -3
- package/src/__tests__/column-roles.test.ts +34 -0
- package/src/__tests__/commands/board.test.ts +7 -0
- package/src/__tests__/conflict.test.ts +11 -1
- package/src/__tests__/db.test.ts +70 -0
- package/src/__tests__/index.test.ts +55 -0
- package/src/__tests__/jira-cache.test.ts +56 -0
- package/src/__tests__/jira-jql.test.ts +51 -0
- package/src/__tests__/jira-provider-read.test.ts +50 -0
- package/src/__tests__/mcp-server.test.ts +2 -2
- package/src/__tests__/metrics.test.ts +37 -1
- package/src/__tests__/postgres-local-provider.test.ts +90 -1
- package/src/__tests__/server.test.ts +126 -0
- package/src/__tests__/use-cases.test.ts +77 -0
- package/src/__tests__/webhooks.test.ts +91 -22
- package/src/api.ts +58 -36
- package/src/column-roles.ts +52 -0
- package/src/commands/board.ts +4 -4
- package/src/db.ts +145 -114
- package/src/errors.ts +1 -0
- package/src/index.ts +84 -33
- package/src/mcp/core.ts +8 -7
- package/src/metrics.ts +48 -23
- package/src/provider-runtime.ts +9 -2
- package/src/providers/index.ts +4 -1
- package/src/providers/jira-cache.ts +23 -6
- package/src/providers/jira-core.ts +926 -0
- package/src/providers/jira-jql.ts +48 -0
- package/src/providers/jira.ts +111 -759
- package/src/providers/linear-cache.ts +5 -3
- package/src/providers/linear-core.ts +693 -0
- package/src/providers/linear.ts +70 -554
- package/src/providers/postgres-jira-cache.ts +597 -0
- package/src/providers/postgres-jira.ts +15 -1312
- package/src/providers/postgres-linear-cache.ts +500 -0
- package/src/providers/postgres-linear.ts +22 -1088
- package/src/providers/postgres-local.ts +181 -74
- package/src/providers/types.ts +71 -2
- package/src/server.ts +118 -32
- package/src/use-cases.ts +139 -0
- package/src/webhooks.ts +19 -0
- package/ui/dist/assets/index-65LcV0R7.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-CFhtfqCn.js +0 -40
|
@@ -280,11 +280,13 @@ export function upsertProjects(
|
|
|
280
280
|
}
|
|
281
281
|
|
|
282
282
|
// Bound per-value storage so repeated edits to a long description can't balloon the cache.
|
|
283
|
-
|
|
284
|
-
|
|
283
|
+
// Exported and shared with the Postgres cache (postgres-linear-cache.ts) so both
|
|
284
|
+
// backends clamp identically and the truncation marker can't drift between them.
|
|
285
|
+
export const ACTIVITY_VALUE_MAX_CHARS = 4096
|
|
286
|
+
export const ACTIVITY_TRUNCATION_SUFFIX = '…[truncated]'
|
|
285
287
|
const ACTIVITY_VALUE_BUDGET = ACTIVITY_VALUE_MAX_CHARS - ACTIVITY_TRUNCATION_SUFFIX.length
|
|
286
288
|
|
|
287
|
-
function clampActivityValue(value: string): string {
|
|
289
|
+
export function clampActivityValue(value: string): string {
|
|
288
290
|
if (value.length <= ACTIVITY_VALUE_MAX_CHARS) return value
|
|
289
291
|
return value.slice(0, ACTIVITY_VALUE_BUDGET) + ACTIVITY_TRUNCATION_SUFFIX
|
|
290
292
|
}
|
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
import { ErrorCode, KanbanError } from '../errors'
|
|
2
|
+
import type {
|
|
3
|
+
ActivityEntry,
|
|
4
|
+
BoardBootstrap,
|
|
5
|
+
BoardConfig,
|
|
6
|
+
BoardMetrics,
|
|
7
|
+
BoardView,
|
|
8
|
+
Column,
|
|
9
|
+
ProviderTeamInfo,
|
|
10
|
+
Task,
|
|
11
|
+
TaskComment,
|
|
12
|
+
} from '../types'
|
|
13
|
+
import {
|
|
14
|
+
authorizeWebhook,
|
|
15
|
+
headerLower,
|
|
16
|
+
verifyHmacSha256,
|
|
17
|
+
type WebhookRequest,
|
|
18
|
+
type WebhookResult,
|
|
19
|
+
} from '../webhooks'
|
|
20
|
+
import { LINEAR_CAPABILITIES } from './capabilities'
|
|
21
|
+
import { LinearClient, resolveLabelIdsForCreate, type LinearComment } from './linear-client'
|
|
22
|
+
import type { LinearActivityRow, LinearStateRow, LinearSyncMeta } from './linear-cache'
|
|
23
|
+
import { unsupportedOperation } from './errors'
|
|
24
|
+
import type {
|
|
25
|
+
CreateTaskInput,
|
|
26
|
+
KanbanProvider,
|
|
27
|
+
ProviderContext,
|
|
28
|
+
ProviderSyncStatus,
|
|
29
|
+
TaskListFilters,
|
|
30
|
+
UpdateTaskInput,
|
|
31
|
+
} from './types'
|
|
32
|
+
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
|
|
33
|
+
|
|
34
|
+
const FULL_RECONCILIATION_INTERVAL_MS = 5 * 60_000
|
|
35
|
+
|
|
36
|
+
function parseTimestamp(value: string | null | undefined): number {
|
|
37
|
+
if (!value) return 0
|
|
38
|
+
const parsed = Date.parse(value)
|
|
39
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function maxTimestamp(a: string | null | undefined, b: string | null | undefined): string | null {
|
|
43
|
+
const aMs = parseTimestamp(a)
|
|
44
|
+
const bMs = parseTimestamp(b)
|
|
45
|
+
if (!aMs && !bMs) return null
|
|
46
|
+
return aMs >= bMs ? (a ?? null) : (b ?? null)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toLinearPriority(priority: Task['priority'] | undefined): number | undefined {
|
|
50
|
+
switch (priority) {
|
|
51
|
+
case 'urgent':
|
|
52
|
+
return 1
|
|
53
|
+
case 'high':
|
|
54
|
+
return 2
|
|
55
|
+
case 'medium':
|
|
56
|
+
return 3
|
|
57
|
+
case 'low':
|
|
58
|
+
return 4
|
|
59
|
+
default:
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Storage-agnostic cache/repository port the Linear core depends on. The SQLite
|
|
66
|
+
* (linear.ts) and Postgres (postgres-linear.ts) backends each provide an
|
|
67
|
+
* implementation; the core never touches a Database or Sql client directly. All
|
|
68
|
+
* methods are async so the Postgres backend is native and the SQLite backend
|
|
69
|
+
* wraps its synchronous bun:sqlite calls.
|
|
70
|
+
*/
|
|
71
|
+
export interface LinearCachePort {
|
|
72
|
+
readonly ready: Promise<void>
|
|
73
|
+
|
|
74
|
+
loadSyncMeta(): Promise<LinearSyncMeta>
|
|
75
|
+
saveSyncMeta(meta: Partial<LinearSyncMeta>): Promise<void>
|
|
76
|
+
|
|
77
|
+
replaceStates(
|
|
78
|
+
states: Array<{
|
|
79
|
+
id: string
|
|
80
|
+
name: string
|
|
81
|
+
position: number
|
|
82
|
+
color?: string | null
|
|
83
|
+
type?: string | null
|
|
84
|
+
}>,
|
|
85
|
+
): Promise<void>
|
|
86
|
+
upsertUsers(users: Array<{ id: string; name: string; active?: boolean }>): Promise<void>
|
|
87
|
+
upsertProjects(
|
|
88
|
+
projects: Array<{ id: string; name: string; url?: string | null; state?: string | null }>,
|
|
89
|
+
): Promise<void>
|
|
90
|
+
|
|
91
|
+
upsertIssues(
|
|
92
|
+
issues: Array<{
|
|
93
|
+
id: string
|
|
94
|
+
identifier: string
|
|
95
|
+
title: string
|
|
96
|
+
description?: string | null
|
|
97
|
+
priority?: number | null
|
|
98
|
+
assigneeId?: string | null
|
|
99
|
+
assigneeName?: string | null
|
|
100
|
+
projectId?: string | null
|
|
101
|
+
projectName?: string | null
|
|
102
|
+
stateId: string
|
|
103
|
+
stateName: string
|
|
104
|
+
statePosition: number
|
|
105
|
+
labels?: string[] | null
|
|
106
|
+
commentCount?: number | null
|
|
107
|
+
url?: string | null
|
|
108
|
+
createdAt: string
|
|
109
|
+
updatedAt: string
|
|
110
|
+
}>,
|
|
111
|
+
): Promise<void>
|
|
112
|
+
deleteIssue(idOrIdentifier: string): Promise<void>
|
|
113
|
+
pruneIssues(liveIssueIds: string[]): Promise<void>
|
|
114
|
+
adjustIssueCommentCount(idOrIdentifier: string, delta: number): Promise<void>
|
|
115
|
+
|
|
116
|
+
saveActivity(rows: LinearActivityRow[]): Promise<void>
|
|
117
|
+
getCachedActivity(params?: { issueId?: string; limit?: number }): Promise<LinearActivityRow[]>
|
|
118
|
+
|
|
119
|
+
getCachedColumns(): Promise<LinearStateRow[]>
|
|
120
|
+
getCachedBoard(): Promise<BoardView>
|
|
121
|
+
getCachedTask(lookup: string): Promise<Task | null>
|
|
122
|
+
getCachedTasks(): Promise<Task[]>
|
|
123
|
+
getCachedConfig(): Promise<BoardConfig>
|
|
124
|
+
|
|
125
|
+
// Lookups used by the write paths
|
|
126
|
+
findUserIdByName(name: string): Promise<string | null>
|
|
127
|
+
findProjectIdByName(name: string): Promise<string | null>
|
|
128
|
+
resolveIssueId(lookup: string): Promise<string | null>
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Shared Linear provider business logic (sync orchestration, issue-history
|
|
133
|
+
* ingest, writes, state transitions, comments, webhook dispatch, activity
|
|
134
|
+
* mapping). Concrete providers (LinearProvider over SQLite, PostgresLinearProvider
|
|
135
|
+
* over Postgres) subclass this and inject a LinearCachePort plus the API client.
|
|
136
|
+
*/
|
|
137
|
+
export class LinearProviderCore implements KanbanProvider {
|
|
138
|
+
readonly type = 'linear' as const
|
|
139
|
+
// When a server-side background warmer owns cache refresh, request-path syncs
|
|
140
|
+
// are suppressed once the cache is warm so reads/writes never block on Linear I/O.
|
|
141
|
+
private backgroundManaged = false
|
|
142
|
+
|
|
143
|
+
constructor(
|
|
144
|
+
protected readonly cache: LinearCachePort,
|
|
145
|
+
protected readonly teamId: string,
|
|
146
|
+
protected readonly client: LinearClient,
|
|
147
|
+
protected readonly pollingSyncIntervalMs = DEFAULT_POLLING_SYNC_INTERVAL_MS,
|
|
148
|
+
) {}
|
|
149
|
+
|
|
150
|
+
async initialize(): Promise<void> {
|
|
151
|
+
await this.cache.ready
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async resolvedTeamId(): Promise<string> {
|
|
155
|
+
return (await this.cache.loadSyncMeta()).team?.id ?? this.teamId
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async getConfiguredTeam(): Promise<ProviderTeamInfo> {
|
|
159
|
+
const metaTeam = (await this.cache.loadSyncMeta()).team
|
|
160
|
+
if (metaTeam) return metaTeam
|
|
161
|
+
|
|
162
|
+
const team = await this.client.getTeam(this.teamId)
|
|
163
|
+
const configuredTeam = { id: team.id, key: team.key, name: team.name }
|
|
164
|
+
await this.cache.saveSyncMeta({ team: configuredTeam })
|
|
165
|
+
return configuredTeam
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
protected async sync(force = false, viaWarmer = false): Promise<void> {
|
|
169
|
+
await this.cache.ready
|
|
170
|
+
const meta = await this.cache.loadSyncMeta()
|
|
171
|
+
const lastSyncAtMs = parseTimestamp(meta.lastSyncAt)
|
|
172
|
+
const lastFullSyncAtMs = parseTimestamp(meta.lastFullSyncAt)
|
|
173
|
+
// Server mode: a background warmer (syncCache(), viaWarmer=true) owns refresh.
|
|
174
|
+
// Once the cache has synced at least once, implicit request-path reads serve the
|
|
175
|
+
// warm cache instead of blocking on a Linear round-trip, which could exceed the
|
|
176
|
+
// HTTP idle timeout. Forced syncs (writes' read-after-write) and the warmer
|
|
177
|
+
// still run; CLI mode and cold start sync synchronously.
|
|
178
|
+
if (this.backgroundManaged && !force && !viaWarmer && lastSyncAtMs) return
|
|
179
|
+
const now = Date.now()
|
|
180
|
+
if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
|
|
181
|
+
|
|
182
|
+
const shouldFullSync =
|
|
183
|
+
force ||
|
|
184
|
+
!lastFullSyncAtMs ||
|
|
185
|
+
!meta.lastIssueUpdatedAt ||
|
|
186
|
+
now - lastFullSyncAtMs >= FULL_RECONCILIATION_INTERVAL_MS
|
|
187
|
+
|
|
188
|
+
const team = await this.client.getTeam(this.teamId)
|
|
189
|
+
const [users, projects, issues] = await Promise.all([
|
|
190
|
+
this.client.listUsers(),
|
|
191
|
+
this.client.listProjects(),
|
|
192
|
+
this.client.listIssues(
|
|
193
|
+
team.id,
|
|
194
|
+
shouldFullSync ? undefined : (meta.lastIssueUpdatedAt ?? undefined),
|
|
195
|
+
),
|
|
196
|
+
])
|
|
197
|
+
|
|
198
|
+
await this.cache.replaceStates(team.states)
|
|
199
|
+
await this.cache.upsertUsers(users)
|
|
200
|
+
await this.cache.upsertProjects(projects)
|
|
201
|
+
await this.cache.upsertIssues(
|
|
202
|
+
issues.map((issue) => ({
|
|
203
|
+
id: issue.id,
|
|
204
|
+
identifier: issue.identifier,
|
|
205
|
+
title: issue.title,
|
|
206
|
+
description: issue.description ?? '',
|
|
207
|
+
priority: issue.priority ?? 0,
|
|
208
|
+
assigneeId: issue.assignee?.id ?? null,
|
|
209
|
+
assigneeName: issue.assignee?.name ?? null,
|
|
210
|
+
projectId: issue.project?.id ?? null,
|
|
211
|
+
projectName: issue.project?.name ?? null,
|
|
212
|
+
stateId: issue.state.id,
|
|
213
|
+
stateName: issue.state.name,
|
|
214
|
+
statePosition: issue.state.position,
|
|
215
|
+
labels: issue.labels ?? [],
|
|
216
|
+
commentCount: issue.commentCount,
|
|
217
|
+
url: issue.url ?? null,
|
|
218
|
+
createdAt: issue.createdAt,
|
|
219
|
+
updatedAt: issue.updatedAt,
|
|
220
|
+
})),
|
|
221
|
+
)
|
|
222
|
+
if (shouldFullSync) {
|
|
223
|
+
await this.cache.pruneIssues(issues.map((issue) => issue.id))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const newestIssueTimestamp = maxTimestamp(
|
|
227
|
+
meta.lastIssueUpdatedAt,
|
|
228
|
+
issues.length > 0
|
|
229
|
+
? issues.reduce(
|
|
230
|
+
(latest, issue) => (issue.updatedAt > latest ? issue.updatedAt : latest),
|
|
231
|
+
issues[0]!.updatedAt,
|
|
232
|
+
)
|
|
233
|
+
: null,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
// Best-effort changelog ingest; failures don't fail the main sync.
|
|
237
|
+
await this.ingestTeamHistory(
|
|
238
|
+
issues.map((issue) => issue.id),
|
|
239
|
+
meta.lastIssueUpdatedAt,
|
|
240
|
+
).catch((err) => {
|
|
241
|
+
console.warn('[linear] issueHistory ingest failed:', err)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const syncedAt = new Date().toISOString()
|
|
245
|
+
await this.cache.saveSyncMeta({
|
|
246
|
+
team: { id: team.id, key: team.key, name: team.name },
|
|
247
|
+
lastSyncAt: syncedAt,
|
|
248
|
+
lastFullSyncAt: shouldFullSync ? syncedAt : undefined,
|
|
249
|
+
lastIssueUpdatedAt: newestIssueTimestamp ?? syncedAt,
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async ingestTeamHistory(issueIds: string[], sinceIso: string | null): Promise<void> {
|
|
254
|
+
if (issueIds.length === 0) return
|
|
255
|
+
const concurrency = 5
|
|
256
|
+
for (let i = 0; i < issueIds.length; i += concurrency) {
|
|
257
|
+
const batch = issueIds.slice(i, i + concurrency)
|
|
258
|
+
const results = await Promise.all(
|
|
259
|
+
batch.map((issueId) => this.fetchIssueHistory(issueId, sinceIso)),
|
|
260
|
+
)
|
|
261
|
+
const rows = results.flat()
|
|
262
|
+
if (rows.length > 0) await this.cache.saveActivity(rows)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private async fetchIssueHistory(
|
|
267
|
+
issueId: string,
|
|
268
|
+
sinceIso: string | null,
|
|
269
|
+
): Promise<LinearActivityRow[]> {
|
|
270
|
+
const rows: LinearActivityRow[] = []
|
|
271
|
+
let cursor: string | null = null
|
|
272
|
+
for (let page = 0; page < 10; page++) {
|
|
273
|
+
const batch = await this.client.listIssueHistory({ issueId, first: 50, after: cursor })
|
|
274
|
+
let reachedKnown = false
|
|
275
|
+
for (const node of batch.nodes) {
|
|
276
|
+
// Linear returns history newest-first; once we hit an entry we've already ingested,
|
|
277
|
+
// every subsequent page is older still, so break out of pagination entirely.
|
|
278
|
+
if (sinceIso && node.createdAt <= sinceIso) {
|
|
279
|
+
reachedKnown = true
|
|
280
|
+
break
|
|
281
|
+
}
|
|
282
|
+
if (!node.fromState && !node.toState) continue
|
|
283
|
+
rows.push({
|
|
284
|
+
issue_id: issueId,
|
|
285
|
+
history_id: node.id,
|
|
286
|
+
item_field: 'state',
|
|
287
|
+
from_value: node.fromState?.id ?? null,
|
|
288
|
+
to_value: node.toState?.id ?? null,
|
|
289
|
+
created_at: node.createdAt,
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
if (reachedKnown) break
|
|
293
|
+
if (!batch.pageInfo.hasNextPage || !batch.pageInfo.endCursor) break
|
|
294
|
+
cursor = batch.pageInfo.endCursor
|
|
295
|
+
}
|
|
296
|
+
return rows
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private async resolveTask(idOrRef: string): Promise<Task> {
|
|
300
|
+
const task = await this.cache.getCachedTask(idOrRef)
|
|
301
|
+
if (!task) {
|
|
302
|
+
throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${idOrRef}'`)
|
|
303
|
+
}
|
|
304
|
+
return task
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private async resolveState(column: string): Promise<Column> {
|
|
308
|
+
const states = await this.cache.getCachedColumns()
|
|
309
|
+
const match = states.find(
|
|
310
|
+
(state) => state.id === column || state.name.toLowerCase() === column.toLowerCase(),
|
|
311
|
+
)
|
|
312
|
+
if (!match) {
|
|
313
|
+
throw new KanbanError(
|
|
314
|
+
ErrorCode.COLUMN_NOT_FOUND,
|
|
315
|
+
`No Linear workflow state matching '${column}'`,
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
return match
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private async resolveAssigneeId(name?: string): Promise<string | undefined> {
|
|
322
|
+
if (!name) return undefined
|
|
323
|
+
return (await this.cache.findUserIdByName(name)) ?? undefined
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private async resolveProjectId(name?: string): Promise<string | undefined> {
|
|
327
|
+
if (!name) return undefined
|
|
328
|
+
return (await this.cache.findProjectIdByName(name)) ?? undefined
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private toTaskComment(task: Task, comment: LinearComment): TaskComment {
|
|
332
|
+
return {
|
|
333
|
+
id: comment.id,
|
|
334
|
+
task_id: task.id,
|
|
335
|
+
body: comment.body,
|
|
336
|
+
author: comment.user?.displayName || comment.user?.name || null,
|
|
337
|
+
created_at: comment.createdAt,
|
|
338
|
+
updated_at: comment.updatedAt,
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async syncCache(): Promise<void> {
|
|
343
|
+
// viaWarmer bypasses the backgroundManaged request-path suppression without
|
|
344
|
+
// forcing a full reconcile (force=false keeps the normal delta/full cadence).
|
|
345
|
+
await this.sync(false, true)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
setBackgroundManaged(managed: boolean): void {
|
|
349
|
+
this.backgroundManaged = managed
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async getSyncStatus(): Promise<ProviderSyncStatus> {
|
|
353
|
+
const meta = await this.cache.loadSyncMeta()
|
|
354
|
+
return {
|
|
355
|
+
lastSyncAt: meta.lastSyncAt,
|
|
356
|
+
lastFullSyncAt: meta.lastFullSyncAt,
|
|
357
|
+
lastWebhookAt: meta.lastWebhookAt,
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async getContext(): Promise<ProviderContext> {
|
|
362
|
+
await this.sync()
|
|
363
|
+
const meta = await this.cache.loadSyncMeta()
|
|
364
|
+
return {
|
|
365
|
+
provider: 'linear',
|
|
366
|
+
capabilities: LINEAR_CAPABILITIES,
|
|
367
|
+
team: meta.team,
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async getBootstrap(): Promise<BoardBootstrap> {
|
|
372
|
+
await this.sync()
|
|
373
|
+
return {
|
|
374
|
+
provider: 'linear',
|
|
375
|
+
capabilities: LINEAR_CAPABILITIES,
|
|
376
|
+
board: await this.cache.getCachedBoard(),
|
|
377
|
+
config: await this.cache.getCachedConfig(),
|
|
378
|
+
metrics: null,
|
|
379
|
+
activity: [],
|
|
380
|
+
team: (await this.cache.loadSyncMeta()).team,
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async getBoard(): Promise<BoardView> {
|
|
385
|
+
await this.sync()
|
|
386
|
+
return this.cache.getCachedBoard()
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async listColumns(): Promise<Column[]> {
|
|
390
|
+
await this.sync()
|
|
391
|
+
return this.cache.getCachedColumns()
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async listTasks(filters: TaskListFilters = {}): Promise<Task[]> {
|
|
395
|
+
await this.sync()
|
|
396
|
+
let tasks = await this.cache.getCachedTasks()
|
|
397
|
+
if (filters.column) {
|
|
398
|
+
const column = await this.resolveState(filters.column)
|
|
399
|
+
tasks = tasks.filter((task) => task.column_id === column.id)
|
|
400
|
+
}
|
|
401
|
+
if (filters.priority) tasks = tasks.filter((task) => task.priority === filters.priority)
|
|
402
|
+
if (filters.assignee) tasks = tasks.filter((task) => task.assignee === filters.assignee)
|
|
403
|
+
if (filters.project) tasks = tasks.filter((task) => task.project === filters.project)
|
|
404
|
+
if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
|
|
405
|
+
if (filters.sort === 'updated')
|
|
406
|
+
tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
|
407
|
+
if (filters.limit) tasks = tasks.slice(0, filters.limit)
|
|
408
|
+
return tasks
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async getTask(idOrRef: string): Promise<Task> {
|
|
412
|
+
await this.sync()
|
|
413
|
+
return this.resolveTask(idOrRef)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async createTask(input: CreateTaskInput): Promise<Task> {
|
|
417
|
+
await this.sync()
|
|
418
|
+
const state = input.column ? await this.resolveState(input.column) : undefined
|
|
419
|
+
const labelIds = await resolveLabelIdsForCreate(this.client, input.labels)
|
|
420
|
+
const result = await this.client.createIssue({
|
|
421
|
+
teamId: await this.resolvedTeamId(),
|
|
422
|
+
stateId: state?.id,
|
|
423
|
+
title: input.title,
|
|
424
|
+
description: input.description,
|
|
425
|
+
priority: toLinearPriority(input.priority),
|
|
426
|
+
assigneeId: await this.resolveAssigneeId(input.assignee),
|
|
427
|
+
projectId: await this.resolveProjectId(input.project),
|
|
428
|
+
labelIds,
|
|
429
|
+
})
|
|
430
|
+
if (!result.success || !result.issue) {
|
|
431
|
+
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue creation failed')
|
|
432
|
+
}
|
|
433
|
+
const issue = result.issue
|
|
434
|
+
await this.cache.upsertIssues([
|
|
435
|
+
{
|
|
436
|
+
id: issue.id,
|
|
437
|
+
identifier: issue.identifier,
|
|
438
|
+
title: issue.title,
|
|
439
|
+
description: issue.description ?? '',
|
|
440
|
+
priority: issue.priority ?? 0,
|
|
441
|
+
assigneeId: issue.assignee?.id ?? null,
|
|
442
|
+
assigneeName: issue.assignee?.name ?? issue.assignee?.displayName ?? '',
|
|
443
|
+
projectId: issue.project?.id ?? null,
|
|
444
|
+
projectName: issue.project?.name ?? '',
|
|
445
|
+
stateId: issue.state.id,
|
|
446
|
+
stateName: issue.state.name,
|
|
447
|
+
statePosition: issue.state.position,
|
|
448
|
+
labels: issue.labels ?? [],
|
|
449
|
+
commentCount: issue.commentCount,
|
|
450
|
+
url: issue.url ?? null,
|
|
451
|
+
createdAt: issue.createdAt,
|
|
452
|
+
updatedAt: issue.updatedAt,
|
|
453
|
+
},
|
|
454
|
+
])
|
|
455
|
+
return this.resolveTask(issue.id)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
|
|
459
|
+
await this.sync()
|
|
460
|
+
const task = await this.resolveTask(idOrRef)
|
|
461
|
+
if (input.expectedVersion !== undefined && task.version !== input.expectedVersion) {
|
|
462
|
+
throw new KanbanError(
|
|
463
|
+
ErrorCode.CONFLICT,
|
|
464
|
+
`Linear issue ${task.externalRef ?? idOrRef} was updated remotely (expected version ${input.expectedVersion}, current ${task.version ?? 'unknown'})`,
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
const updateInput: Record<string, unknown> = {}
|
|
468
|
+
if (input.title !== undefined) updateInput['title'] = input.title
|
|
469
|
+
if (input.description !== undefined) updateInput['description'] = input.description
|
|
470
|
+
if (input.priority !== undefined) updateInput['priority'] = toLinearPriority(input.priority)
|
|
471
|
+
if (input.assignee !== undefined)
|
|
472
|
+
updateInput['assigneeId'] = (await this.resolveAssigneeId(input.assignee)) ?? null
|
|
473
|
+
if (input.project !== undefined)
|
|
474
|
+
updateInput['projectId'] = (await this.resolveProjectId(input.project)) ?? null
|
|
475
|
+
if (input.metadata !== undefined) {
|
|
476
|
+
unsupportedOperation('Linear mode does not support metadata updates')
|
|
477
|
+
}
|
|
478
|
+
const result = await this.client.updateIssue(task.providerId || task.id, updateInput)
|
|
479
|
+
if (!result.success) {
|
|
480
|
+
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue update failed')
|
|
481
|
+
}
|
|
482
|
+
await this.sync(true)
|
|
483
|
+
return this.resolveTask(task.providerId || task.id)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async moveTask(idOrRef: string, column: string): Promise<Task> {
|
|
487
|
+
await this.sync()
|
|
488
|
+
const task = await this.resolveTask(idOrRef)
|
|
489
|
+
const state = await this.resolveState(column)
|
|
490
|
+
const result = await this.client.updateIssue(task.providerId || task.id, { stateId: state.id })
|
|
491
|
+
if (!result.success) {
|
|
492
|
+
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue move failed')
|
|
493
|
+
}
|
|
494
|
+
await this.sync(true)
|
|
495
|
+
return this.resolveTask(task.providerId || task.id)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async deleteTask(_idOrRef: string): Promise<Task> {
|
|
499
|
+
unsupportedOperation('Task deletion is not supported in Linear mode')
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async listComments(idOrRef: string): Promise<TaskComment[]> {
|
|
503
|
+
await this.sync()
|
|
504
|
+
const task = await this.resolveTask(idOrRef)
|
|
505
|
+
const comments = await this.client.listComments(task.providerId || task.id)
|
|
506
|
+
return comments.map((comment) => this.toTaskComment(task, comment))
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
|
|
510
|
+
await this.sync()
|
|
511
|
+
const task = await this.resolveTask(idOrRef)
|
|
512
|
+
const comment = await this.client.getComment(commentId)
|
|
513
|
+
return this.toTaskComment(task, comment)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async comment(idOrRef: string, body: string): Promise<TaskComment> {
|
|
517
|
+
await this.sync()
|
|
518
|
+
const task = await this.resolveTask(idOrRef)
|
|
519
|
+
const result = await this.client.commentCreate(task.providerId || task.id, body)
|
|
520
|
+
if (!result.success || !result.comment) {
|
|
521
|
+
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear comment creation failed')
|
|
522
|
+
}
|
|
523
|
+
await this.cache.adjustIssueCommentCount(task.providerId || task.id, 1)
|
|
524
|
+
return this.toTaskComment(task, result.comment)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
|
|
528
|
+
await this.sync()
|
|
529
|
+
const task = await this.resolveTask(idOrRef)
|
|
530
|
+
const result = await this.client.commentUpdate(commentId, body)
|
|
531
|
+
if (!result.success || !result.comment) {
|
|
532
|
+
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear comment update failed')
|
|
533
|
+
}
|
|
534
|
+
return this.toTaskComment(task, result.comment)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]> {
|
|
538
|
+
await this.sync()
|
|
539
|
+
const issueId = taskId ? ((await this.cache.resolveIssueId(taskId)) ?? undefined) : undefined
|
|
540
|
+
const rows = await this.cache.getCachedActivity({
|
|
541
|
+
...(issueId !== undefined ? { issueId } : {}),
|
|
542
|
+
limit: limit ?? 100,
|
|
543
|
+
})
|
|
544
|
+
return rows.map((row) => this.activityRowToEntry(row))
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private activityRowToEntry(row: LinearActivityRow): ActivityEntry {
|
|
548
|
+
// fromState/toState already reference state ids which agent-kanban
|
|
549
|
+
// surfaces 1:1 as column ids (see linear_states/getCachedColumns),
|
|
550
|
+
// so no lookup is needed here.
|
|
551
|
+
return {
|
|
552
|
+
id: `linear-activity:${row.issue_id}:${row.history_id}:${row.item_field}`,
|
|
553
|
+
task_id: `linear:${row.issue_id}`,
|
|
554
|
+
action: row.item_field === 'state' ? 'moved' : 'updated',
|
|
555
|
+
field_changed: row.item_field,
|
|
556
|
+
old_value: row.from_value,
|
|
557
|
+
new_value: row.to_value,
|
|
558
|
+
timestamp: row.created_at,
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async getMetrics(): Promise<BoardMetrics> {
|
|
563
|
+
unsupportedOperation('Metrics are not available in Linear mode')
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async getConfig(): Promise<BoardConfig> {
|
|
567
|
+
await this.sync()
|
|
568
|
+
return this.cache.getCachedConfig()
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async patchConfig(_input: Partial<BoardConfig>): Promise<BoardConfig> {
|
|
572
|
+
unsupportedOperation('Config mutation is not supported in Linear mode')
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
|
|
576
|
+
return this.handleWebhookCore(payload)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Shared webhook dispatch. Postgres wraps this with webhook-event auditing;
|
|
580
|
+
// SQLite calls it directly via the default handleWebhook above.
|
|
581
|
+
protected async handleWebhookCore(payload: WebhookRequest): Promise<WebhookResult> {
|
|
582
|
+
if (!process.env['LINEAR_WEBHOOK_SECRET']) {
|
|
583
|
+
console.warn(
|
|
584
|
+
'[linear] LINEAR_WEBHOOK_SECRET is not set — accepting webhook without signature verification (open dev mode)',
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
const auth = authorizeWebhook({
|
|
588
|
+
secret: process.env['LINEAR_WEBHOOK_SECRET'],
|
|
589
|
+
rawBody: payload.rawBody,
|
|
590
|
+
signature: headerLower(payload.headers, 'linear-signature'),
|
|
591
|
+
verify: verifyHmacSha256,
|
|
592
|
+
})
|
|
593
|
+
if (auth) return auth
|
|
594
|
+
let body: {
|
|
595
|
+
action?: 'create' | 'update' | 'remove'
|
|
596
|
+
type?: string
|
|
597
|
+
data?: {
|
|
598
|
+
id: string
|
|
599
|
+
identifier?: string
|
|
600
|
+
title?: string
|
|
601
|
+
description?: string | null
|
|
602
|
+
priority?: number | null
|
|
603
|
+
url?: string | null
|
|
604
|
+
createdAt?: string
|
|
605
|
+
updatedAt?: string
|
|
606
|
+
assignee?: { id: string; name?: string | null } | null
|
|
607
|
+
assigneeId?: string | null
|
|
608
|
+
project?: { id: string; name: string } | null
|
|
609
|
+
projectId?: string | null
|
|
610
|
+
state?: { id: string; name: string; position?: number } | null
|
|
611
|
+
stateId?: string | null
|
|
612
|
+
team?: { id?: string | null; key?: string | null } | null
|
|
613
|
+
teamId?: string | null
|
|
614
|
+
labels?: Array<{ id: string; name: string }> | null
|
|
615
|
+
commentCount?: number | null
|
|
616
|
+
}
|
|
617
|
+
} = {}
|
|
618
|
+
try {
|
|
619
|
+
body = JSON.parse(payload.rawBody) as typeof body
|
|
620
|
+
} catch {
|
|
621
|
+
return { handled: false, message: 'Invalid JSON body' }
|
|
622
|
+
}
|
|
623
|
+
if (body.type !== 'Issue') {
|
|
624
|
+
return { handled: false, message: `Ignoring ${body.type ?? 'unknown'} event` }
|
|
625
|
+
}
|
|
626
|
+
const data = body.data
|
|
627
|
+
if (!data) return { handled: false, message: 'No data in payload' }
|
|
628
|
+
|
|
629
|
+
if (body.action === 'remove') {
|
|
630
|
+
await this.cache.deleteIssue(data.id)
|
|
631
|
+
await this.cache.saveSyncMeta({ lastWebhookAt: new Date().toISOString() })
|
|
632
|
+
return { handled: true }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (body.action === 'create' || body.action === 'update') {
|
|
636
|
+
const configuredTeam = await this.getConfiguredTeam()
|
|
637
|
+
const payloadTeamId = data.team?.id ?? data.teamId ?? null
|
|
638
|
+
if (payloadTeamId && payloadTeamId !== configuredTeam.id) {
|
|
639
|
+
return {
|
|
640
|
+
handled: false,
|
|
641
|
+
message: `Ignoring issue from team '${payloadTeamId}'`,
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (!payloadTeamId) {
|
|
646
|
+
const issueTeam = await this.client.getIssueTeam(data.id)
|
|
647
|
+
if (!issueTeam) {
|
|
648
|
+
return {
|
|
649
|
+
handled: false,
|
|
650
|
+
message: `Ignoring issue '${data.id}' because its team could not be verified`,
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (issueTeam.id !== configuredTeam.id) {
|
|
654
|
+
return {
|
|
655
|
+
handled: false,
|
|
656
|
+
message: `Ignoring issue from team '${issueTeam.key}'`,
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (!data.identifier || !data.title || !data.createdAt || !data.updatedAt) {
|
|
662
|
+
return { handled: false, message: 'Missing required issue fields' }
|
|
663
|
+
}
|
|
664
|
+
const stateId = data.state?.id ?? data.stateId ?? null
|
|
665
|
+
if (!stateId) return { handled: false, message: 'Missing state id' }
|
|
666
|
+
await this.cache.upsertIssues([
|
|
667
|
+
{
|
|
668
|
+
id: data.id,
|
|
669
|
+
identifier: data.identifier,
|
|
670
|
+
title: data.title,
|
|
671
|
+
description: data.description ?? '',
|
|
672
|
+
priority: data.priority ?? 0,
|
|
673
|
+
assigneeId: data.assignee?.id ?? data.assigneeId ?? null,
|
|
674
|
+
assigneeName: data.assignee?.name ?? null,
|
|
675
|
+
projectId: data.project?.id ?? data.projectId ?? null,
|
|
676
|
+
projectName: data.project?.name ?? null,
|
|
677
|
+
stateId,
|
|
678
|
+
stateName: data.state?.name ?? '',
|
|
679
|
+
statePosition: data.state?.position ?? 0,
|
|
680
|
+
labels: (data.labels ?? []).map((l) => l.name),
|
|
681
|
+
commentCount: data.commentCount,
|
|
682
|
+
url: data.url ?? null,
|
|
683
|
+
createdAt: data.createdAt,
|
|
684
|
+
updatedAt: data.updatedAt,
|
|
685
|
+
},
|
|
686
|
+
])
|
|
687
|
+
await this.cache.saveSyncMeta({ lastWebhookAt: new Date().toISOString() })
|
|
688
|
+
return { handled: true }
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return { handled: false, message: `Unsupported action: ${body.action}` }
|
|
692
|
+
}
|
|
693
|
+
}
|