@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
|
@@ -3,7 +3,7 @@ import { Database } from 'bun:sqlite'
|
|
|
3
3
|
import { initSchema, seedDefaultColumns } from '../db'
|
|
4
4
|
import { createProvider } from '../providers/index'
|
|
5
5
|
import type { KanbanProvider } from '../providers/types'
|
|
6
|
-
import
|
|
6
|
+
import { normalizeCreateTaskInput } from '../use-cases'
|
|
7
7
|
|
|
8
8
|
let db: Database
|
|
9
9
|
let provider: KanbanProvider
|
|
@@ -17,61 +17,40 @@ beforeEach(() => {
|
|
|
17
17
|
})
|
|
18
18
|
|
|
19
19
|
describe('use-cases label normalization', () => {
|
|
20
|
-
// The use-case
|
|
20
|
+
// The use-case seam owns label normalization so every transport feeds raw
|
|
21
21
|
// labels in its own shape and gets the same result.
|
|
22
22
|
test('normalizes CLI-style nested flag arrays', async () => {
|
|
23
|
-
const task = await
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const task = await provider.createTask(
|
|
24
|
+
normalizeCreateTaskInput({
|
|
25
|
+
title: 'cli',
|
|
26
|
+
labels: [['bug', 'ui'], undefined],
|
|
27
|
+
}),
|
|
28
|
+
)
|
|
27
29
|
expect(task.labels).toEqual(['bug', 'ui'])
|
|
28
30
|
})
|
|
29
31
|
|
|
30
32
|
test('normalizes HTTP-style string arrays', async () => {
|
|
31
|
-
const task = await
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const task = await provider.createTask(
|
|
34
|
+
normalizeCreateTaskInput({
|
|
35
|
+
title: 'http',
|
|
36
|
+
labels: ['bug', 'ui'],
|
|
37
|
+
}),
|
|
38
|
+
)
|
|
35
39
|
expect(task.labels).toEqual(['bug', 'ui'])
|
|
36
40
|
})
|
|
37
41
|
|
|
38
42
|
test('normalizes a comma-separated string and de-dupes', async () => {
|
|
39
|
-
const task = await
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
const task = await provider.createTask(
|
|
44
|
+
normalizeCreateTaskInput({
|
|
45
|
+
title: 'csv',
|
|
46
|
+
labels: 'bug, ui, bug',
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
43
49
|
expect(task.labels).toEqual(['bug', 'ui'])
|
|
44
50
|
})
|
|
45
51
|
|
|
46
52
|
test('treats omitted labels as none', async () => {
|
|
47
|
-
const task = await
|
|
53
|
+
const task = await provider.createTask(normalizeCreateTaskInput({ title: 'none' }))
|
|
48
54
|
expect(task.labels).toEqual([])
|
|
49
55
|
})
|
|
50
56
|
})
|
|
51
|
-
|
|
52
|
-
describe('use-cases provider forwarding', () => {
|
|
53
|
-
test('createTask then getTask/listTasks round-trip', async () => {
|
|
54
|
-
const created = await useCases.createTask(provider, { title: 'roundtrip', column: 'backlog' })
|
|
55
|
-
const fetched = await useCases.getTask(provider, created.id)
|
|
56
|
-
expect(fetched.id).toBe(created.id)
|
|
57
|
-
const all = await useCases.listTasks(provider)
|
|
58
|
-
expect(all.map((t) => t.id)).toContain(created.id)
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
test('moveTask forwards to the target column and returns the task', async () => {
|
|
62
|
-
const created = await useCases.createTask(provider, { title: 'mover', column: 'backlog' })
|
|
63
|
-
const columns = await useCases.listColumns(provider)
|
|
64
|
-
const target = columns.find((c) => c.name.toLowerCase() === 'in-progress')!
|
|
65
|
-
const moved = await useCases.moveTask(provider, created.id, target.name)
|
|
66
|
-
expect(moved.column_id).toBe(target.id)
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
test('addComment / listComments / updateComment forward correctly', async () => {
|
|
70
|
-
const task = await useCases.createTask(provider, { title: 'commented' })
|
|
71
|
-
const comment = await useCases.addComment(provider, task.id, 'first')
|
|
72
|
-
const listed = await useCases.listComments(provider, task.id)
|
|
73
|
-
expect(listed.map((c) => c.body)).toContain('first')
|
|
74
|
-
const updated = await useCases.updateComment(provider, task.id, comment.id, 'edited')
|
|
75
|
-
expect(updated.body).toBe('edited')
|
|
76
|
-
})
|
|
77
|
-
})
|
package/src/api.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { KanbanError, ErrorCode } from './errors'
|
|
|
2
2
|
import type { BoardConfig, CliOutput, Task } from './types'
|
|
3
3
|
import type { CreateTaskInput, UpdateTaskInput, KanbanProvider } from './providers/types'
|
|
4
4
|
import { parsePositiveInt } from './transport-input'
|
|
5
|
-
import
|
|
5
|
+
import { success } from './output'
|
|
6
|
+
import { normalizeCreateTaskInput } from './use-cases'
|
|
6
7
|
|
|
7
8
|
export type WsEvent =
|
|
8
9
|
| { type: 'task:upsert'; task: Task; columnId: string }
|
|
@@ -75,6 +76,10 @@ async function wrapHandler(fn: () => Promise<CliOutput> | CliOutput): Promise<Re
|
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
async function okHandler<T>(fn: () => Promise<T> | T): Promise<Response> {
|
|
80
|
+
return wrapHandler(async () => success(await fn()))
|
|
81
|
+
}
|
|
82
|
+
|
|
78
83
|
export interface ApiResult {
|
|
79
84
|
response: Response
|
|
80
85
|
mutated: boolean
|
|
@@ -85,95 +90,76 @@ function upsertEvent(task: Task): WsEvent {
|
|
|
85
90
|
return { type: 'task:upsert', task, columnId: task.column_id }
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
async function readResult<T>(fn: () => Promise<T> | T): Promise<ApiResult> {
|
|
94
|
+
return { response: await okHandler(fn), mutated: false }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function mutationResult<T>(
|
|
98
|
+
fn: () => Promise<T> | T,
|
|
99
|
+
eventFor?: (data: T) => WsEvent | undefined,
|
|
100
|
+
): Promise<ApiResult> {
|
|
101
|
+
let data: T | undefined
|
|
102
|
+
const response = await okHandler(async () => {
|
|
103
|
+
data = await fn()
|
|
104
|
+
return data
|
|
105
|
+
})
|
|
106
|
+
return {
|
|
107
|
+
response,
|
|
108
|
+
mutated: response.ok,
|
|
109
|
+
event: response.ok && data !== undefined && eventFor ? eventFor(data) : undefined,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
88
113
|
export async function handleRequest(provider: KanbanProvider, req: Request): Promise<ApiResult> {
|
|
89
114
|
const url = new URL(req.url)
|
|
90
115
|
const path = url.pathname
|
|
91
116
|
const method = req.method
|
|
92
117
|
|
|
93
118
|
if (path === '/api/bootstrap' && method === 'GET') {
|
|
94
|
-
return
|
|
95
|
-
response: await wrapHandler(async () => ({
|
|
96
|
-
ok: true,
|
|
97
|
-
data: await useCases.getBootstrap(provider),
|
|
98
|
-
})),
|
|
99
|
-
mutated: false,
|
|
100
|
-
}
|
|
119
|
+
return readResult(() => provider.getBootstrap())
|
|
101
120
|
}
|
|
102
121
|
|
|
103
122
|
if (path === '/api/provider' && method === 'GET') {
|
|
104
|
-
return
|
|
105
|
-
response: await wrapHandler(async () => ({
|
|
106
|
-
ok: true,
|
|
107
|
-
data: await useCases.getContext(provider),
|
|
108
|
-
})),
|
|
109
|
-
mutated: false,
|
|
110
|
-
}
|
|
123
|
+
return readResult(() => provider.getContext())
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
if (path === '/api/board' && method === 'GET') {
|
|
114
|
-
return
|
|
115
|
-
response: await wrapHandler(async () => ({
|
|
116
|
-
ok: true,
|
|
117
|
-
data: await useCases.getBoard(provider),
|
|
118
|
-
})),
|
|
119
|
-
mutated: false,
|
|
120
|
-
}
|
|
127
|
+
return readResult(() => provider.getBoard())
|
|
121
128
|
}
|
|
122
129
|
|
|
123
130
|
if (path === '/api/columns' && method === 'GET') {
|
|
124
|
-
return
|
|
125
|
-
response: await wrapHandler(async () => ({
|
|
126
|
-
ok: true,
|
|
127
|
-
data: await useCases.listColumns(provider),
|
|
128
|
-
})),
|
|
129
|
-
mutated: false,
|
|
130
|
-
}
|
|
131
|
+
return readResult(() => provider.listColumns())
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
if (path === '/api/tasks' && method === 'GET') {
|
|
134
|
-
return {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
ok: true,
|
|
144
|
-
data: await useCases.listTasks(provider, {
|
|
145
|
-
column,
|
|
146
|
-
priority,
|
|
147
|
-
assignee,
|
|
148
|
-
project,
|
|
149
|
-
sort,
|
|
150
|
-
limit,
|
|
151
|
-
}),
|
|
152
|
-
}
|
|
153
|
-
}),
|
|
154
|
-
mutated: false,
|
|
155
|
-
}
|
|
135
|
+
return readResult(() => {
|
|
136
|
+
const column = url.searchParams.get('column') ?? undefined
|
|
137
|
+
const priority = url.searchParams.get('priority') ?? undefined
|
|
138
|
+
const assignee = url.searchParams.get('assignee') ?? undefined
|
|
139
|
+
const project = url.searchParams.get('project') ?? undefined
|
|
140
|
+
const sort = url.searchParams.get('sort') ?? undefined
|
|
141
|
+
const limit = parsePositiveInt(url.searchParams.get('limit'))
|
|
142
|
+
return provider.listTasks({ column, priority, assignee, project, sort, limit })
|
|
143
|
+
})
|
|
156
144
|
}
|
|
157
145
|
|
|
158
146
|
if (path === '/api/tasks' && method === 'POST') {
|
|
159
|
-
|
|
160
|
-
const response = await wrapHandler(async () => {
|
|
147
|
+
return mutationResult(async () => {
|
|
161
148
|
const body = await parseJsonBody<Partial<CreateTaskInput>>(req)
|
|
162
149
|
requireArgument(body.title, 'title')
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return { response, mutated: response.ok, event }
|
|
150
|
+
return provider.createTask(
|
|
151
|
+
normalizeCreateTaskInput({
|
|
152
|
+
title: body.title!,
|
|
153
|
+
description: body.description,
|
|
154
|
+
column: body.column,
|
|
155
|
+
priority: body.priority,
|
|
156
|
+
assignee: body.assignee,
|
|
157
|
+
project: body.project,
|
|
158
|
+
labels: body.labels,
|
|
159
|
+
metadata: body.metadata,
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
}, upsertEvent)
|
|
177
163
|
}
|
|
178
164
|
|
|
179
165
|
const taskMatch = path.match(/^\/api\/tasks\/([^/]+)$/)
|
|
@@ -181,70 +167,47 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
181
167
|
const id = decodeURIComponent(taskMatch[1]!)
|
|
182
168
|
|
|
183
169
|
if (method === 'GET') {
|
|
184
|
-
return
|
|
185
|
-
response: await wrapHandler(async () => ({
|
|
186
|
-
ok: true,
|
|
187
|
-
data: await useCases.getTask(provider, id),
|
|
188
|
-
})),
|
|
189
|
-
mutated: false,
|
|
190
|
-
}
|
|
170
|
+
return readResult(() => provider.getTask(id))
|
|
191
171
|
}
|
|
192
172
|
|
|
193
173
|
if (method === 'PATCH') {
|
|
194
|
-
|
|
195
|
-
const response = await wrapHandler(async () => {
|
|
174
|
+
return mutationResult(async () => {
|
|
196
175
|
const body = await parseJsonBody<UpdateTaskInput>(req)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
})
|
|
200
|
-
const event = response.ok && updated ? upsertEvent(updated) : undefined
|
|
201
|
-
return { response, mutated: response.ok, event }
|
|
176
|
+
return provider.updateTask(id, body)
|
|
177
|
+
}, upsertEvent)
|
|
202
178
|
}
|
|
203
179
|
|
|
204
180
|
if (method === 'DELETE') {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const event: WsEvent | undefined = response.ok ? { type: 'task:delete', id } : undefined
|
|
210
|
-
return { response, mutated: response.ok, event }
|
|
181
|
+
return mutationResult(
|
|
182
|
+
() => provider.deleteTask(id),
|
|
183
|
+
() => ({ type: 'task:delete', id }),
|
|
184
|
+
)
|
|
211
185
|
}
|
|
212
186
|
}
|
|
213
187
|
|
|
214
188
|
const moveMatch = path.match(/^\/api\/tasks\/([^/]+)\/move$/)
|
|
215
189
|
if (moveMatch && method === 'PATCH') {
|
|
216
190
|
const id = decodeURIComponent(moveMatch[1]!)
|
|
217
|
-
|
|
218
|
-
const response = await wrapHandler(async () => {
|
|
191
|
+
return mutationResult(async () => {
|
|
219
192
|
const body = await parseJsonBody<MoveTaskBody>(req)
|
|
220
193
|
requireArgument(body.column, 'column')
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
})
|
|
224
|
-
const event = response.ok && moved ? upsertEvent(moved) : undefined
|
|
225
|
-
return { response, mutated: response.ok, event }
|
|
194
|
+
return provider.moveTask(id, body.column!)
|
|
195
|
+
}, upsertEvent)
|
|
226
196
|
}
|
|
227
197
|
|
|
228
198
|
const commentsMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments$/)
|
|
229
199
|
if (commentsMatch) {
|
|
230
200
|
const id = decodeURIComponent(commentsMatch[1]!)
|
|
231
201
|
if (method === 'GET') {
|
|
232
|
-
return
|
|
233
|
-
response: await wrapHandler(async () => ({
|
|
234
|
-
ok: true,
|
|
235
|
-
data: await useCases.listComments(provider, id),
|
|
236
|
-
})),
|
|
237
|
-
mutated: false,
|
|
238
|
-
}
|
|
202
|
+
return readResult(() => provider.listComments(id))
|
|
239
203
|
}
|
|
240
204
|
|
|
241
205
|
if (method === 'POST') {
|
|
242
|
-
|
|
206
|
+
return mutationResult(async () => {
|
|
243
207
|
const body = await parseJsonBody<CommentBody>(req)
|
|
244
208
|
requireArgument(body.body, 'body')
|
|
245
|
-
return
|
|
209
|
+
return provider.comment(id, body.body!)
|
|
246
210
|
})
|
|
247
|
-
return { response, mutated: response.ok }
|
|
248
211
|
}
|
|
249
212
|
}
|
|
250
213
|
|
|
@@ -254,52 +217,35 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
254
217
|
const commentId = decodeURIComponent(commentMatch[2]!)
|
|
255
218
|
|
|
256
219
|
if (method === 'PATCH') {
|
|
257
|
-
|
|
220
|
+
return mutationResult(async () => {
|
|
258
221
|
const body = await parseJsonBody<CommentBody>(req)
|
|
259
222
|
requireArgument(body.body, 'body')
|
|
260
|
-
return
|
|
223
|
+
return provider.updateComment(id, commentId, body.body!)
|
|
261
224
|
})
|
|
262
|
-
return { response, mutated: response.ok }
|
|
263
225
|
}
|
|
264
226
|
}
|
|
265
227
|
|
|
266
228
|
if (path === '/api/activity' && method === 'GET') {
|
|
267
|
-
return {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}),
|
|
273
|
-
mutated: false,
|
|
274
|
-
}
|
|
229
|
+
return readResult(() => {
|
|
230
|
+
const taskId = url.searchParams.get('taskId') ?? undefined
|
|
231
|
+
const limit = parsePositiveInt(url.searchParams.get('limit'))
|
|
232
|
+
return provider.getActivity(limit, taskId)
|
|
233
|
+
})
|
|
275
234
|
}
|
|
276
235
|
|
|
277
236
|
if (path === '/api/metrics' && method === 'GET') {
|
|
278
|
-
return
|
|
279
|
-
response: await wrapHandler(async () => ({
|
|
280
|
-
ok: true,
|
|
281
|
-
data: await useCases.getMetrics(provider),
|
|
282
|
-
})),
|
|
283
|
-
mutated: false,
|
|
284
|
-
}
|
|
237
|
+
return readResult(() => provider.getMetrics())
|
|
285
238
|
}
|
|
286
239
|
|
|
287
240
|
if (path === '/api/config' && method === 'GET') {
|
|
288
|
-
return
|
|
289
|
-
response: await wrapHandler(async () => ({
|
|
290
|
-
ok: true,
|
|
291
|
-
data: await useCases.getConfig(provider),
|
|
292
|
-
})),
|
|
293
|
-
mutated: false,
|
|
294
|
-
}
|
|
241
|
+
return readResult(() => provider.getConfig())
|
|
295
242
|
}
|
|
296
243
|
|
|
297
244
|
if (path === '/api/config' && method === 'PATCH') {
|
|
298
|
-
|
|
245
|
+
return mutationResult(async () => {
|
|
299
246
|
const body = await parseJsonBody<Partial<BoardConfig>>(req)
|
|
300
|
-
return
|
|
247
|
+
return provider.patchConfig(body)
|
|
301
248
|
})
|
|
302
|
-
return { response, mutated: response.ok }
|
|
303
249
|
}
|
|
304
250
|
|
|
305
251
|
const webhookMatch = path.match(/^\/api\/webhooks\/([^/]+)$/)
|
package/src/column-roles.ts
CHANGED
|
@@ -30,8 +30,9 @@ const IN_PROGRESS_NAMES = new Set([
|
|
|
30
30
|
])
|
|
31
31
|
|
|
32
32
|
// Lowercase and drop separators so 'In Progress', 'in-progress', and
|
|
33
|
-
// 'in_progress' all normalize to the same token.
|
|
34
|
-
|
|
33
|
+
// 'in_progress' all normalize to the same token. Also used by the Jira cache's
|
|
34
|
+
// column resolution so trigger strings like 'Todo' match the status 'To Do'.
|
|
35
|
+
export function normalizeColumnName(name: string): string {
|
|
35
36
|
return name.toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
36
37
|
}
|
|
37
38
|
|