@andypai/agent-kanban 0.6.4 → 0.6.5
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/package.json +11 -3
- package/src/__tests__/index.test.ts +27 -1
- package/src/__tests__/jira-cache.test.ts +68 -0
- package/src/__tests__/jira-provider-read.test.ts +130 -0
- package/src/__tests__/jira-wiring.test.ts +41 -0
- package/src/__tests__/linear-provider-comment.test.ts +81 -0
- package/src/__tests__/linear-provider-sync.test.ts +107 -0
- package/src/__tests__/linear-state-resolution.test.ts +60 -0
- package/src/__tests__/local-provider-core.test.ts +245 -0
- package/src/__tests__/postgres-jira-provider.test.ts +55 -0
- package/src/__tests__/postgres-linear-provider.test.ts +90 -0
- package/src/__tests__/postgres-local-provider.test.ts +26 -0
- package/src/__tests__/provider-sync-core.test.ts +150 -0
- package/src/__tests__/provider-team-info.test.ts +37 -0
- package/src/__tests__/ui-board-slice.test.ts +14 -0
- package/src/__tests__/use-cases.test.ts +21 -42
- package/src/api.ts +78 -132
- package/src/column-roles.ts +3 -2
- package/src/index.ts +135 -92
- package/src/mcp/core.ts +7 -8
- package/src/provider-runtime.ts +12 -56
- package/src/providers/cache-task-mappers.ts +123 -0
- package/src/providers/factory.ts +103 -0
- package/src/providers/index.ts +4 -25
- package/src/providers/jira-cache.ts +41 -99
- package/src/providers/jira-core.ts +78 -80
- package/src/providers/linear-cache.ts +42 -69
- package/src/providers/linear-core.ts +41 -55
- package/src/providers/local-core.ts +226 -0
- package/src/providers/local.ts +6 -182
- package/src/providers/postgres-batch.ts +10 -0
- package/src/providers/postgres-jira-cache.ts +255 -213
- package/src/providers/postgres-jira.ts +2 -20
- package/src/providers/postgres-linear-cache.ts +279 -183
- package/src/providers/postgres-linear.ts +2 -20
- package/src/providers/postgres-local.ts +22 -47
- package/src/providers/sqlite-local-store.ts +121 -0
- package/src/providers/sync-core.ts +91 -0
- package/src/providers/team-info.ts +24 -0
- package/src/providers/warn-once.ts +6 -0
- package/src/use-cases.ts +7 -131
- package/src/webhook-events.ts +29 -1
- package/ui/dist/assets/index-DcZH7fI3.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-Cigv8a9S.js +0 -40
|
@@ -48,6 +48,7 @@ import type {
|
|
|
48
48
|
} from './types'
|
|
49
49
|
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
|
|
50
50
|
import { warnOnce } from './warn-once'
|
|
51
|
+
import { applyTaskFilters, forEachWithConcurrency, SyncGate, syncStatusFromMeta } from './sync-core'
|
|
51
52
|
|
|
52
53
|
export const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
|
|
53
54
|
|
|
@@ -158,6 +159,36 @@ export interface JiraCachePort {
|
|
|
158
159
|
resolveIssueId(lookup: string): Promise<string | null>
|
|
159
160
|
}
|
|
160
161
|
|
|
162
|
+
type JiraCacheIssue = Parameters<JiraCachePort['upsertIssues']>[0][number]
|
|
163
|
+
|
|
164
|
+
// Map a Jira API issue to the cache upsert row. Shared by bulk sync, direct
|
|
165
|
+
// hydrate, and webhook ingest so every path caches the same issue shape.
|
|
166
|
+
function toCacheIssue(
|
|
167
|
+
issue: JiraIssue,
|
|
168
|
+
baseUrl: string,
|
|
169
|
+
fallbackProjectKey: string,
|
|
170
|
+
): JiraCacheIssue {
|
|
171
|
+
return {
|
|
172
|
+
id: issue.id,
|
|
173
|
+
key: issue.key,
|
|
174
|
+
summary: issue.fields.summary,
|
|
175
|
+
descriptionText: issue.fields.description
|
|
176
|
+
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
177
|
+
: '',
|
|
178
|
+
statusId: issue.fields.status.id,
|
|
179
|
+
priorityName: issue.fields.priority?.name ?? null,
|
|
180
|
+
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
181
|
+
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
182
|
+
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
183
|
+
labels: issue.fields.labels ?? [],
|
|
184
|
+
commentCount: issue.fields.comment?.total ?? 0,
|
|
185
|
+
projectKey: issue.fields.project?.key ?? fallbackProjectKey,
|
|
186
|
+
url: `${baseUrl}/browse/${issue.key}`,
|
|
187
|
+
createdAt: issue.fields.created,
|
|
188
|
+
updatedAt: issue.fields.updated,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
161
192
|
/**
|
|
162
193
|
* Shared Jira provider business logic (sync orchestration, hydration, writes,
|
|
163
194
|
* transitions, comments, webhook dispatch, activity mapping). Concrete providers
|
|
@@ -168,9 +199,7 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
168
199
|
readonly type = 'jira' as const
|
|
169
200
|
protected readonly client: JiraClient
|
|
170
201
|
protected readonly pollingSyncIntervalMs: number
|
|
171
|
-
|
|
172
|
-
// are suppressed once the cache is warm so reads/writes never block on Jira I/O.
|
|
173
|
-
private backgroundManaged = false
|
|
202
|
+
private readonly syncGate: SyncGate
|
|
174
203
|
|
|
175
204
|
constructor(
|
|
176
205
|
protected readonly cache: JiraCachePort,
|
|
@@ -178,6 +207,7 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
178
207
|
client?: JiraClient,
|
|
179
208
|
) {
|
|
180
209
|
this.pollingSyncIntervalMs = config.pollingSyncIntervalMs ?? DEFAULT_POLLING_SYNC_INTERVAL_MS
|
|
210
|
+
this.syncGate = new SyncGate(this.pollingSyncIntervalMs)
|
|
181
211
|
this.client =
|
|
182
212
|
client ??
|
|
183
213
|
new JiraClient({
|
|
@@ -194,7 +224,6 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
194
224
|
protected async sync(force = false, viaWarmer = false): Promise<void> {
|
|
195
225
|
await this.cache.ready
|
|
196
226
|
const meta = await this.cache.loadSyncMeta()
|
|
197
|
-
const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
|
|
198
227
|
// Server mode: a background warmer (syncCache(), viaWarmer=true) owns refresh.
|
|
199
228
|
// Once the cache has synced at least once (lastSyncAt persisted), implicit
|
|
200
229
|
// request-path reads serve the warm cache instead of blocking on a Jira
|
|
@@ -202,9 +231,8 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
202
231
|
// (~minutes) and would otherwise exceed the HTTP idle timeout. Forced syncs
|
|
203
232
|
// (writes' read-after-write) and the warmer still run; CLI mode and cold start
|
|
204
233
|
// (no prior sync) fall through and sync synchronously, preserving freshness.
|
|
205
|
-
if (this.
|
|
234
|
+
if (this.syncGate.shouldSkip({ force, viaWarmer, lastSyncAt: meta.lastSyncAt })) return
|
|
206
235
|
const now = Date.now()
|
|
207
|
-
if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
|
|
208
236
|
// `force` only bypasses the poll throttle; it must NOT imply a full
|
|
209
237
|
// 1970-based reconcile, which re-fetches every issue plus a per-issue
|
|
210
238
|
// changelog call (~minutes). Writes get read-after-write freshness from
|
|
@@ -216,10 +244,16 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
216
244
|
await this.cache.saveTeamInfo({ id: project.id, key: project.key, name: project.name })
|
|
217
245
|
|
|
218
246
|
// 2. Columns: board path OR status fallback path.
|
|
247
|
+
// The projection buckets issues by status-id membership in a column's
|
|
248
|
+
// status_ids; a status id present on an issue but in no column is cached but
|
|
249
|
+
// invisible to every listTasks({column}). Track the mapped set so we can
|
|
250
|
+
// surface such unmapped statuses below instead of dropping them silently.
|
|
251
|
+
const mappedStatusIds = new Set<string>()
|
|
219
252
|
if (this.config.boardId !== undefined) {
|
|
220
253
|
const boardCfg = await this.client.getBoardColumns(this.config.boardId)
|
|
221
254
|
const boardId = this.config.boardId
|
|
222
255
|
const rows = jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns)
|
|
256
|
+
for (const row of rows) for (const id of row.statusIds) mappedStatusIds.add(id)
|
|
223
257
|
await this.cache.replaceColumns(rows, fullReconcile)
|
|
224
258
|
} else {
|
|
225
259
|
const statusCats = await this.client.getProjectStatuses(project.key)
|
|
@@ -239,8 +273,11 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
239
273
|
statusIds: [s.id],
|
|
240
274
|
source: 'status' as const,
|
|
241
275
|
}))
|
|
276
|
+
for (const row of rows) for (const id of row.statusIds) mappedStatusIds.add(id)
|
|
242
277
|
await this.cache.replaceColumns(rows, fullReconcile)
|
|
243
278
|
}
|
|
279
|
+
// Issue statuses seen this sync that map to no column (statusId → name).
|
|
280
|
+
const unmappedStatuses = new Map<string, string>()
|
|
244
281
|
|
|
245
282
|
// 3. Catalogs: users + priorities + issue types in parallel.
|
|
246
283
|
// NOTE: listAssignableUsers is capped at 100 in T04; tenants with more
|
|
@@ -317,25 +354,7 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
317
354
|
})
|
|
318
355
|
|
|
319
356
|
await this.cache.upsertIssues(
|
|
320
|
-
page.issues.map((issue) => (
|
|
321
|
-
id: issue.id,
|
|
322
|
-
key: issue.key,
|
|
323
|
-
summary: issue.fields.summary,
|
|
324
|
-
descriptionText: issue.fields.description
|
|
325
|
-
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
326
|
-
: '',
|
|
327
|
-
statusId: issue.fields.status.id,
|
|
328
|
-
priorityName: issue.fields.priority?.name ?? null,
|
|
329
|
-
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
330
|
-
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
331
|
-
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
332
|
-
labels: issue.fields.labels ?? [],
|
|
333
|
-
commentCount: issue.fields.comment?.total ?? 0,
|
|
334
|
-
projectKey: issue.fields.project?.key ?? project.key,
|
|
335
|
-
url: `${this.config.baseUrl}/browse/${issue.key}`,
|
|
336
|
-
createdAt: issue.fields.created,
|
|
337
|
-
updatedAt: issue.fields.updated,
|
|
338
|
-
})),
|
|
357
|
+
page.issues.map((issue) => toCacheIssue(issue, this.config.baseUrl, project.key)),
|
|
339
358
|
)
|
|
340
359
|
|
|
341
360
|
for (const issue of page.issues) {
|
|
@@ -343,19 +362,22 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
343
362
|
if (newestUpdatedAt === null || issue.fields.updated > newestUpdatedAt) {
|
|
344
363
|
newestUpdatedAt = issue.fields.updated
|
|
345
364
|
}
|
|
365
|
+
if (!mappedStatusIds.has(issue.fields.status.id)) {
|
|
366
|
+
unmappedStatuses.set(issue.fields.status.id, issue.fields.status.name)
|
|
367
|
+
}
|
|
346
368
|
}
|
|
347
369
|
|
|
348
370
|
// Fetch changelog per changed issue so the poll-based
|
|
349
371
|
// `moved` trigger in @garage/dispatch works. Server-side dedupe
|
|
350
372
|
// keyed on (issue_id, history_id, item_field) keeps this cheap
|
|
351
373
|
// even if the same issue is updated repeatedly.
|
|
352
|
-
|
|
374
|
+
await forEachWithConcurrency(page.issues, 5, async (issue) => {
|
|
353
375
|
await this.ingestIssueActivity(issue.id).catch((err) => {
|
|
354
376
|
// Activity is best-effort; the main sync shouldn't fail if
|
|
355
377
|
// one changelog call 404s or rate-limits.
|
|
356
378
|
console.warn(`[jira] activity fetch for ${issue.key} failed:`, err)
|
|
357
379
|
})
|
|
358
|
-
}
|
|
380
|
+
})
|
|
359
381
|
|
|
360
382
|
const decision = decideJiraPagination(page, seenPageTokens)
|
|
361
383
|
if (decision.nextToken !== undefined) {
|
|
@@ -375,6 +397,15 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
375
397
|
break
|
|
376
398
|
}
|
|
377
399
|
|
|
400
|
+
// Surface issues whose status maps to no column. They are cached but invisible
|
|
401
|
+
// to every listTasks({column}) and getCachedBoard — a silent drop that strands
|
|
402
|
+
// tickets (e.g. a poller's trigger column never sees them). Warn once per
|
|
403
|
+
// (project, status) so a misconfigured board or a status missing from the
|
|
404
|
+
// project's status catalog is observable rather than silent.
|
|
405
|
+
for (const [statusId, statusName] of unmappedStatuses) {
|
|
406
|
+
this.warnUnmappedStatus(project.key, statusId, statusName)
|
|
407
|
+
}
|
|
408
|
+
|
|
378
409
|
if (!paginationComplete) {
|
|
379
410
|
// The scan ended early (stalled/contradictory cursor). Leave sync metadata
|
|
380
411
|
// unchanged so this partial result is not recorded as a clean sync: lastSyncAt
|
|
@@ -409,6 +440,18 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
409
440
|
return resolveJiraColumnId(await this.cache.getColumns(), input)
|
|
410
441
|
}
|
|
411
442
|
|
|
443
|
+
private warnUnmappedStatus(projectKey: string, statusId: string, statusName: string): void {
|
|
444
|
+
const hint =
|
|
445
|
+
this.config.boardId !== undefined
|
|
446
|
+
? `it is not on board ${this.config.boardId}`
|
|
447
|
+
: `it is absent from the project status catalog`
|
|
448
|
+
warnOnce(
|
|
449
|
+
`jira-unmapped-status:${projectKey}:${statusId}`,
|
|
450
|
+
`[jira] ${projectKey} status '${statusName}' (${statusId}) maps to no column (${hint}); ` +
|
|
451
|
+
`issues in this status are cached but invisible to listTasks/getBoard`,
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
|
|
412
455
|
private async buildBoardConfig(): Promise<BoardConfig> {
|
|
413
456
|
const cache = await this.cache.getCachedConfig()
|
|
414
457
|
const members = cache.users.map((u) => ({
|
|
@@ -434,16 +477,12 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
434
477
|
}
|
|
435
478
|
|
|
436
479
|
setBackgroundManaged(managed: boolean): void {
|
|
437
|
-
this.
|
|
480
|
+
this.syncGate.setBackgroundManaged(managed)
|
|
438
481
|
}
|
|
439
482
|
|
|
440
483
|
async getSyncStatus(): Promise<ProviderSyncStatus> {
|
|
441
484
|
const meta = await this.cache.loadSyncMeta()
|
|
442
|
-
return
|
|
443
|
-
lastSyncAt: meta.lastSyncAt,
|
|
444
|
-
lastFullSyncAt: meta.lastFullSyncAt,
|
|
445
|
-
lastWebhookAt: meta.lastWebhookAt,
|
|
446
|
-
}
|
|
485
|
+
return syncStatusFromMeta(meta)
|
|
447
486
|
}
|
|
448
487
|
|
|
449
488
|
async getContext(): Promise<ProviderContext> {
|
|
@@ -488,15 +527,10 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
488
527
|
async listTasks(filters: TaskListFilters = {}): Promise<Task[]> {
|
|
489
528
|
await this.sync()
|
|
490
529
|
const columnId = filters.column ? await this.resolveColumnId(filters.column) : undefined
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
|
|
496
|
-
if (filters.sort === 'updated')
|
|
497
|
-
tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
|
498
|
-
if (filters.limit) tasks = tasks.slice(0, filters.limit)
|
|
499
|
-
return tasks
|
|
530
|
+
return applyTaskFilters(
|
|
531
|
+
await this.cache.getCachedTasks(columnId ? { columnId } : undefined),
|
|
532
|
+
filters,
|
|
533
|
+
)
|
|
500
534
|
}
|
|
501
535
|
|
|
502
536
|
async getTask(idOrRef: string): Promise<Task> {
|
|
@@ -588,25 +622,7 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
588
622
|
// it rather than threading an unreachable null through every caller.
|
|
589
623
|
const issue = await this.client.getIssue(key)
|
|
590
624
|
await this.cache.upsertIssues([
|
|
591
|
-
|
|
592
|
-
id: issue.id,
|
|
593
|
-
key: issue.key,
|
|
594
|
-
summary: issue.fields.summary,
|
|
595
|
-
descriptionText: issue.fields.description
|
|
596
|
-
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
597
|
-
: '',
|
|
598
|
-
statusId: issue.fields.status.id,
|
|
599
|
-
priorityName: issue.fields.priority?.name ?? null,
|
|
600
|
-
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
601
|
-
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
602
|
-
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
603
|
-
labels: issue.fields.labels ?? [],
|
|
604
|
-
commentCount: issue.fields.comment?.total ?? 0,
|
|
605
|
-
projectKey: issue.fields.project?.key ?? this.config.projectKey,
|
|
606
|
-
url: `${this.config.baseUrl}/browse/${issue.key}`,
|
|
607
|
-
createdAt: issue.fields.created,
|
|
608
|
-
updatedAt: issue.fields.updated,
|
|
609
|
-
},
|
|
625
|
+
toCacheIssue(issue, this.config.baseUrl, this.config.projectKey),
|
|
610
626
|
])
|
|
611
627
|
// Ingest the changelog like the sync loop does, so a just-applied transition
|
|
612
628
|
// is recorded in jira_activity immediately (backs getActivity and the
|
|
@@ -894,25 +910,7 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
894
910
|
}
|
|
895
911
|
}
|
|
896
912
|
await this.cache.upsertIssues([
|
|
897
|
-
|
|
898
|
-
id: issue.id,
|
|
899
|
-
key: issue.key,
|
|
900
|
-
summary: issue.fields.summary,
|
|
901
|
-
descriptionText: issue.fields.description
|
|
902
|
-
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
903
|
-
: '',
|
|
904
|
-
statusId: issue.fields.status.id,
|
|
905
|
-
priorityName: issue.fields.priority?.name ?? null,
|
|
906
|
-
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
907
|
-
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
908
|
-
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
909
|
-
labels: issue.fields.labels ?? [],
|
|
910
|
-
commentCount: issue.fields.comment?.total ?? 0,
|
|
911
|
-
projectKey,
|
|
912
|
-
url: `${this.config.baseUrl}/browse/${issue.key}`,
|
|
913
|
-
createdAt: issue.fields.created,
|
|
914
|
-
updatedAt: issue.fields.updated,
|
|
915
|
-
},
|
|
913
|
+
toCacheIssue(issue, this.config.baseUrl, this.config.projectKey),
|
|
916
914
|
])
|
|
917
915
|
if (event === 'jira:issue_updated') {
|
|
918
916
|
await this.ingestIssueActivity(issue.id).catch((err) => {
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import { normalizeColumnName } from '../column-roles'
|
|
3
|
+
import { ErrorCode, KanbanError } from '../errors'
|
|
2
4
|
import type { BoardConfig, BoardView, ProviderTeamInfo, Task } from '../types'
|
|
5
|
+
import { linearTaskFromRow, type LinearTaskRow } from './cache-task-mappers'
|
|
6
|
+
import { parseProviderTeamInfo } from './team-info'
|
|
3
7
|
|
|
4
8
|
export interface LinearStateRow {
|
|
5
9
|
id: string
|
|
@@ -19,6 +23,39 @@ export interface LinearSyncMeta {
|
|
|
19
23
|
lastWebhookAt: string | null
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
function ambiguousStateError(input: string, matches: LinearStateRow[]): KanbanError {
|
|
27
|
+
return new KanbanError(
|
|
28
|
+
ErrorCode.COLUMN_NOT_FOUND,
|
|
29
|
+
`Linear state name '${input}' is ambiguous; use one of these state ids: ${matches
|
|
30
|
+
.map((state) => state.id)
|
|
31
|
+
.join(', ')}`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Resolves a user-supplied column reference to a cached workflow state, trying
|
|
36
|
+
// (1) exact id, (2) case-insensitive name, (3) separator-insensitive name
|
|
37
|
+
// ('in-progress' → 'In Progress') — mirroring resolveJiraColumnId so collapsed
|
|
38
|
+
// trigger strings resolve on both providers instead of throwing
|
|
39
|
+
// COLUMN_NOT_FOUND. Exact passes run first so a precise reference is never
|
|
40
|
+
// overridden by a normalized-name collision; name lookups matching multiple
|
|
41
|
+
// states are rejected as ambiguous so the caller picks an id. A token that
|
|
42
|
+
// collapses to empty is skipped so punctuation-only input never matches.
|
|
43
|
+
export function resolveLinearState(states: LinearStateRow[], input: string): LinearStateRow {
|
|
44
|
+
const byId = states.find((state) => state.id === input)
|
|
45
|
+
if (byId) return byId
|
|
46
|
+
const lower = input.toLowerCase()
|
|
47
|
+
const byName = states.filter((state) => state.name.toLowerCase() === lower)
|
|
48
|
+
if (byName.length === 1) return byName[0]!
|
|
49
|
+
if (byName.length > 1) throw ambiguousStateError(input, byName)
|
|
50
|
+
const normalized = normalizeColumnName(input)
|
|
51
|
+
if (normalized !== '') {
|
|
52
|
+
const byNormalized = states.filter((state) => normalizeColumnName(state.name) === normalized)
|
|
53
|
+
if (byNormalized.length === 1) return byNormalized[0]!
|
|
54
|
+
if (byNormalized.length > 1) throw ambiguousStateError(input, byNormalized)
|
|
55
|
+
}
|
|
56
|
+
throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No Linear workflow state matching '${input}'`)
|
|
57
|
+
}
|
|
58
|
+
|
|
22
59
|
export function initLinearCacheSchema(db: Database): void {
|
|
23
60
|
db.run(`
|
|
24
61
|
CREATE TABLE IF NOT EXISTS linear_sync_meta (
|
|
@@ -200,9 +237,8 @@ export function saveSyncMeta(db: Database, meta: Partial<LinearSyncMeta>): void
|
|
|
200
237
|
}
|
|
201
238
|
|
|
202
239
|
export function loadSyncMeta(db: Database): LinearSyncMeta {
|
|
203
|
-
const teamRaw = getMeta(db, 'team')
|
|
204
240
|
return {
|
|
205
|
-
team:
|
|
241
|
+
team: parseProviderTeamInfo(getMeta(db, 'team')),
|
|
206
242
|
lastSyncAt: getMeta(db, 'lastSyncAt'),
|
|
207
243
|
lastFullSyncAt: getMeta(db, 'lastFullSyncAt'),
|
|
208
244
|
lastIssueUpdatedAt: getMeta(db, 'lastIssueUpdatedAt'),
|
|
@@ -442,70 +478,7 @@ export function getCachedColumns(db: Database): LinearStateRow[] {
|
|
|
442
478
|
return db.query('SELECT * FROM linear_states ORDER BY position, name').all() as LinearStateRow[]
|
|
443
479
|
}
|
|
444
480
|
|
|
445
|
-
|
|
446
|
-
switch (priority) {
|
|
447
|
-
case 1:
|
|
448
|
-
return 'urgent'
|
|
449
|
-
case 2:
|
|
450
|
-
return 'high'
|
|
451
|
-
case 3:
|
|
452
|
-
return 'medium'
|
|
453
|
-
case 0:
|
|
454
|
-
case 4:
|
|
455
|
-
default:
|
|
456
|
-
return 'low'
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
interface LinearIssueRow {
|
|
461
|
-
id: string
|
|
462
|
-
identifier: string
|
|
463
|
-
title: string
|
|
464
|
-
description: string
|
|
465
|
-
state_id: string
|
|
466
|
-
state_position: number
|
|
467
|
-
priority: number
|
|
468
|
-
assignee_name: string
|
|
469
|
-
project_name: string
|
|
470
|
-
labels: string
|
|
471
|
-
comment_count: number
|
|
472
|
-
url: string | null
|
|
473
|
-
created_at: string
|
|
474
|
-
updated_at: string
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
function parseLabels(raw: string): string[] {
|
|
478
|
-
try {
|
|
479
|
-
const parsed: unknown = JSON.parse(raw)
|
|
480
|
-
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
|
|
481
|
-
} catch {
|
|
482
|
-
return []
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
function taskFromRow(row: LinearIssueRow): Task {
|
|
487
|
-
return {
|
|
488
|
-
id: `linear:${row.id}`,
|
|
489
|
-
providerId: row.id,
|
|
490
|
-
externalRef: row.identifier,
|
|
491
|
-
url: row.url,
|
|
492
|
-
title: row.title,
|
|
493
|
-
description: row.description,
|
|
494
|
-
column_id: row.state_id,
|
|
495
|
-
position: row.state_position,
|
|
496
|
-
priority: mapPriority(row.priority),
|
|
497
|
-
assignee: row.assignee_name,
|
|
498
|
-
assignees: row.assignee_name ? [row.assignee_name] : [],
|
|
499
|
-
labels: parseLabels(row.labels),
|
|
500
|
-
comment_count: row.comment_count,
|
|
501
|
-
project: row.project_name,
|
|
502
|
-
metadata: '{}',
|
|
503
|
-
created_at: row.created_at,
|
|
504
|
-
updated_at: row.updated_at,
|
|
505
|
-
version: row.updated_at,
|
|
506
|
-
source_updated_at: row.updated_at,
|
|
507
|
-
}
|
|
508
|
-
}
|
|
481
|
+
type LinearIssueRow = LinearTaskRow
|
|
509
482
|
|
|
510
483
|
export function getCachedBoard(db: Database): BoardView {
|
|
511
484
|
const columns = getCachedColumns(db)
|
|
@@ -520,7 +493,7 @@ export function getCachedBoard(db: Database): BoardView {
|
|
|
520
493
|
ORDER BY updated_at DESC, title ASC`,
|
|
521
494
|
)
|
|
522
495
|
.all({ $state_id: column.id }) as LinearIssueRow[]
|
|
523
|
-
).map(
|
|
496
|
+
).map(linearTaskFromRow),
|
|
524
497
|
})),
|
|
525
498
|
}
|
|
526
499
|
}
|
|
@@ -534,7 +507,7 @@ export function getCachedTask(db: Database, lookup: string): Task | null {
|
|
|
534
507
|
LIMIT 1`,
|
|
535
508
|
)
|
|
536
509
|
.get({ $lookup: normalized }) as LinearIssueRow | null
|
|
537
|
-
return row ?
|
|
510
|
+
return row ? linearTaskFromRow(row) : null
|
|
538
511
|
}
|
|
539
512
|
|
|
540
513
|
export function getCachedTasks(db: Database): Task[] {
|
|
@@ -542,7 +515,7 @@ export function getCachedTasks(db: Database): Task[] {
|
|
|
542
515
|
db
|
|
543
516
|
.query('SELECT * FROM linear_issues ORDER BY updated_at DESC, title ASC')
|
|
544
517
|
.all() as LinearIssueRow[]
|
|
545
|
-
).map(
|
|
518
|
+
).map(linearTaskFromRow)
|
|
546
519
|
}
|
|
547
520
|
|
|
548
521
|
export function getCachedConfig(db: Database): BoardConfig {
|
|
@@ -24,7 +24,12 @@ import {
|
|
|
24
24
|
type LinearComment,
|
|
25
25
|
type LinearIssue,
|
|
26
26
|
} from './linear-client'
|
|
27
|
-
import
|
|
27
|
+
import {
|
|
28
|
+
resolveLinearState,
|
|
29
|
+
type LinearActivityRow,
|
|
30
|
+
type LinearStateRow,
|
|
31
|
+
type LinearSyncMeta,
|
|
32
|
+
} from './linear-cache'
|
|
28
33
|
import { providerUpstreamError, unsupportedOperation } from './errors'
|
|
29
34
|
import type {
|
|
30
35
|
CreateTaskInput,
|
|
@@ -36,22 +41,16 @@ import type {
|
|
|
36
41
|
} from './types'
|
|
37
42
|
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
|
|
38
43
|
import { warnOnce } from './warn-once'
|
|
44
|
+
import {
|
|
45
|
+
applyTaskFilters,
|
|
46
|
+
maxSyncTimestamp,
|
|
47
|
+
parseSyncTimestamp,
|
|
48
|
+
SyncGate,
|
|
49
|
+
syncStatusFromMeta,
|
|
50
|
+
} from './sync-core'
|
|
39
51
|
|
|
40
52
|
const FULL_RECONCILIATION_INTERVAL_MS = 5 * 60_000
|
|
41
53
|
|
|
42
|
-
function parseTimestamp(value: string | null | undefined): number {
|
|
43
|
-
if (!value) return 0
|
|
44
|
-
const parsed = Date.parse(value)
|
|
45
|
-
return Number.isFinite(parsed) ? parsed : 0
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function maxTimestamp(a: string | null | undefined, b: string | null | undefined): string | null {
|
|
49
|
-
const aMs = parseTimestamp(a)
|
|
50
|
-
const bMs = parseTimestamp(b)
|
|
51
|
-
if (!aMs && !bMs) return null
|
|
52
|
-
return aMs >= bMs ? (a ?? null) : (b ?? null)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
54
|
function toLinearPriority(priority: Task['priority'] | undefined): number | undefined {
|
|
56
55
|
switch (priority) {
|
|
57
56
|
case 'urgent':
|
|
@@ -169,16 +168,16 @@ export interface LinearCachePort {
|
|
|
169
168
|
*/
|
|
170
169
|
export class LinearProviderCore implements KanbanProvider {
|
|
171
170
|
readonly type = 'linear' as const
|
|
172
|
-
|
|
173
|
-
// are suppressed once the cache is warm so reads/writes never block on Linear I/O.
|
|
174
|
-
private backgroundManaged = false
|
|
171
|
+
private readonly syncGate: SyncGate
|
|
175
172
|
|
|
176
173
|
constructor(
|
|
177
174
|
protected readonly cache: LinearCachePort,
|
|
178
175
|
protected readonly teamId: string,
|
|
179
176
|
protected readonly client: LinearClient,
|
|
180
177
|
protected readonly pollingSyncIntervalMs = DEFAULT_POLLING_SYNC_INTERVAL_MS,
|
|
181
|
-
) {
|
|
178
|
+
) {
|
|
179
|
+
this.syncGate = new SyncGate(this.pollingSyncIntervalMs)
|
|
180
|
+
}
|
|
182
181
|
|
|
183
182
|
async initialize(): Promise<void> {
|
|
184
183
|
await this.cache.ready
|
|
@@ -201,16 +200,14 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
201
200
|
protected async sync(force = false, viaWarmer = false): Promise<void> {
|
|
202
201
|
await this.cache.ready
|
|
203
202
|
const meta = await this.cache.loadSyncMeta()
|
|
204
|
-
const
|
|
205
|
-
const lastFullSyncAtMs = parseTimestamp(meta.lastFullSyncAt)
|
|
203
|
+
const lastFullSyncAtMs = parseSyncTimestamp(meta.lastFullSyncAt)
|
|
206
204
|
// Server mode: a background warmer (syncCache(), viaWarmer=true) owns refresh.
|
|
207
205
|
// Once the cache has synced at least once, implicit request-path reads serve the
|
|
208
206
|
// warm cache instead of blocking on a Linear round-trip, which could exceed the
|
|
209
207
|
// HTTP idle timeout. Forced syncs (writes' read-after-write) and the warmer
|
|
210
208
|
// still run; CLI mode and cold start sync synchronously.
|
|
211
|
-
if (this.
|
|
209
|
+
if (this.syncGate.shouldSkip({ force, viaWarmer, lastSyncAt: meta.lastSyncAt })) return
|
|
212
210
|
const now = Date.now()
|
|
213
|
-
if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
|
|
214
211
|
|
|
215
212
|
// `force` bypasses the poll throttle (above) for read-after-write freshness,
|
|
216
213
|
// but does NOT imply a full workspace reconcile — that stays on its own
|
|
@@ -238,7 +235,7 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
238
235
|
await this.cache.pruneIssues(issues.map((issue) => issue.id))
|
|
239
236
|
}
|
|
240
237
|
|
|
241
|
-
const newestIssueTimestamp =
|
|
238
|
+
const newestIssueTimestamp = maxSyncTimestamp(
|
|
242
239
|
meta.lastIssueUpdatedAt,
|
|
243
240
|
issues.length > 0
|
|
244
241
|
? issues.reduce(
|
|
@@ -268,6 +265,9 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
268
265
|
private async ingestTeamHistory(issueIds: string[], sinceIso: string | null): Promise<void> {
|
|
269
266
|
if (issueIds.length === 0) return
|
|
270
267
|
const concurrency = 5
|
|
268
|
+
// Manual windows rather than one mapWithConcurrency pass: each window's rows
|
|
269
|
+
// are persisted before the next window starts, so a mid-ingest failure keeps
|
|
270
|
+
// everything fetched so far.
|
|
271
271
|
for (let i = 0; i < issueIds.length; i += concurrency) {
|
|
272
272
|
const batch = issueIds.slice(i, i + concurrency)
|
|
273
273
|
const results = await Promise.all(
|
|
@@ -319,6 +319,10 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
319
319
|
return task
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
private issueIdFor(task: Task): string {
|
|
323
|
+
return task.providerId || task.id.replace(/^linear:/, '')
|
|
324
|
+
}
|
|
325
|
+
|
|
322
326
|
// Read-after-write refresh of a single issue. Re-fetching just the mutated
|
|
323
327
|
// issue (and its history) keeps the post-write read fresh without paying for a
|
|
324
328
|
// full team sync(true)/prune on every update or move.
|
|
@@ -345,17 +349,7 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
345
349
|
}
|
|
346
350
|
|
|
347
351
|
private async resolveState(column: string): Promise<Column> {
|
|
348
|
-
|
|
349
|
-
const match = states.find(
|
|
350
|
-
(state) => state.id === column || state.name.toLowerCase() === column.toLowerCase(),
|
|
351
|
-
)
|
|
352
|
-
if (!match) {
|
|
353
|
-
throw new KanbanError(
|
|
354
|
-
ErrorCode.COLUMN_NOT_FOUND,
|
|
355
|
-
`No Linear workflow state matching '${column}'`,
|
|
356
|
-
)
|
|
357
|
-
}
|
|
358
|
-
return match
|
|
352
|
+
return resolveLinearState(await this.cache.getCachedColumns(), column)
|
|
359
353
|
}
|
|
360
354
|
|
|
361
355
|
// Only invoked for non-empty names. A name that the cache cannot resolve is a
|
|
@@ -397,16 +391,12 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
397
391
|
}
|
|
398
392
|
|
|
399
393
|
setBackgroundManaged(managed: boolean): void {
|
|
400
|
-
this.
|
|
394
|
+
this.syncGate.setBackgroundManaged(managed)
|
|
401
395
|
}
|
|
402
396
|
|
|
403
397
|
async getSyncStatus(): Promise<ProviderSyncStatus> {
|
|
404
398
|
const meta = await this.cache.loadSyncMeta()
|
|
405
|
-
return
|
|
406
|
-
lastSyncAt: meta.lastSyncAt,
|
|
407
|
-
lastFullSyncAt: meta.lastFullSyncAt,
|
|
408
|
-
lastWebhookAt: meta.lastWebhookAt,
|
|
409
|
-
}
|
|
399
|
+
return syncStatusFromMeta(meta)
|
|
410
400
|
}
|
|
411
401
|
|
|
412
402
|
async getContext(): Promise<ProviderContext> {
|
|
@@ -449,14 +439,7 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
449
439
|
const column = await this.resolveState(filters.column)
|
|
450
440
|
tasks = tasks.filter((task) => task.column_id === column.id)
|
|
451
441
|
}
|
|
452
|
-
|
|
453
|
-
if (filters.assignee) tasks = tasks.filter((task) => task.assignee === filters.assignee)
|
|
454
|
-
if (filters.project) tasks = tasks.filter((task) => task.project === filters.project)
|
|
455
|
-
if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
|
|
456
|
-
if (filters.sort === 'updated')
|
|
457
|
-
tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
|
458
|
-
if (filters.limit) tasks = tasks.slice(0, filters.limit)
|
|
459
|
-
return tasks
|
|
442
|
+
return applyTaskFilters(tasks, filters)
|
|
460
443
|
}
|
|
461
444
|
|
|
462
445
|
async getTask(idOrRef: string): Promise<Task> {
|
|
@@ -508,22 +491,24 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
508
491
|
if (input.metadata !== undefined) {
|
|
509
492
|
unsupportedOperation('Linear mode does not support metadata updates')
|
|
510
493
|
}
|
|
511
|
-
const
|
|
494
|
+
const issueId = this.issueIdFor(task)
|
|
495
|
+
const result = await this.client.updateIssue(issueId, updateInput)
|
|
512
496
|
if (!result.success) {
|
|
513
497
|
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue update failed')
|
|
514
498
|
}
|
|
515
|
-
return this.hydrateIssue(
|
|
499
|
+
return this.hydrateIssue(issueId)
|
|
516
500
|
}
|
|
517
501
|
|
|
518
502
|
async moveTask(idOrRef: string, column: string): Promise<Task> {
|
|
519
503
|
await this.sync()
|
|
520
504
|
const task = await this.resolveTask(idOrRef)
|
|
521
505
|
const state = await this.resolveState(column)
|
|
522
|
-
const
|
|
506
|
+
const issueId = this.issueIdFor(task)
|
|
507
|
+
const result = await this.client.updateIssue(issueId, { stateId: state.id })
|
|
523
508
|
if (!result.success) {
|
|
524
509
|
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue move failed')
|
|
525
510
|
}
|
|
526
|
-
return this.hydrateIssue(
|
|
511
|
+
return this.hydrateIssue(issueId)
|
|
527
512
|
}
|
|
528
513
|
|
|
529
514
|
async deleteTask(_idOrRef: string): Promise<Task> {
|
|
@@ -533,7 +518,7 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
533
518
|
async listComments(idOrRef: string): Promise<TaskComment[]> {
|
|
534
519
|
await this.sync()
|
|
535
520
|
const task = await this.resolveTask(idOrRef)
|
|
536
|
-
const comments = await this.client.listComments(
|
|
521
|
+
const comments = await this.client.listComments(this.issueIdFor(task))
|
|
537
522
|
return comments.map((comment) => this.toTaskComment(task, comment))
|
|
538
523
|
}
|
|
539
524
|
|
|
@@ -547,11 +532,12 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
547
532
|
async comment(idOrRef: string, body: string): Promise<TaskComment> {
|
|
548
533
|
await this.sync()
|
|
549
534
|
const task = await this.resolveTask(idOrRef)
|
|
550
|
-
const
|
|
535
|
+
const issueId = this.issueIdFor(task)
|
|
536
|
+
const result = await this.client.commentCreate(issueId, body)
|
|
551
537
|
if (!result.success || !result.comment) {
|
|
552
538
|
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear comment creation failed')
|
|
553
539
|
}
|
|
554
|
-
await this.cache.adjustIssueCommentCount(
|
|
540
|
+
await this.cache.adjustIssueCommentCount(issueId, 1)
|
|
555
541
|
return this.toTaskComment(task, result.comment)
|
|
556
542
|
}
|
|
557
543
|
|