@andypai/agent-kanban 0.6.0 → 0.6.1
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/README.md +42 -19
- package/package.json +3 -2
- package/src/__tests__/activity.test.ts +12 -0
- package/src/__tests__/api.test.ts +4 -2
- package/src/__tests__/board-utils.test.ts +16 -3
- package/src/__tests__/column-roles.test.ts +34 -0
- package/src/__tests__/commands/board.test.ts +7 -0
- package/src/__tests__/conflict.test.ts +11 -1
- package/src/__tests__/db.test.ts +70 -0
- package/src/__tests__/index.test.ts +55 -0
- package/src/__tests__/jira-cache.test.ts +56 -0
- package/src/__tests__/jira-jql.test.ts +51 -0
- package/src/__tests__/jira-provider-read.test.ts +50 -0
- package/src/__tests__/mcp-server.test.ts +2 -2
- package/src/__tests__/metrics.test.ts +37 -1
- package/src/__tests__/postgres-local-provider.test.ts +90 -1
- package/src/__tests__/server.test.ts +126 -0
- package/src/__tests__/use-cases.test.ts +77 -0
- package/src/__tests__/webhooks.test.ts +75 -22
- package/src/api.ts +58 -36
- package/src/column-roles.ts +52 -0
- package/src/commands/board.ts +4 -4
- package/src/db.ts +145 -114
- package/src/errors.ts +1 -0
- package/src/index.ts +84 -33
- package/src/mcp/core.ts +8 -7
- package/src/metrics.ts +48 -23
- package/src/provider-runtime.ts +9 -2
- package/src/providers/index.ts +4 -1
- package/src/providers/jira-cache.ts +23 -6
- package/src/providers/jira-core.ts +921 -0
- package/src/providers/jira-jql.ts +48 -0
- package/src/providers/jira.ts +111 -759
- package/src/providers/linear-cache.ts +5 -3
- package/src/providers/linear-core.ts +688 -0
- package/src/providers/linear.ts +70 -554
- package/src/providers/postgres-jira-cache.ts +597 -0
- package/src/providers/postgres-jira.ts +15 -1312
- package/src/providers/postgres-linear-cache.ts +500 -0
- package/src/providers/postgres-linear.ts +22 -1088
- package/src/providers/postgres-local.ts +181 -74
- package/src/providers/types.ts +71 -2
- package/src/server.ts +118 -32
- package/src/use-cases.ts +139 -0
- package/src/webhooks.ts +26 -0
- package/ui/dist/assets/index-65LcV0R7.js +40 -0
- package/ui/dist/index.html +1 -1
- 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
|
|
4
|
+
import * as useCases from './use-cases'
|
|
5
5
|
|
|
6
6
|
export type WsEvent =
|
|
7
|
-
| { type: 'task:upsert'; task: Task;
|
|
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
|
-
|
|
75
|
-
|
|
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 () => ({
|
|
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 () => ({
|
|
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 () => ({
|
|
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 () => ({
|
|
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
|
|
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
|
|
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:
|
|
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 ?
|
|
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 () => ({
|
|
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
|
|
190
|
+
updated = await useCases.updateTask(provider, id, body)
|
|
175
191
|
return { ok: true, data: updated }
|
|
176
192
|
})
|
|
177
|
-
const event = response.ok && updated ?
|
|
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
|
|
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
|
|
214
|
+
moved = await useCases.moveTask(provider, id, body.column!)
|
|
199
215
|
return { ok: true, data: moved }
|
|
200
216
|
})
|
|
201
|
-
const event = response.ok && moved ?
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 () => ({
|
|
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 () => ({
|
|
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
|
|
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:
|
|
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
|
+
}
|
package/src/commands/board.ts
CHANGED
|
@@ -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
|
}
|