@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.
Files changed (45) hide show
  1. package/package.json +11 -3
  2. package/src/__tests__/index.test.ts +27 -1
  3. package/src/__tests__/jira-cache.test.ts +68 -0
  4. package/src/__tests__/jira-provider-read.test.ts +130 -0
  5. package/src/__tests__/jira-wiring.test.ts +41 -0
  6. package/src/__tests__/linear-provider-comment.test.ts +81 -0
  7. package/src/__tests__/linear-provider-sync.test.ts +107 -0
  8. package/src/__tests__/linear-state-resolution.test.ts +60 -0
  9. package/src/__tests__/local-provider-core.test.ts +245 -0
  10. package/src/__tests__/postgres-jira-provider.test.ts +55 -0
  11. package/src/__tests__/postgres-linear-provider.test.ts +90 -0
  12. package/src/__tests__/postgres-local-provider.test.ts +26 -0
  13. package/src/__tests__/provider-sync-core.test.ts +150 -0
  14. package/src/__tests__/provider-team-info.test.ts +37 -0
  15. package/src/__tests__/ui-board-slice.test.ts +14 -0
  16. package/src/__tests__/use-cases.test.ts +21 -42
  17. package/src/api.ts +78 -132
  18. package/src/column-roles.ts +3 -2
  19. package/src/index.ts +135 -92
  20. package/src/mcp/core.ts +7 -8
  21. package/src/provider-runtime.ts +12 -56
  22. package/src/providers/cache-task-mappers.ts +123 -0
  23. package/src/providers/factory.ts +103 -0
  24. package/src/providers/index.ts +4 -25
  25. package/src/providers/jira-cache.ts +41 -99
  26. package/src/providers/jira-core.ts +81 -81
  27. package/src/providers/linear-cache.ts +42 -69
  28. package/src/providers/linear-core.ts +44 -56
  29. package/src/providers/local-core.ts +226 -0
  30. package/src/providers/local.ts +6 -182
  31. package/src/providers/postgres-batch.ts +10 -0
  32. package/src/providers/postgres-jira-cache.ts +255 -213
  33. package/src/providers/postgres-jira.ts +2 -20
  34. package/src/providers/postgres-linear-cache.ts +279 -183
  35. package/src/providers/postgres-linear.ts +2 -20
  36. package/src/providers/postgres-local.ts +22 -47
  37. package/src/providers/sqlite-local-store.ts +121 -0
  38. package/src/providers/sync-core.ts +91 -0
  39. package/src/providers/team-info.ts +24 -0
  40. package/src/providers/warn-once.ts +15 -0
  41. package/src/use-cases.ts +7 -131
  42. package/src/webhook-events.ts +29 -1
  43. package/ui/dist/assets/index-DcZH7fI3.js +40 -0
  44. package/ui/dist/index.html +1 -1
  45. package/ui/dist/assets/index-Cigv8a9S.js +0 -40
