@andypai/agent-kanban 0.6.3 → 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 +81 -81
- package/src/providers/linear-cache.ts +42 -69
- package/src/providers/linear-core.ts +44 -56
- 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 +15 -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
|
@@ -47,6 +47,8 @@ import type {
|
|
|
47
47
|
UpdateTaskInput,
|
|
48
48
|
} from './types'
|
|
49
49
|
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
|
|
50
|
+
import { warnOnce } from './warn-once'
|
|
51
|
+
import { applyTaskFilters, forEachWithConcurrency, SyncGate, syncStatusFromMeta } from './sync-core'
|
|
50
52
|
|
|
51
53
|
export const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
|
|
52
54
|
|
|
@@ -157,6 +159,36 @@ export interface JiraCachePort {
|
|
|
157
159
|
resolveIssueId(lookup: string): Promise<string | null>
|
|
158
160
|
}
|
|
159
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
|
+
|
|
160
192
|
/**
|
|
161
193
|
* Shared Jira provider business logic (sync orchestration, hydration, writes,
|
|
162
194
|
* transitions, comments, webhook dispatch, activity mapping). Concrete providers
|
|
@@ -167,9 +199,7 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
167
199
|
readonly type = 'jira' as const
|
|
168
200
|
protected readonly client: JiraClient
|
|
169
201
|
protected readonly pollingSyncIntervalMs: number
|
|
170
|
-
|
|
171
|
-
// are suppressed once the cache is warm so reads/writes never block on Jira I/O.
|
|
172
|
-
private backgroundManaged = false
|
|
202
|
+
private readonly syncGate: SyncGate
|
|
173
203
|
|
|
174
204
|
constructor(
|
|
175
205
|
protected readonly cache: JiraCachePort,
|
|
@@ -177,6 +207,7 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
177
207
|
client?: JiraClient,
|
|
178
208
|
) {
|
|
179
209
|
this.pollingSyncIntervalMs = config.pollingSyncIntervalMs ?? DEFAULT_POLLING_SYNC_INTERVAL_MS
|
|
210
|
+
this.syncGate = new SyncGate(this.pollingSyncIntervalMs)
|
|
180
211
|
this.client =
|
|
181
212
|
client ??
|
|
182
213
|
new JiraClient({
|
|
@@ -193,7 +224,6 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
193
224
|
protected async sync(force = false, viaWarmer = false): Promise<void> {
|
|
194
225
|
await this.cache.ready
|
|
195
226
|
const meta = await this.cache.loadSyncMeta()
|
|
196
|
-
const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
|
|
197
227
|
// Server mode: a background warmer (syncCache(), viaWarmer=true) owns refresh.
|
|
198
228
|
// Once the cache has synced at least once (lastSyncAt persisted), implicit
|
|
199
229
|
// request-path reads serve the warm cache instead of blocking on a Jira
|
|
@@ -201,9 +231,8 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
201
231
|
// (~minutes) and would otherwise exceed the HTTP idle timeout. Forced syncs
|
|
202
232
|
// (writes' read-after-write) and the warmer still run; CLI mode and cold start
|
|
203
233
|
// (no prior sync) fall through and sync synchronously, preserving freshness.
|
|
204
|
-
if (this.
|
|
234
|
+
if (this.syncGate.shouldSkip({ force, viaWarmer, lastSyncAt: meta.lastSyncAt })) return
|
|
205
235
|
const now = Date.now()
|
|
206
|
-
if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
|
|
207
236
|
// `force` only bypasses the poll throttle; it must NOT imply a full
|
|
208
237
|
// 1970-based reconcile, which re-fetches every issue plus a per-issue
|
|
209
238
|
// changelog call (~minutes). Writes get read-after-write freshness from
|
|
@@ -215,10 +244,16 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
215
244
|
await this.cache.saveTeamInfo({ id: project.id, key: project.key, name: project.name })
|
|
216
245
|
|
|
217
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>()
|
|
218
252
|
if (this.config.boardId !== undefined) {
|
|
219
253
|
const boardCfg = await this.client.getBoardColumns(this.config.boardId)
|
|
220
254
|
const boardId = this.config.boardId
|
|
221
255
|
const rows = jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns)
|
|
256
|
+
for (const row of rows) for (const id of row.statusIds) mappedStatusIds.add(id)
|
|
222
257
|
await this.cache.replaceColumns(rows, fullReconcile)
|
|
223
258
|
} else {
|
|
224
259
|
const statusCats = await this.client.getProjectStatuses(project.key)
|
|
@@ -238,8 +273,11 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
238
273
|
statusIds: [s.id],
|
|
239
274
|
source: 'status' as const,
|
|
240
275
|
}))
|
|
276
|
+
for (const row of rows) for (const id of row.statusIds) mappedStatusIds.add(id)
|
|
241
277
|
await this.cache.replaceColumns(rows, fullReconcile)
|
|
242
278
|
}
|
|
279
|
+
// Issue statuses seen this sync that map to no column (statusId → name).
|
|
280
|
+
const unmappedStatuses = new Map<string, string>()
|
|
243
281
|
|
|
244
282
|
// 3. Catalogs: users + priorities + issue types in parallel.
|
|
245
283
|
// NOTE: listAssignableUsers is capped at 100 in T04; tenants with more
|
|
@@ -316,25 +354,7 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
316
354
|
})
|
|
317
355
|
|
|
318
356
|
await this.cache.upsertIssues(
|
|
319
|
-
page.issues.map((issue) => (
|
|
320
|
-
id: issue.id,
|
|
321
|
-
key: issue.key,
|
|
322
|
-
summary: issue.fields.summary,
|
|
323
|
-
descriptionText: issue.fields.description
|
|
324
|
-
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
325
|
-
: '',
|
|
326
|
-
statusId: issue.fields.status.id,
|
|
327
|
-
priorityName: issue.fields.priority?.name ?? null,
|
|
328
|
-
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
329
|
-
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
330
|
-
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
331
|
-
labels: issue.fields.labels ?? [],
|
|
332
|
-
commentCount: issue.fields.comment?.total ?? 0,
|
|
333
|
-
projectKey: issue.fields.project?.key ?? project.key,
|
|
334
|
-
url: `${this.config.baseUrl}/browse/${issue.key}`,
|
|
335
|
-
createdAt: issue.fields.created,
|
|
336
|
-
updatedAt: issue.fields.updated,
|
|
337
|
-
})),
|
|
357
|
+
page.issues.map((issue) => toCacheIssue(issue, this.config.baseUrl, project.key)),
|
|
338
358
|
)
|
|
339
359
|
|
|
340
360
|
for (const issue of page.issues) {
|
|
@@ -342,19 +362,22 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
342
362
|
if (newestUpdatedAt === null || issue.fields.updated > newestUpdatedAt) {
|
|
343
363
|
newestUpdatedAt = issue.fields.updated
|
|
344
364
|
}
|
|
365
|
+
if (!mappedStatusIds.has(issue.fields.status.id)) {
|
|
366
|
+
unmappedStatuses.set(issue.fields.status.id, issue.fields.status.name)
|
|
367
|
+
}
|
|
345
368
|
}
|
|
346
369
|
|
|
347
370
|
// Fetch changelog per changed issue so the poll-based
|
|
348
371
|
// `moved` trigger in @garage/dispatch works. Server-side dedupe
|
|
349
372
|
// keyed on (issue_id, history_id, item_field) keeps this cheap
|
|
350
373
|
// even if the same issue is updated repeatedly.
|
|
351
|
-
|
|
374
|
+
await forEachWithConcurrency(page.issues, 5, async (issue) => {
|
|
352
375
|
await this.ingestIssueActivity(issue.id).catch((err) => {
|
|
353
376
|
// Activity is best-effort; the main sync shouldn't fail if
|
|
354
377
|
// one changelog call 404s or rate-limits.
|
|
355
378
|
console.warn(`[jira] activity fetch for ${issue.key} failed:`, err)
|
|
356
379
|
})
|
|
357
|
-
}
|
|
380
|
+
})
|
|
358
381
|
|
|
359
382
|
const decision = decideJiraPagination(page, seenPageTokens)
|
|
360
383
|
if (decision.nextToken !== undefined) {
|
|
@@ -374,6 +397,15 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
374
397
|
break
|
|
375
398
|
}
|
|
376
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
|
+
|
|
377
409
|
if (!paginationComplete) {
|
|
378
410
|
// The scan ended early (stalled/contradictory cursor). Leave sync metadata
|
|
379
411
|
// unchanged so this partial result is not recorded as a clean sync: lastSyncAt
|
|
@@ -408,6 +440,18 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
408
440
|
return resolveJiraColumnId(await this.cache.getColumns(), input)
|
|
409
441
|
}
|
|
410
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
|
+
|
|
411
455
|
private async buildBoardConfig(): Promise<BoardConfig> {
|
|
412
456
|
const cache = await this.cache.getCachedConfig()
|
|
413
457
|
const members = cache.users.map((u) => ({
|
|
@@ -433,16 +477,12 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
433
477
|
}
|
|
434
478
|
|
|
435
479
|
setBackgroundManaged(managed: boolean): void {
|
|
436
|
-
this.
|
|
480
|
+
this.syncGate.setBackgroundManaged(managed)
|
|
437
481
|
}
|
|
438
482
|
|
|
439
483
|
async getSyncStatus(): Promise<ProviderSyncStatus> {
|
|
440
484
|
const meta = await this.cache.loadSyncMeta()
|
|
441
|
-
return
|
|
442
|
-
lastSyncAt: meta.lastSyncAt,
|
|
443
|
-
lastFullSyncAt: meta.lastFullSyncAt,
|
|
444
|
-
lastWebhookAt: meta.lastWebhookAt,
|
|
445
|
-
}
|
|
485
|
+
return syncStatusFromMeta(meta)
|
|
446
486
|
}
|
|
447
487
|
|
|
448
488
|
async getContext(): Promise<ProviderContext> {
|
|
@@ -487,15 +527,10 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
487
527
|
async listTasks(filters: TaskListFilters = {}): Promise<Task[]> {
|
|
488
528
|
await this.sync()
|
|
489
529
|
const columnId = filters.column ? await this.resolveColumnId(filters.column) : undefined
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
|
|
495
|
-
if (filters.sort === 'updated')
|
|
496
|
-
tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
|
497
|
-
if (filters.limit) tasks = tasks.slice(0, filters.limit)
|
|
498
|
-
return tasks
|
|
530
|
+
return applyTaskFilters(
|
|
531
|
+
await this.cache.getCachedTasks(columnId ? { columnId } : undefined),
|
|
532
|
+
filters,
|
|
533
|
+
)
|
|
499
534
|
}
|
|
500
535
|
|
|
501
536
|
async getTask(idOrRef: string): Promise<Task> {
|
|
@@ -587,25 +622,7 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
587
622
|
// it rather than threading an unreachable null through every caller.
|
|
588
623
|
const issue = await this.client.getIssue(key)
|
|
589
624
|
await this.cache.upsertIssues([
|
|
590
|
-
|
|
591
|
-
id: issue.id,
|
|
592
|
-
key: issue.key,
|
|
593
|
-
summary: issue.fields.summary,
|
|
594
|
-
descriptionText: issue.fields.description
|
|
595
|
-
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
596
|
-
: '',
|
|
597
|
-
statusId: issue.fields.status.id,
|
|
598
|
-
priorityName: issue.fields.priority?.name ?? null,
|
|
599
|
-
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
600
|
-
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
601
|
-
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
602
|
-
labels: issue.fields.labels ?? [],
|
|
603
|
-
commentCount: issue.fields.comment?.total ?? 0,
|
|
604
|
-
projectKey: issue.fields.project?.key ?? this.config.projectKey,
|
|
605
|
-
url: `${this.config.baseUrl}/browse/${issue.key}`,
|
|
606
|
-
createdAt: issue.fields.created,
|
|
607
|
-
updatedAt: issue.fields.updated,
|
|
608
|
-
},
|
|
625
|
+
toCacheIssue(issue, this.config.baseUrl, this.config.projectKey),
|
|
609
626
|
])
|
|
610
627
|
// Ingest the changelog like the sync loop does, so a just-applied transition
|
|
611
628
|
// is recorded in jira_activity immediately (backs getActivity and the
|
|
@@ -856,7 +873,8 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
856
873
|
// SQLite calls it directly via the default handleWebhook above.
|
|
857
874
|
protected async handleWebhookCore(payload: WebhookRequest): Promise<WebhookResult> {
|
|
858
875
|
if (!process.env['JIRA_WEBHOOK_SECRET']) {
|
|
859
|
-
|
|
876
|
+
warnOnce(
|
|
877
|
+
'jira-webhook-open-dev-mode',
|
|
860
878
|
'[jira] JIRA_WEBHOOK_SECRET is not set — accepting webhook without signature verification (open dev mode)',
|
|
861
879
|
)
|
|
862
880
|
}
|
|
@@ -892,25 +910,7 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
892
910
|
}
|
|
893
911
|
}
|
|
894
912
|
await this.cache.upsertIssues([
|
|
895
|
-
|
|
896
|
-
id: issue.id,
|
|
897
|
-
key: issue.key,
|
|
898
|
-
summary: issue.fields.summary,
|
|
899
|
-
descriptionText: issue.fields.description
|
|
900
|
-
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
901
|
-
: '',
|
|
902
|
-
statusId: issue.fields.status.id,
|
|
903
|
-
priorityName: issue.fields.priority?.name ?? null,
|
|
904
|
-
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
905
|
-
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
906
|
-
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
907
|
-
labels: issue.fields.labels ?? [],
|
|
908
|
-
commentCount: issue.fields.comment?.total ?? 0,
|
|
909
|
-
projectKey,
|
|
910
|
-
url: `${this.config.baseUrl}/browse/${issue.key}`,
|
|
911
|
-
createdAt: issue.fields.created,
|
|
912
|
-
updatedAt: issue.fields.updated,
|
|
913
|
-
},
|
|
913
|
+
toCacheIssue(issue, this.config.baseUrl, this.config.projectKey),
|
|
914
914
|
])
|
|
915
915
|
if (event === 'jira:issue_updated') {
|
|
916
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,
|
|
@@ -35,22 +40,17 @@ import type {
|
|
|
35
40
|
UpdateTaskInput,
|
|
36
41
|
} from './types'
|
|
37
42
|
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
|
|
43
|
+
import { warnOnce } from './warn-once'
|
|
44
|
+
import {
|
|
45
|
+
applyTaskFilters,
|
|
46
|
+
maxSyncTimestamp,
|
|
47
|
+
parseSyncTimestamp,
|
|
48
|
+
SyncGate,
|
|
49
|
+
syncStatusFromMeta,
|
|
50
|
+
} from './sync-core'
|
|
38
51
|
|
|
39
52
|
const FULL_RECONCILIATION_INTERVAL_MS = 5 * 60_000
|
|
40
53
|
|
|
41
|
-
function parseTimestamp(value: string | null | undefined): number {
|
|
42
|
-
if (!value) return 0
|
|
43
|
-
const parsed = Date.parse(value)
|
|
44
|
-
return Number.isFinite(parsed) ? parsed : 0
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function maxTimestamp(a: string | null | undefined, b: string | null | undefined): string | null {
|
|
48
|
-
const aMs = parseTimestamp(a)
|
|
49
|
-
const bMs = parseTimestamp(b)
|
|
50
|
-
if (!aMs && !bMs) return null
|
|
51
|
-
return aMs >= bMs ? (a ?? null) : (b ?? null)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
54
|
function toLinearPriority(priority: Task['priority'] | undefined): number | undefined {
|
|
55
55
|
switch (priority) {
|
|
56
56
|
case 'urgent':
|
|
@@ -168,16 +168,16 @@ export interface LinearCachePort {
|
|
|
168
168
|
*/
|
|
169
169
|
export class LinearProviderCore implements KanbanProvider {
|
|
170
170
|
readonly type = 'linear' as const
|
|
171
|
-
|
|
172
|
-
// are suppressed once the cache is warm so reads/writes never block on Linear I/O.
|
|
173
|
-
private backgroundManaged = false
|
|
171
|
+
private readonly syncGate: SyncGate
|
|
174
172
|
|
|
175
173
|
constructor(
|
|
176
174
|
protected readonly cache: LinearCachePort,
|
|
177
175
|
protected readonly teamId: string,
|
|
178
176
|
protected readonly client: LinearClient,
|
|
179
177
|
protected readonly pollingSyncIntervalMs = DEFAULT_POLLING_SYNC_INTERVAL_MS,
|
|
180
|
-
) {
|
|
178
|
+
) {
|
|
179
|
+
this.syncGate = new SyncGate(this.pollingSyncIntervalMs)
|
|
180
|
+
}
|
|
181
181
|
|
|
182
182
|
async initialize(): Promise<void> {
|
|
183
183
|
await this.cache.ready
|
|
@@ -200,16 +200,14 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
200
200
|
protected async sync(force = false, viaWarmer = false): Promise<void> {
|
|
201
201
|
await this.cache.ready
|
|
202
202
|
const meta = await this.cache.loadSyncMeta()
|
|
203
|
-
const
|
|
204
|
-
const lastFullSyncAtMs = parseTimestamp(meta.lastFullSyncAt)
|
|
203
|
+
const lastFullSyncAtMs = parseSyncTimestamp(meta.lastFullSyncAt)
|
|
205
204
|
// Server mode: a background warmer (syncCache(), viaWarmer=true) owns refresh.
|
|
206
205
|
// Once the cache has synced at least once, implicit request-path reads serve the
|
|
207
206
|
// warm cache instead of blocking on a Linear round-trip, which could exceed the
|
|
208
207
|
// HTTP idle timeout. Forced syncs (writes' read-after-write) and the warmer
|
|
209
208
|
// still run; CLI mode and cold start sync synchronously.
|
|
210
|
-
if (this.
|
|
209
|
+
if (this.syncGate.shouldSkip({ force, viaWarmer, lastSyncAt: meta.lastSyncAt })) return
|
|
211
210
|
const now = Date.now()
|
|
212
|
-
if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
|
|
213
211
|
|
|
214
212
|
// `force` bypasses the poll throttle (above) for read-after-write freshness,
|
|
215
213
|
// but does NOT imply a full workspace reconcile — that stays on its own
|
|
@@ -237,7 +235,7 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
237
235
|
await this.cache.pruneIssues(issues.map((issue) => issue.id))
|
|
238
236
|
}
|
|
239
237
|
|
|
240
|
-
const newestIssueTimestamp =
|
|
238
|
+
const newestIssueTimestamp = maxSyncTimestamp(
|
|
241
239
|
meta.lastIssueUpdatedAt,
|
|
242
240
|
issues.length > 0
|
|
243
241
|
? issues.reduce(
|
|
@@ -267,6 +265,9 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
267
265
|
private async ingestTeamHistory(issueIds: string[], sinceIso: string | null): Promise<void> {
|
|
268
266
|
if (issueIds.length === 0) return
|
|
269
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.
|
|
270
271
|
for (let i = 0; i < issueIds.length; i += concurrency) {
|
|
271
272
|
const batch = issueIds.slice(i, i + concurrency)
|
|
272
273
|
const results = await Promise.all(
|
|
@@ -318,6 +319,10 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
318
319
|
return task
|
|
319
320
|
}
|
|
320
321
|
|
|
322
|
+
private issueIdFor(task: Task): string {
|
|
323
|
+
return task.providerId || task.id.replace(/^linear:/, '')
|
|
324
|
+
}
|
|
325
|
+
|
|
321
326
|
// Read-after-write refresh of a single issue. Re-fetching just the mutated
|
|
322
327
|
// issue (and its history) keeps the post-write read fresh without paying for a
|
|
323
328
|
// full team sync(true)/prune on every update or move.
|
|
@@ -344,17 +349,7 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
344
349
|
}
|
|
345
350
|
|
|
346
351
|
private async resolveState(column: string): Promise<Column> {
|
|
347
|
-
|
|
348
|
-
const match = states.find(
|
|
349
|
-
(state) => state.id === column || state.name.toLowerCase() === column.toLowerCase(),
|
|
350
|
-
)
|
|
351
|
-
if (!match) {
|
|
352
|
-
throw new KanbanError(
|
|
353
|
-
ErrorCode.COLUMN_NOT_FOUND,
|
|
354
|
-
`No Linear workflow state matching '${column}'`,
|
|
355
|
-
)
|
|
356
|
-
}
|
|
357
|
-
return match
|
|
352
|
+
return resolveLinearState(await this.cache.getCachedColumns(), column)
|
|
358
353
|
}
|
|
359
354
|
|
|
360
355
|
// Only invoked for non-empty names. A name that the cache cannot resolve is a
|
|
@@ -396,16 +391,12 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
396
391
|
}
|
|
397
392
|
|
|
398
393
|
setBackgroundManaged(managed: boolean): void {
|
|
399
|
-
this.
|
|
394
|
+
this.syncGate.setBackgroundManaged(managed)
|
|
400
395
|
}
|
|
401
396
|
|
|
402
397
|
async getSyncStatus(): Promise<ProviderSyncStatus> {
|
|
403
398
|
const meta = await this.cache.loadSyncMeta()
|
|
404
|
-
return
|
|
405
|
-
lastSyncAt: meta.lastSyncAt,
|
|
406
|
-
lastFullSyncAt: meta.lastFullSyncAt,
|
|
407
|
-
lastWebhookAt: meta.lastWebhookAt,
|
|
408
|
-
}
|
|
399
|
+
return syncStatusFromMeta(meta)
|
|
409
400
|
}
|
|
410
401
|
|
|
411
402
|
async getContext(): Promise<ProviderContext> {
|
|
@@ -448,14 +439,7 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
448
439
|
const column = await this.resolveState(filters.column)
|
|
449
440
|
tasks = tasks.filter((task) => task.column_id === column.id)
|
|
450
441
|
}
|
|
451
|
-
|
|
452
|
-
if (filters.assignee) tasks = tasks.filter((task) => task.assignee === filters.assignee)
|
|
453
|
-
if (filters.project) tasks = tasks.filter((task) => task.project === filters.project)
|
|
454
|
-
if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
|
|
455
|
-
if (filters.sort === 'updated')
|
|
456
|
-
tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
|
457
|
-
if (filters.limit) tasks = tasks.slice(0, filters.limit)
|
|
458
|
-
return tasks
|
|
442
|
+
return applyTaskFilters(tasks, filters)
|
|
459
443
|
}
|
|
460
444
|
|
|
461
445
|
async getTask(idOrRef: string): Promise<Task> {
|
|
@@ -507,22 +491,24 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
507
491
|
if (input.metadata !== undefined) {
|
|
508
492
|
unsupportedOperation('Linear mode does not support metadata updates')
|
|
509
493
|
}
|
|
510
|
-
const
|
|
494
|
+
const issueId = this.issueIdFor(task)
|
|
495
|
+
const result = await this.client.updateIssue(issueId, updateInput)
|
|
511
496
|
if (!result.success) {
|
|
512
497
|
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue update failed')
|
|
513
498
|
}
|
|
514
|
-
return this.hydrateIssue(
|
|
499
|
+
return this.hydrateIssue(issueId)
|
|
515
500
|
}
|
|
516
501
|
|
|
517
502
|
async moveTask(idOrRef: string, column: string): Promise<Task> {
|
|
518
503
|
await this.sync()
|
|
519
504
|
const task = await this.resolveTask(idOrRef)
|
|
520
505
|
const state = await this.resolveState(column)
|
|
521
|
-
const
|
|
506
|
+
const issueId = this.issueIdFor(task)
|
|
507
|
+
const result = await this.client.updateIssue(issueId, { stateId: state.id })
|
|
522
508
|
if (!result.success) {
|
|
523
509
|
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue move failed')
|
|
524
510
|
}
|
|
525
|
-
return this.hydrateIssue(
|
|
511
|
+
return this.hydrateIssue(issueId)
|
|
526
512
|
}
|
|
527
513
|
|
|
528
514
|
async deleteTask(_idOrRef: string): Promise<Task> {
|
|
@@ -532,7 +518,7 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
532
518
|
async listComments(idOrRef: string): Promise<TaskComment[]> {
|
|
533
519
|
await this.sync()
|
|
534
520
|
const task = await this.resolveTask(idOrRef)
|
|
535
|
-
const comments = await this.client.listComments(
|
|
521
|
+
const comments = await this.client.listComments(this.issueIdFor(task))
|
|
536
522
|
return comments.map((comment) => this.toTaskComment(task, comment))
|
|
537
523
|
}
|
|
538
524
|
|
|
@@ -546,11 +532,12 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
546
532
|
async comment(idOrRef: string, body: string): Promise<TaskComment> {
|
|
547
533
|
await this.sync()
|
|
548
534
|
const task = await this.resolveTask(idOrRef)
|
|
549
|
-
const
|
|
535
|
+
const issueId = this.issueIdFor(task)
|
|
536
|
+
const result = await this.client.commentCreate(issueId, body)
|
|
550
537
|
if (!result.success || !result.comment) {
|
|
551
538
|
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear comment creation failed')
|
|
552
539
|
}
|
|
553
|
-
await this.cache.adjustIssueCommentCount(
|
|
540
|
+
await this.cache.adjustIssueCommentCount(issueId, 1)
|
|
554
541
|
return this.toTaskComment(task, result.comment)
|
|
555
542
|
}
|
|
556
543
|
|
|
@@ -610,7 +597,8 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
610
597
|
// SQLite calls it directly via the default handleWebhook above.
|
|
611
598
|
protected async handleWebhookCore(payload: WebhookRequest): Promise<WebhookResult> {
|
|
612
599
|
if (!process.env['LINEAR_WEBHOOK_SECRET']) {
|
|
613
|
-
|
|
600
|
+
warnOnce(
|
|
601
|
+
'linear-webhook-open-dev-mode',
|
|
614
602
|
'[linear] LINEAR_WEBHOOK_SECRET is not set — accepting webhook without signature verification (open dev mode)',
|
|
615
603
|
)
|
|
616
604
|
}
|