@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.
Files changed (45) hide show
  1. package/package.json +18 -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 +78 -80
  27. package/src/providers/linear-cache.ts +42 -69
  28. package/src/providers/linear-core.ts +41 -55
  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 +6 -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
@@ -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 * as useCases from '../use-cases'
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 layer owns label normalization so every transport feeds raw
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 useCases.createTask(provider, {
24
- title: 'cli',
25
- labels: [['bug', 'ui'], undefined],
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 useCases.createTask(provider, {
32
- title: 'http',
33
- labels: ['bug', 'ui'],
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 useCases.createTask(provider, {
40
- title: 'csv',
41
- labels: 'bug, ui, bug',
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 useCases.createTask(provider, { title: 'none' })
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 * as useCases from './use-cases'
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
- response: await wrapHandler(async () => {
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 {
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
- let created: Task | null = null
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
- created = await useCases.createTask(provider, {
164
- title: body.title!,
165
- description: body.description,
166
- column: body.column,
167
- priority: body.priority,
168
- assignee: body.assignee,
169
- project: body.project,
170
- labels: body.labels,
171
- metadata: body.metadata,
172
- })
173
- return { ok: true, data: created }
174
- })
175
- const event = response.ok && created ? upsertEvent(created) : undefined
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
- let updated: Task | null = null
195
- const response = await wrapHandler(async () => {
174
+ return mutationResult(async () => {
196
175
  const body = await parseJsonBody<UpdateTaskInput>(req)
197
- updated = await useCases.updateTask(provider, id, body)
198
- return { ok: true, data: updated }
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
- const response = await wrapHandler(async () => ({
206
- ok: true,
207
- data: await useCases.deleteTask(provider, id),
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
- let moved: Task | null = null
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
- moved = await useCases.moveTask(provider, id, body.column!)
222
- return { ok: true, data: moved }
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
- const response = await wrapHandler(async () => {
206
+ return mutationResult(async () => {
243
207
  const body = await parseJsonBody<CommentBody>(req)
244
208
  requireArgument(body.body, 'body')
245
- return { ok: true, data: await useCases.addComment(provider, id, body.body!) }
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
- const response = await wrapHandler(async () => {
220
+ return mutationResult(async () => {
258
221
  const body = await parseJsonBody<CommentBody>(req)
259
222
  requireArgument(body.body, 'body')
260
- return { ok: true, data: await useCases.updateComment(provider, id, commentId, body.body!) }
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
- response: await wrapHandler(async () => {
269
- const taskId = url.searchParams.get('taskId') ?? undefined
270
- const limit = parsePositiveInt(url.searchParams.get('limit'))
271
- return { ok: true, data: await useCases.getActivity(provider, limit, taskId) }
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
- const response = await wrapHandler(async () => {
245
+ return mutationResult(async () => {
299
246
  const body = await parseJsonBody<Partial<BoardConfig>>(req)
300
- return { ok: true, data: await useCases.patchConfig(provider, body) }
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\/([^/]+)$/)
@@ -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
- function normalizeColumnName(name: string): string {
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