@@ -0,0 +1,245 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { ErrorCode, KanbanError } from '../errors'
4
+ import { LOCAL_CAPABILITIES } from '../providers/capabilities'
5
+ import {
6
+ LocalProviderCore,
7
+ type LocalStorePort,
8
+ type LocalTaskRecord,
9
+ } from '../providers/local-core'
10
+ import type { BoardConfig, BoardMetrics, Column, TaskComment } from '../types'
11
+ import type { CreateTaskInput, TaskListFilters, UpdateTaskInput } from '../providers/types'
12
+
13
+ const column: Column = {
14
+ id: 'c_1',
15
+ name: 'backlog',
16
+ position: 0,
17
+ color: null,
18
+ created_at: '2026-01-01T00:00:00.000Z',
19
+ updated_at: '2026-01-01T00:00:00.000Z',
20
+ }
21
+
22
+ const metrics: BoardMetrics = {
23
+ tasksByColumn: [],
24
+ tasksByPriority: [],
25
+ totalTasks: 0,
26
+ completedTasks: 0,
27
+ avgCompletionHours: null,
28
+ recentActivity: [],
29
+ tasksCreatedThisWeek: 0,
30
+ inProgressCount: 0,
31
+ completionPercent: 0,
32
+ assignees: [],
33
+ projects: [],
34
+ }
35
+
36
+ const config: BoardConfig = {
37
+ members: [],
38
+ projects: [],
39
+ provider: 'local',
40
+ discoveredAssignees: [],
41
+ discoveredProjects: [],
42
+ }
43
+
44
+ function localTask(overrides: Partial<LocalTaskRecord> = {}): LocalTaskRecord {
45
+ return {
46
+ id: 't_1',
47
+ title: 'Local task',
48
+ description: '',
49
+ column_id: column.id,
50
+ column_name: column.name,
51
+ position: 0,
52
+ priority: 'medium',
53
+ assignee: 'amy',
54
+ assignees: [],
55
+ labels: [],
56
+ comment_count: 0,
57
+ project: '',
58
+ metadata: '{}',
59
+ revision: 0,
60
+ created_at: '2026-01-01T00:00:00.000Z',
61
+ updated_at: '2026-01-01T00:00:00.000Z',
62
+ version: null,
63
+ source_updated_at: null,
64
+ ...overrides,
65
+ }
66
+ }
67
+
68
+ class FakeLocalStore implements LocalStorePort {
69
+ readonly capabilities = LOCAL_CAPABILITIES
70
+ task = localTask()
71
+ getTaskCalls = 0
72
+ getTaskVersionCalls = 0
73
+ updates: Array<Omit<UpdateTaskInput, 'expectedVersion'>> = []
74
+
75
+ getBoard() {
76
+ return { columns: [{ ...column, tasks: [this.task] }] }
77
+ }
78
+
79
+ listColumns() {
80
+ return [column]
81
+ }
82
+
83
+ listTasks(_filters: TaskListFilters = {}) {
84
+ return [this.task]
85
+ }
86
+
87
+ getTask(_idOrRef: string) {
88
+ this.getTaskCalls += 1
89
+ return this.task
90
+ }
91
+
92
+ getTaskVersion(_idOrRef: string) {
93
+ this.getTaskVersionCalls += 1
94
+ return String(this.task.revision ?? 0)
95
+ }
96
+
97
+ createTask(input: CreateTaskInput) {
98
+ this.task = localTask({ title: input.title, revision: 0 })
99
+ return this.task
100
+ }
101
+
102
+ updateTask(_idOrRef: string, input: Omit<UpdateTaskInput, 'expectedVersion'>) {
103
+ this.updates.push(input)
104
+ this.task = localTask({
105
+ ...this.task,
106
+ ...input,
107
+ revision: (this.task.revision ?? 0) + 1,
108
+ })
109
+ return this.task
110
+ }
111
+
112
+ moveTask(_idOrRef: string, _column: string) {
113
+ return this.task
114
+ }
115
+
116
+ deleteTask(_idOrRef: string) {
117
+ return this.task
118
+ }
119
+
120
+ listComments(_idOrRef: string): TaskComment[] {
121
+ return []
122
+ }
123
+
124
+ getComment(_idOrRef: string, commentId: string): TaskComment {
125
+ return {
126
+ id: commentId,
127
+ task_id: this.task.id,
128
+ body: '',
129
+ author: null,
130
+ created_at: '2026-01-01T00:00:00.000Z',
131
+ updated_at: '2026-01-01T00:00:00.000Z',
132
+ }
133
+ }
134
+
135
+ comment(_idOrRef: string, body: string): TaskComment {
136
+ return {
137
+ id: 'cm_1',
138
+ task_id: this.task.id,
139
+ body,
140
+ author: null,
141
+ created_at: '2026-01-01T00:00:00.000Z',
142
+ updated_at: '2026-01-01T00:00:00.000Z',
143
+ }
144
+ }
145
+
146
+ updateComment(_idOrRef: string, commentId: string, body: string): TaskComment {
147
+ return {
148
+ id: commentId,
149
+ task_id: this.task.id,
150
+ body,
151
+ author: null,
152
+ created_at: '2026-01-01T00:00:00.000Z',
153
+ updated_at: '2026-01-02T00:00:00.000Z',
154
+ }
155
+ }
156
+
157
+ getActivity() {
158
+ return []
159
+ }
160
+
161
+ getMetrics() {
162
+ return metrics
163
+ }
164
+
165
+ getConfig() {
166
+ return config
167
+ }
168
+
169
+ patchConfig() {
170
+ return config
171
+ }
172
+
173
+ countComments() {
174
+ return 2
175
+ }
176
+
177
+ countCommentsByTask() {
178
+ return new Map([[this.task.id, 2]])
179
+ }
180
+ }
181
+
182
+ describe('LocalProviderCore', () => {
183
+ test('normalizes local task identity, comment count, labels, and version fields', async () => {
184
+ const store = new FakeLocalStore()
185
+ store.task = {
186
+ ...localTask({ revision: 3 }),
187
+ assignees: undefined,
188
+ labels: undefined,
189
+ comment_count: undefined,
190
+ version: undefined,
191
+ source_updated_at: undefined,
192
+ } as unknown as LocalTaskRecord
193
+
194
+ const provider = new LocalProviderCore(store)
195
+ const task = await provider.getTask(store.task.id)
196
+
197
+ expect(task.providerId).toBe('t_1')
198
+ expect(task.externalRef).toBe('t_1')
199
+ expect(task.url).toBeNull()
200
+ expect(task.assignees).toEqual(['amy'])
201
+ expect(task.labels).toEqual([])
202
+ expect(task.comment_count).toBe(2)
203
+ expect(task.version).toBe('3')
204
+ expect(task.source_updated_at).toBeNull()
205
+ })
206
+
207
+ test('checks expectedVersion before store update and strips it from the store input', async () => {
208
+ const store = new FakeLocalStore()
209
+ store.task = localTask({ revision: 4 })
210
+ const provider = new LocalProviderCore(store)
211
+
212
+ const updated = await provider.updateTask(store.task.id, {
213
+ title: 'Updated title',
214
+ expectedVersion: '4',
215
+ })
216
+
217
+ expect(updated.title).toBe('Updated title')
218
+ expect(store.updates).toEqual([{ title: 'Updated title' }])
219
+ expect(Object.hasOwn(store.updates[0]!, 'expectedVersion')).toBe(false)
220
+ expect(store.getTaskVersionCalls).toBe(1)
221
+ expect(store.getTaskCalls).toBe(0)
222
+ })
223
+
224
+ test('rejects stale expectedVersion without calling the store update', async () => {
225
+ const store = new FakeLocalStore()
226
+ store.task = localTask({ revision: 4 })
227
+ const provider = new LocalProviderCore(store)
228
+
229
+ let err: unknown
230
+ try {
231
+ await provider.updateTask(store.task.id, {
232
+ title: 'Stale update',
233
+ expectedVersion: '3',
234
+ })
235
+ } catch (caught) {
236
+ err = caught
237
+ }
238
+
239
+ expect(err).toBeInstanceOf(KanbanError)
240
+ expect((err as KanbanError).code).toBe(ErrorCode.CONFLICT)
241
+ expect(store.updates).toEqual([])
242
+ expect(store.getTaskVersionCalls).toBe(1)
243
+ expect(store.getTaskCalls).toBe(0)
244
+ })
245
+ })
@@ -4,6 +4,7 @@ import postgres from 'postgres'
4
4
  import { run } from '../index'
