@andypai/agent-kanban 0.6.0 → 0.6.2

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 (48) hide show
  1. package/README.md +42 -19
  2. package/package.json +3 -2
  3. package/src/__tests__/activity.test.ts +12 -0
  4. package/src/__tests__/api.test.ts +4 -2
  5. package/src/__tests__/board-utils.test.ts +16 -3
  6. package/src/__tests__/column-roles.test.ts +34 -0
  7. package/src/__tests__/commands/board.test.ts +7 -0
  8. package/src/__tests__/conflict.test.ts +11 -1
  9. package/src/__tests__/db.test.ts +70 -0
  10. package/src/__tests__/index.test.ts +55 -0
  11. package/src/__tests__/jira-cache.test.ts +56 -0
  12. package/src/__tests__/jira-jql.test.ts +51 -0
  13. package/src/__tests__/jira-provider-read.test.ts +50 -0
  14. package/src/__tests__/mcp-server.test.ts +2 -2
  15. package/src/__tests__/metrics.test.ts +37 -1
  16. package/src/__tests__/postgres-local-provider.test.ts +90 -1
  17. package/src/__tests__/server.test.ts +126 -0
  18. package/src/__tests__/use-cases.test.ts +77 -0
  19. package/src/__tests__/webhooks.test.ts +91 -22
  20. package/src/api.ts +58 -36
  21. package/src/column-roles.ts +52 -0
  22. package/src/commands/board.ts +4 -4
  23. package/src/db.ts +145 -114
  24. package/src/errors.ts +1 -0
  25. package/src/index.ts +84 -33
  26. package/src/mcp/core.ts +8 -7
  27. package/src/metrics.ts +48 -23
  28. package/src/provider-runtime.ts +9 -2
  29. package/src/providers/index.ts +4 -1
  30. package/src/providers/jira-cache.ts +23 -6
  31. package/src/providers/jira-core.ts +926 -0
  32. package/src/providers/jira-jql.ts +48 -0
  33. package/src/providers/jira.ts +111 -759
  34. package/src/providers/linear-cache.ts +5 -3
  35. package/src/providers/linear-core.ts +693 -0
  36. package/src/providers/linear.ts +70 -554
  37. package/src/providers/postgres-jira-cache.ts +597 -0
  38. package/src/providers/postgres-jira.ts +15 -1312
  39. package/src/providers/postgres-linear-cache.ts +500 -0
  40. package/src/providers/postgres-linear.ts +22 -1088
  41. package/src/providers/postgres-local.ts +181 -74
  42. package/src/providers/types.ts +71 -2
  43. package/src/server.ts +118 -32
  44. package/src/use-cases.ts +139 -0
  45. package/src/webhooks.ts +19 -0
  46. package/ui/dist/assets/index-65LcV0R7.js +40 -0
  47. package/ui/dist/index.html +1 -1
  48. package/ui/dist/assets/index-CFhtfqCn.js +0 -40
package/src/api.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  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
- import { normalizeLabels } from './labels'
4
+ import * as useCases from './use-cases'
5
5
 
6
6
  export type WsEvent =
7
- | { type: 'task:upsert'; task: Task; columnName: string }
7
+ | { type: 'task:upsert'; task: Task; columnId: string }
8
8
  | { type: 'task:delete'; id: string }
9
+ // Fallback when a mutation has no precise event; the UI does a full refresh.
10
+ | { type: 'refresh' }
9
11
 
