@andypai/agent-kanban 0.6.4 → 0.7.0
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 +18 -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
|
@@ -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
|
+
})
|