5
5
  import type { Task } from '../types'
6
6
  import { PostgresJiraProvider } from '../providers/postgres-jira'
7
+ import { PostgresJiraCache } from '../providers/postgres-jira-cache'
7
8
 
8
9
  const databaseUrl = process.env['KANBAN_PG_TEST_URL'] ?? process.env['DATABASE_URL']
9
10
  const pgTest = databaseUrl ? test : test.skip
@@ -469,6 +470,60 @@ describe('postgres jira provider', () => {
469
470
  },
470
471
  )
471
472
 
473
+ pgTest('rolls back the whole Jira priority batch when one row fails', async () => {
474
+ if (!sql) throw new Error('expected postgres test connection')
475
+ const cache = new PostgresJiraCache(sql)
476
+ await cache.ready
477
+ await cache.replacePriorities([{ id: '2', name: 'High' }], true)
478
+
479
+ await expect(
480
+ cache.replacePriorities(
481
+ [
482
+ { id: '2', name: 'Highest' },
483
+ { id: 'broken', name: null as unknown as string },
484
+ ],
485
+ true,
486
+ ),
487
+ ).rejects.toThrow()
488
+
489
+ const rows = await sql<Array<{ id: string; name: string }>>`
490
+ SELECT id, name FROM jira_priorities ORDER BY id
491
+ `
492
+ expect(rows.map((row) => ({ id: row.id, name: row.name }))).toEqual([{ id: '2', name: 'High' }])
493
+ })
494
+
495
+ // A sync batch can repeat an issue id (an issue updated mid-pagination shows
496
+ // up on two pages); the batched upsert must last-wins instead of erroring
497
+ // ("ON CONFLICT DO UPDATE command cannot affect row a second time").
498
+ pgTest('upsertIssues tolerates duplicate issue ids within one batch', async () => {
499
+ if (!sql) throw new Error('expected postgres test connection')
500
+ const cache = new PostgresJiraCache(sql)
501
+ await cache.ready
502
+
503
+ const issue = {
504
+ id: '9001',
505
+ key: 'ENG-201',
506
+ summary: 'First occurrence',
507
+ descriptionText: 'v1',
508
+ statusId: '10001',
509
+ projectKey: 'ENG',
510
+ createdAt: '2026-01-01T00:00:00.000Z',
511
+ updatedAt: '2026-01-02T00:00:00.000Z',
512
+ }
513
+ await cache.upsertIssues([
514
+ issue,
515
+ { ...issue, summary: 'Last occurrence wins', updatedAt: '2026-01-03T00:00:00.000Z' },
516
+ ])
517
+
518
+ const [row] = await sql<{ summary: string; updated_at: string }[]>`
519
+ SELECT summary, updated_at FROM jira_issues WHERE id = '9001'
520
+ `
521
+ expect(row).toEqual({
522
+ summary: 'Last occurrence wins',
523
+ updated_at: '2026-01-03T00:00:00.000Z',
524
+ })
525
+ })
526
+
472
527
  pgTest('concurrent catalog refreshes do not collide on a primary key', async () => {
473
528
  if (!sql) throw new Error('expected postgres test connection')
474
529
  globalThis.fetch = jiraFetchStub(standardRoutes()).fn
@@ -4,6 +4,7 @@ import postgres from 'postgres'
4
4
  import { run } from '../index'
5
5
  import type { Task, TaskComment } from '../types'
6
6
  import { PostgresLinearProvider } from '../providers/postgres-linear'
7
+ import { PostgresLinearCache } from '../providers/postgres-linear-cache'
7
8
 
8
9
  const databaseUrl = process.env['KANBAN_PG_TEST_URL'] ?? process.env['DATABASE_URL']
9
10
  const pgTest = databaseUrl ? test : test.skip
@@ -359,4 +360,93 @@ describe('postgres linear provider', () => {
359
360
  'label-owner',
360
361
  ])
361
362
  })