10
12
  interface MoveTaskBody {
11
13
  column?: string
@@ -34,7 +36,8 @@ function statusForCode(code: string): number {
34
36
  if (
35
37
  code === ErrorCode.TASK_NOT_FOUND ||
36
38
  code === ErrorCode.COLUMN_NOT_FOUND ||
37
- code === ErrorCode.COMMENT_NOT_FOUND
39
+ code === ErrorCode.COMMENT_NOT_FOUND ||
40
+ code === ErrorCode.NOT_FOUND
38
41
  )
39
42
  return 404
40
43
  if (code === ErrorCode.PROVIDER_AUTH_FAILED) return 401
@@ -71,17 +74,8 @@ export interface ApiResult {
71
74
  event?: WsEvent
72
75
  }
73
76
 
74
- async function resolveColumnName(
75
- provider: KanbanProvider,
76
- columnId: string,
77
- ): Promise<string | null> {
78
- const columns = await provider.listColumns()
79
- return columns.find((column) => column.id === columnId)?.name ?? null
80
- }
81
-
82
- async function upsertEvent(provider: KanbanProvider, task: Task): Promise<WsEvent | undefined> {
83
- const columnName = await resolveColumnName(provider, task.column_id)
84
- return columnName ? { type: 'task:upsert', task, columnName } : undefined
77
+ function upsertEvent(task: Task): WsEvent {
78
+ return { type: 'task:upsert', task, columnId: task.column_id }
85
79
  }
86
80
 
87
81
  export async function handleRequest(provider: KanbanProvider, req: Request): Promise<ApiResult> {
@@ -91,28 +85,40 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
91
85
 
92
86
  if (path === '/api/bootstrap' && method === 'GET') {
93
87
  return {
94
- response: await wrapHandler(async () => ({ ok: true, data: await provider.getBootstrap() })),
88
+ response: await wrapHandler(async () => ({
89
+ ok: true,
90
+ data: await useCases.getBootstrap(provider),
91
+ })),
95
92
  mutated: false,
96
93
  }
97
94
  }
98
95
 
99
96
  if (path === '/api/provider' && method === 'GET') {
100
97
  return {
101
- response: await wrapHandler(async () => ({ ok: true, data: await provider.getContext() })),
98
+ response: await wrapHandler(async () => ({
99
+ ok: true,
100
+ data: await useCases.getContext(provider),
101
+ })),
102
102
  mutated: false,
103
103
  }
104
104
  }
105
105
 
106
106
  if (path === '/api/board' && method === 'GET') {
107
107
  return {
108
- response: await wrapHandler(async () => ({ ok: true, data: await provider.getBoard() })),
108
+ response: await wrapHandler(async () => ({
109
+ ok: true,
110
+ data: await useCases.getBoard(provider),
111
+ })),
109
112
  mutated: false,
110
113
  }
111
114
  }
112
115
 
113
116
  if (path === '/api/columns' && method === 'GET') {
114
117
  return {
115
- response: await wrapHandler(async () => ({ ok: true, data: await provider.listColumns() })),
118
+ response: await wrapHandler(async () => ({
119
+ ok: true,
120
+ data: await useCases.listColumns(provider),
121
+ })),
116
122
  mutated: false,
117
123
  }
118
124
  }
@@ -128,7 +134,14 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
128
134
  const limit = parseOptionalInt(url.searchParams.get('limit'))
129
135
  return {
130
136
  ok: true,
131
- data: await provider.listTasks({ column, priority, assignee, project, sort, limit }),
137
+ data: await useCases.listTasks(provider, {
138
+ column,
139
+ priority,
140
+ assignee,
141
+ project,
142
+ sort,
143
+ limit,
144
+ }),
132
145
  }
133
146
  }),
134
147
  mutated: false,
@@ -140,19 +153,19 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
140
153
  if (!body.title) return { response: missingArgument('title'), mutated: false }
141
154
  let created: Task | null = null
142
155
  const response = await wrapHandler(async () => {
143
- created = await provider.createTask({
156
+ created = await useCases.createTask(provider, {
144
157
  title: body.title!,
145
158
  description: body.description,
146
159
  column: body.column,
147
160
  priority: body.priority,
148
161
  assignee: body.assignee,
149
162
  project: body.project,
150
- labels: normalizeLabels(body.labels),
163
+ labels: body.labels,
151
164
  metadata: body.metadata,
152
165
  })
153
166
  return { ok: true, data: created }
154
167
  })
155
- const event = response.ok && created ? await upsertEvent(provider, created) : undefined
168
+ const event = response.ok && created ? upsertEvent(created) : undefined
156
169
  return { response, mutated: response.ok, event }
157
170
  }
158
171
 
@@ -162,7 +175,10 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
162
175
 
163
176
  if (method === 'GET') {
164
177
  return {
165
- response: await wrapHandler(async () => ({ ok: true, data: await provider.getTask(id) })),
178
+ response: await wrapHandler(async () => ({
179
+ ok: true,
180
+ data: await useCases.getTask(provider, id),
181
+ })),
166
182
  mutated: false,
167
183
  }
168
184
  }
@@ -171,17 +187,17 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
171
187
  const body = (await req.json()) as UpdateTaskInput
172
188
  let updated: Task | null = null
173
189
  const response = await wrapHandler(async () => {
174
- updated = await provider.updateTask(id, body)
190
+ updated = await useCases.updateTask(provider, id, body)
175
191
  return { ok: true, data: updated }
176
192
  })
177
- const event = response.ok && updated ? await upsertEvent(provider, updated) : undefined
193
+ const event = response.ok && updated ? upsertEvent(updated) : undefined
178
194
  return { response, mutated: response.ok, event }
179
195
  }
180
196
 
181
197
  if (method === 'DELETE') {
182
198
  const response = await wrapHandler(async () => ({
183
199
  ok: true,
184
- data: await provider.deleteTask(id),
200
+ data: await useCases.deleteTask(provider, id),
185
201
  }))
186
202
  const event: WsEvent | undefined = response.ok ? { type: 'task:delete', id } : undefined
187
203
  return { response, mutated: response.ok, event }
@@ -195,10 +211,10 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
195
211
  if (!body.column) return { response: missingArgument('column'), mutated: false }
196
212
  let moved: Task | null = null
197
213
  const response = await wrapHandler(async () => {
198
- moved = await provider.moveTask(id, body.column!)
214
+ moved = await useCases.moveTask(provider, id, body.column!)
199
215
  return { ok: true, data: moved }
200
216
  })
201
- const event = response.ok && moved ? await upsertEvent(provider, moved) : undefined
217
+ const event = response.ok && moved ? upsertEvent(moved) : undefined
202
218
  return { response, mutated: response.ok, event }
203
219
  }
204
220
 
@@ -209,7 +225,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
209
225
  return {
210
226
  response: await wrapHandler(async () => ({
211
227
  ok: true,
212
- data: await provider.listComments(id),
228
+ data: await useCases.listComments(provider, id),
213
229
  })),
214
230
  mutated: false,
215
231
  }
@@ -220,7 +236,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
220
236
  if (!body.body) return { response: missingArgument('body'), mutated: false }
221
237
  const response = await wrapHandler(async () => ({
222
238
  ok: true,
223
- data: await provider.comment(id, body.body!),
239
+ data: await useCases.addComment(provider, id, body.body!),
224
240
  }))
225
241
  return { response, mutated: response.ok }
226
242
  }
@@ -236,7 +252,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
236
252
  if (!body.body) return { response: missingArgument('body'), mutated: false }
237
253
  const response = await wrapHandler(async () => ({
238
254
  ok: true,
239
- data: await provider.updateComment(id, commentId, body.body!),
255
+ data: await useCases.updateComment(provider, id, commentId, body.body!),
240
256
  }))
241
257
  return { response, mutated: response.ok }
242
258
  }
@@ -247,7 +263,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
247
263
  response: await wrapHandler(async () => {
248
264
  const taskId = url.searchParams.get('taskId') ?? undefined
249
265
  const limit = parseOptionalInt(url.searchParams.get('limit'))
250
- return { ok: true, data: await provider.getActivity(limit, taskId) }
266
+ return { ok: true, data: await useCases.getActivity(provider, limit, taskId) }
251
267
  }),
252
268
  mutated: false,
253
269
  }
@@ -255,14 +271,20 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
255
271
 
256
272
  if (path === '/api/metrics' && method === 'GET') {
257
273
  return {
258
- response: await wrapHandler(async () => ({ ok: true, data: await provider.getMetrics() })),
274
+ response: await wrapHandler(async () => ({
275
+ ok: true,
276
+ data: await useCases.getMetrics(provider),
277
+ })),
259
278
  mutated: false,
260
279
  }
261
280
  }
262
281
 
263
282
  if (path === '/api/config' && method === 'GET') {
264
283
  return {
265
- response: await wrapHandler(async () => ({ ok: true, data: await provider.getConfig() })),
284
+ response: await wrapHandler(async () => ({
285
+ ok: true,
286
+ data: await useCases.getConfig(provider),
287
+ })),
266
288
  mutated: false,
267
289
  }
268
290
  }