363
+
364
+ pgTest('rolls back description activity when Linear issue upsert fails', async () => {
365
+ if (!sql) throw new Error('expected postgres test connection')
366
+ const cache = new PostgresLinearCache(sql)
367
+ await cache.ready
368
+
369
+ const issue = {
370
+ id: 'lin-cache-1',
371
+ identifier: 'GB-101',
372
+ title: 'Atomic Linear issue',
373
+ description: 'old description',
374
+ priority: 2,
375
+ assigneeId: null,
376
+ assigneeName: null,
377
+ projectId: null,
378
+ projectName: null,
379
+ stateId: 'state-todo',
380
+ stateName: 'Todo',
381
+ statePosition: 0,
382
+ labels: ['atomic'],
383
+ commentCount: 0,
384
+ url: 'https://linear.app/issue/GB-101',
385
+ createdAt: '2026-01-01T00:00:00.000Z',
386
+ updatedAt: '2026-01-02T00:00:00.000Z',
387
+ }
388
+ await cache.upsertIssues([issue])
389
+
390
+ await expect(
391
+ cache.upsertIssues([
392
+ {
393
+ ...issue,
394
+ title: 'Should roll back',
395
+ description: 'new description',
396
+ stateId: null as unknown as string,
397
+ updatedAt: '2026-01-03T00:00:00.000Z',
398
+ },
399
+ ]),
400
+ ).rejects.toThrow()
401
+
402
+ const [row] = await sql<{ title: string; description: string }[]>`
403
+ SELECT title, description FROM linear_issues WHERE id = 'lin-cache-1'
404
+ `
405
+ expect(row).toEqual({ title: 'Atomic Linear issue', description: 'old description' })
406
+ const activity = await sql<{ history_id: string }[]>`
407
+ SELECT history_id FROM linear_activity WHERE issue_id = 'lin-cache-1'
408
+ `
409
+ expect(activity).toHaveLength(0)
410
+ })
411
+
412
+ // Linear's updatedAt-ordered pagination can return the same issue on two
413
+ // pages of one sync; the batched upsert must last-wins instead of erroring
414
+ // ("ON CONFLICT DO UPDATE command cannot affect row a second time").
415
+ pgTest('upsertIssues tolerates duplicate issue ids within one batch', async () => {
416
+ if (!sql) throw new Error('expected postgres test connection')
417
+ const cache = new PostgresLinearCache(sql)
418
+ await cache.ready
419
+
420
+ const issue = {
421
+ id: 'lin-dup-1',
422
+ identifier: 'GB-201',
423
+ title: 'First occurrence',
424
+ description: 'v1',
425
+ priority: 2,
426
+ assigneeId: null,
427
+ assigneeName: null,
428
+ projectId: null,
429
+ projectName: null,
430
+ stateId: 'state-todo',
431
+ stateName: 'Todo',
432
+ statePosition: 0,
433
+ labels: [],
434
+ commentCount: 0,
435
+ url: null,
436
+ createdAt: '2026-01-01T00:00:00.000Z',
437
+ updatedAt: '2026-01-02T00:00:00.000Z',
438
+ }
439
+ await cache.upsertIssues([
440
+ issue,
441
+ { ...issue, title: 'Last occurrence wins', updatedAt: '2026-01-03T00:00:00.000Z' },
442
+ ])
443
+
444
+ const [row] = await sql<{ title: string; updated_at: string }[]>`
445
+ SELECT title, updated_at FROM linear_issues WHERE id = 'lin-dup-1'
446
+ `
447
+ expect(row).toEqual({
448
+ title: 'Last occurrence wins',
449
+ updated_at: '2026-01-03T00:00:00.000Z',
450
+ })
451
+ })
362
452
  })
@@ -206,6 +206,32 @@ describe('postgres local provider', () => {
206
206
  }
207
207
  })
208
208
 
209
+ // Exercises the real Postgres getTaskVersion path behind the shared core's
210
+ // optimistic-version check (the core conflict tests run against a fake store).
211
+ pgTest('update with stale expectedVersion throws CONFLICT through Postgres storage', async () => {
212
+ const runtime = await openKanbanRuntime()
213
+ try {
214
+ const created = await runtime.provider.createTask({ title: 'Versioned task' })
215
+ await runtime.provider.updateTask(created.id, { title: 'bumped' })
216
+
217
+ await expect(
218
+ runtime.provider.updateTask(created.id, {
219
+ title: 'stale write',
220
+ expectedVersion: created.version ?? undefined,
221
+ }),
222
+ ).rejects.toMatchObject({ code: 'CONFLICT' })
223
+
224
+ const matched = await runtime.provider.updateTask(created.id, {
225
+ title: 'fresh write',
226
+ expectedVersion: '1',
227
+ })
228
+ expect(matched.title).toBe('fresh write')
229
+ expect(matched.version).toBe('2')
230
+ } finally {
231
+ await runtime.close()
232
+ }
233
+ })
234
+
209
235
  pgTest('migrates a pre-existing tasks table missing project/labels/revision', async () => {
210
236
  // Simulate an older Postgres-local database created before project, labels,
211
237
  // and revision columns existed. CREATE TABLE IF NOT EXISTS would not add
@@ -0,0 +1,150 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { Task } from '../types'
3
+ import {
4
+ applyTaskFilters,
5
+ mapWithConcurrency,
6
+ maxSyncTimestamp,
7
+ parseSyncTimestamp,
8
+ SyncGate,
9
+ syncStatusFromMeta,
10
+ } from '../providers/sync-core'
11
+
12
+ function task(overrides: Partial<Task>): Task {
13
+ return {
14
+ id: overrides.id ?? 't_1',
15
+ providerId: overrides.providerId ?? overrides.id ?? 't_1',
16
+ title: overrides.title ?? 'Task',
17
+ description: '',
18
+ column_id: overrides.column_id ?? 'todo',
19
+ position: overrides.position ?? 0,
20
+ priority: overrides.priority ?? 'low',
21
+ assignee: overrides.assignee ?? '',
22
+ assignees: overrides.assignees ?? [],
23
+ labels: overrides.labels ?? [],
24
+ comment_count: overrides.comment_count ?? 0,
25
+ project: overrides.project ?? '',
26
+ metadata: '{}',
27
+ created_at: overrides.created_at ?? '2026-01-01T00:00:00Z',
28
+ updated_at: overrides.updated_at ?? '2026-01-01T00:00:00Z',
29
+ version: overrides.version ?? overrides.updated_at ?? '2026-01-01T00:00:00Z',
30
+ source_updated_at:
31
+ overrides.source_updated_at ?? overrides.updated_at ?? '2026-01-01T00:00:00Z',
32
+ url: overrides.url,
33
+ externalRef: overrides.externalRef,
34
+ }
35
+ }
36
+
37
+ describe('provider sync core helpers', () => {
38
+ test('SyncGate shares throttle and background-managed suppression rules', () => {
39
+ const gate = new SyncGate(30_000)
40
+ const syncedAt = '2026-01-01T00:00:00.000Z'
41
+ const justAfterSync = Date.parse('2026-01-01T00:00:10.000Z')
42
+ const afterThrottle = Date.parse('2026-01-01T00:00:31.000Z')
43
+
44
+ expect(
45
+ gate.shouldSkip({ force: false, viaWarmer: false, lastSyncAt: syncedAt, now: justAfterSync }),
46
+ ).toBe(true)
47
+ expect(
48
+ gate.shouldSkip({ force: true, viaWarmer: false, lastSyncAt: syncedAt, now: justAfterSync }),
49
+ ).toBe(false)
50
+ expect(
51
+ gate.shouldSkip({
52
+ force: false,
53
+ viaWarmer: false,
54
+ lastSyncAt: syncedAt,
55
+ now: afterThrottle,
56
+ }),
57
+ ).toBe(false)
58
+
59
+ gate.setBackgroundManaged(true)
60
+ expect(
61
+ gate.shouldSkip({
62
+ force: false,
63
+ viaWarmer: false,
64
+ lastSyncAt: syncedAt,
65
+ now: afterThrottle,
66
+ }),
67
+ ).toBe(true)
68
+ expect(
69
+ gate.shouldSkip({
70
+ force: false,
71
+ viaWarmer: true,
72
+ lastSyncAt: syncedAt,
73
+ now: afterThrottle,
74
+ }),
75
+ ).toBe(false)
76
+ })
77
+
78
+ test('applyTaskFilters centralizes priority, assignee, project, sort, and limit behavior', () => {
79
+ const tasks = [
80
+ task({
81
+ id: 'older',
82
+ title: 'Zulu',
83
+ priority: 'high',
84
+ assignee: 'Ada',
85
+ project: 'Core',
86
+ updated_at: '2026-01-01T00:00:00Z',
87
+ }),
88
+ task({
89
+ id: 'newer',
90
+ title: 'Alpha',
91
+ priority: 'high',
92
+ assignee: 'Ada',
93
+ project: 'Core',
94
+ updated_at: '2026-01-02T00:00:00Z',
95
+ }),
96
+ task({
97
+ id: 'other',
98
+ title: 'Beta',
99
+ priority: 'low',
100
+ assignee: 'Grace',
101
+ project: 'Ops',
102
+ updated_at: '2026-01-03T00:00:00Z',
103
+ }),
104
+ ]
105
+
106
+ expect(
107
+ applyTaskFilters(tasks, {
108
+ priority: 'high',
109
+ assignee: 'Ada',
110
+ project: 'Core',
111
+ sort: 'updated',
112
+ limit: 1,
113
+ }).map((item) => item.id),
114
+ ).toEqual(['newer'])
115
+ expect(applyTaskFilters(tasks, { sort: 'title' }).map((item) => item.id)).toEqual([
116
+ 'newer',
117
+ 'other',
118
+ 'older',
119
+ ])
120
+ })
121
+
122
+ test('mapWithConcurrency preserves order while bounding in-flight work', async () => {
123
+ let inFlight = 0
124
+ let maxInFlight = 0
125
+ const results = await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (item) => {
126
+ inFlight += 1
127
+ maxInFlight = Math.max(maxInFlight, inFlight)
128
+ await new Promise((resolve) => setTimeout(resolve, 2))
129
+ inFlight -= 1
130
+ return item * 2
131
+ })
132
+
133
+ expect(results).toEqual([2, 4, 6, 8, 10])
134
+ expect(maxInFlight).toBe(2)
135
+ })
136
+
137
+ test('timestamp helpers and sync status projection keep provider metadata behavior shared', () => {
138
+ expect(parseSyncTimestamp('not-a-date')).toBe(0)
139
+ expect(maxSyncTimestamp('2026-01-02T00:00:00Z', '2026-01-01T00:00:00Z')).toBe(
140
+ '2026-01-02T00:00:00Z',
141
+ )
142
+ expect(
143
+ syncStatusFromMeta({
144
+ lastSyncAt: 'sync',
145
+ lastFullSyncAt: 'full',
146
+ lastWebhookAt: null,
147
+ }),
148
+ ).toEqual({ lastSyncAt: 'sync', lastFullSyncAt: 'full', lastWebhookAt: null })
149
+ })
150
+ })
@@ -0,0 +1,37 @@
1
+ import { Database } from 'bun:sqlite'
2
+ import { describe, expect, test } from 'bun:test'
3
+
4
+ import { initLinearCacheSchema, loadSyncMeta } from '../providers/linear-cache'
5
+ import { parseProviderTeamInfo } from '../providers/team-info'
6
+
7
+ describe('parseProviderTeamInfo', () => {
8
+ test('accepts the persisted provider team shape', () => {
9
+ expect(
10
+ parseProviderTeamInfo(JSON.stringify({ id: 'team-1', key: 'ENG', name: 'Engineering' })),
11
+ ).toEqual({
12
+ id: 'team-1',
13
+ key: 'ENG',
14
+ name: 'Engineering',
15
+ })
16
+ })
17
+
18
+ test('rejects corrupt or incomplete metadata', () => {
19
+ expect(parseProviderTeamInfo(null)).toBeNull()
20
+ expect(parseProviderTeamInfo('not json')).toBeNull()
21
+ expect(parseProviderTeamInfo(JSON.stringify({ id: 'team-1', key: 'ENG' }))).toBeNull()
22
+ expect(
23
+ parseProviderTeamInfo(JSON.stringify({ id: 'team-1', key: 42, name: 'Engineering' })),
24
+ ).toBeNull()
25
+ })
26
+
27
+ test('Linear sync metadata degrades corrupt team JSON to null', () => {
28
+ const db = new Database(':memory:')
29
+ initLinearCacheSchema(db)
30
+ db.query('INSERT INTO linear_sync_meta (key, value) VALUES ($key, $value)').run({
31
+ $key: 'team',
32
+ $value: 'not json',
33
+ })
34
+
35
+ expect(loadSyncMeta(db).team).toBeNull()
36
+ })
37
+ })
@@ -0,0 +1,14 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { defaultCapabilities } from '../../ui/src/store/capabilities'
4
+
5
+ // The type annotation guarantees key completeness; this pins the values so a
6
+ // default can't quietly flip back to true and reintroduce the pre-bootstrap
7
+ // flash of provider-gated actions.
8
+ describe('UI capability defaults', () => {
9
+ test('every provider capability starts closed until bootstrap loads', () => {
10
+ for (const [capability, enabled] of Object.entries(defaultCapabilities)) {
11
+ expect({ capability, enabled }).toEqual({ capability, enabled: false })
12
+ }
13
+ })
14
+ })