@@ -271,7 +293,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
271
293
  const body = (await req.json()) as Partial<BoardConfig>
272
294
  const response = await wrapHandler(async () => ({
273
295
  ok: true,
274
- data: await provider.patchConfig(body),
296
+ data: await useCases.patchConfig(provider, body),
275
297
  }))
276
298
  return { response, mutated: response.ok }
277
299
  }
@@ -338,7 +360,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
338
360
 
339
361
  return {
340
362
  response: json(
341
- { ok: false, error: { code: 'NOT_FOUND', message: `No route: ${method} ${path}` } },
363
+ { ok: false, error: { code: ErrorCode.NOT_FOUND, message: `No route: ${method} ${path}` } },
342
364
  404,
343
365
  ),
344
366
  mutated: false,
@@ -0,0 +1,52 @@
1
+ // Board metrics need to know which columns mean "done" and "in progress", but
2
+ // columns are just (name, position) with no role metadata. Names vary across
3
+ // boards (custom local columns, Jira/Linear statuses, KANBAN_DEFAULT_COLUMNS),
4
+ // so we classify by a normalized name plus a small synonym set rather than an
5
+ // exact 'done' / 'in-progress' string match.
6
+
7
+ export interface ClassifiableColumn {
8
+ id: string
9
+ name: string
10
+ position: number
11
+ }
12
+
13
+ const DONE_NAMES = new Set([
14
+ 'done',
15
+ 'complete',
16
+ 'completed',
17
+ 'closed',
18
+ 'resolved',
19
+ 'shipped',
20
+ 'merged',
21
+ ])
22
+
23
+ const IN_PROGRESS_NAMES = new Set([
24
+ 'inprogress',
25
+ 'doing',
26
+ 'wip',
27
+ 'started',
28
+ 'indevelopment',
29
+ 'inreview',
30
+ ])
31
+
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 {
35
+ return name.toLowerCase().replace(/[^a-z0-9]/g, '')
36
+ }
37
+
38
+ export function selectDoneColumnIds(columns: ClassifiableColumn[]): string[] {
39
+ const matched = columns.filter((c) => DONE_NAMES.has(normalizeColumnName(c.name)))
40
+ if (matched.length > 0) return matched.map((c) => c.id)
41
+ // No recognizable "done" name: fall back to the terminal column (the kanban
42
+ // convention) so completion metrics aren't silently zero under custom names.
43
+ const terminal = columns.reduce<ClassifiableColumn | null>(
44
+ (best, c) => (best === null || c.position > best.position ? c : best),
45
+ null,
46
+ )
47
+ return terminal ? [terminal.id] : []
48
+ }
49
+
50
+ export function selectInProgressColumnIds(columns: ClassifiableColumn[]): string[] {
51
+ return columns.filter((c) => IN_PROGRESS_NAMES.has(normalizeColumnName(c.name))).map((c) => c.id)
52
+ }
@@ -4,16 +4,16 @@ import { ErrorCode, KanbanError } from '../errors'
4
4
  import { success } from '../output'
5
5
  import type { CliOutput } from '../types'
6
6
 
7
- export function boardInit(db: Database): CliOutput {
7
+ export function boardInit(db: Database, columnNames?: string[]): CliOutput {
8
8
  if (isInitialized(db)) {
9
9
  throw new KanbanError(ErrorCode.BOARD_ALREADY_INITIALIZED, 'Board is already initialized')
10
10
  }
11
11
  initSchema(db)
12
- seedDefaultColumns(db)
12
+ seedDefaultColumns(db, columnNames)
13
13
  return success({ message: 'Board initialized with default columns.' })
14
14
  }
15
15
 
16
- export function boardReset(db: Database): CliOutput {
17
- resetBoard(db)
16
+ export function boardReset(db: Database, columnNames?: string[]): CliOutput {
17
+ resetBoard(db, columnNames)
18
18
  return success({ message: 'Board reset. All data cleared and defaults restored.' })
19
19
  }