@andypai/agent-kanban 0.2.0 → 0.3.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 +120 -24
- package/package.json +4 -2
- package/src/__tests__/activity.test.ts +16 -10
- package/src/__tests__/api.test.ts +99 -3
- package/src/__tests__/board-utils.test.ts +100 -0
- package/src/__tests__/commands/board.test.ts +7 -14
- package/src/__tests__/commands/bulk.test.ts +3 -3
- package/src/__tests__/commands/column.test.ts +4 -4
- package/src/__tests__/conflict.test.ts +64 -0
- package/src/__tests__/db.test.ts +2 -2
- package/src/__tests__/id.test.ts +1 -1
- package/src/__tests__/index.test.ts +233 -56
- package/src/__tests__/jira-adf.test.ts +180 -0
- package/src/__tests__/jira-cache.test.ts +304 -0
- package/src/__tests__/jira-client.test.ts +169 -0
- package/src/__tests__/jira-provider-comment.test.ts +281 -0
- package/src/__tests__/jira-provider-mutations.test.ts +771 -0
- package/src/__tests__/jira-provider-read.test.ts +594 -0
- package/src/__tests__/jira-wiring.test.ts +187 -0
- package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
- package/src/__tests__/linear-provider-comment.test.ts +243 -0
- package/src/__tests__/linear-provider-sync.test.ts +488 -0
- package/src/__tests__/local-provider-comment.test.ts +60 -0
- package/src/__tests__/mcp-core.test.ts +164 -0
- package/src/__tests__/mcp-server.test.ts +252 -0
- package/src/__tests__/metrics.test.ts +2 -2
- package/src/__tests__/output.test.ts +1 -1
- package/src/__tests__/provider-capabilities.test.ts +40 -0
- package/src/__tests__/server.test.ts +291 -0
- package/src/__tests__/webhooks.test.ts +604 -0
- package/src/activity.ts +2 -12
- package/src/api.ts +156 -21
- package/src/commands/board.ts +4 -14
- package/src/commands/bulk.ts +4 -4
- package/src/commands/column.ts +4 -4
- package/src/commands/mcp.ts +87 -0
- package/src/config.ts +1 -1
- package/src/db.ts +118 -6
- package/src/errors.ts +2 -0
- package/src/id.ts +1 -1
- package/src/index.ts +83 -35
- package/src/mcp/core.ts +193 -0
- package/src/mcp/errors.ts +109 -0
- package/src/mcp/index.ts +13 -0
- package/src/mcp/server.ts +512 -0
- package/src/mcp/types.ts +72 -0
- package/src/metrics.ts +1 -1
- package/src/output.ts +1 -1
- package/src/providers/capabilities.ts +22 -17
- package/src/providers/errors.ts +1 -1
- package/src/providers/index.ts +36 -6
- package/src/providers/jira-adf.ts +275 -0
- package/src/providers/jira-cache.ts +625 -0
- package/src/providers/jira-client.ts +390 -0
- package/src/providers/jira.ts +773 -0
- package/src/providers/linear-cache.ts +250 -71
- package/src/providers/linear-client.ts +255 -15
- package/src/providers/linear.ts +338 -20
- package/src/providers/local.ts +74 -23
- package/src/providers/types.ts +19 -3
- package/src/server.ts +141 -13
- package/src/tunnel.ts +79 -0
- package/src/types.ts +19 -2
- package/src/webhooks.ts +36 -0
- package/ui/dist/assets/index-DBnoKL_k.css +1 -0
- package/ui/dist/assets/index-qNVJ6clH.js +40 -0
- package/ui/dist/index.html +2 -2
- package/src/__tests__/commands/task.test.ts +0 -144
- package/src/commands/task.ts +0 -117
- package/src/fixtures.ts +0 -128
- package/ui/dist/assets/index-B8f9NB4z.css +0 -1
- package/ui/dist/assets/index-zWp-rB7b.js +0 -40
package/src/api.ts
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
import { KanbanError, ErrorCode } from './errors
|
|
2
|
-
import type { BoardConfig, CliOutput } from './types
|
|
3
|
-
import type { CreateTaskInput, UpdateTaskInput, KanbanProvider } from './providers/types
|
|
1
|
+
import { KanbanError, ErrorCode } from './errors'
|
|
2
|
+
import type { BoardConfig, CliOutput, Task } from './types'
|
|
3
|
+
import type { CreateTaskInput, UpdateTaskInput, KanbanProvider } from './providers/types'
|
|
4
|
+
|
|
5
|
+
export type WsEvent =
|
|
6
|
+
| { type: 'task:upsert'; task: Task; columnName: string }
|
|
7
|
+
| { type: 'task:delete'; id: string }
|
|
4
8
|
|
|
5
9
|
interface MoveTaskBody {
|
|
6
10
|
column?: string
|
|
7
11
|
}
|
|
8
12
|
|
|
13
|
+
interface CommentBody {
|
|
14
|
+
body?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
function json(data: unknown, status = 200): Response {
|
|
10
18
|
return Response.json(data, { status })
|
|
11
19
|
}
|
|
@@ -22,9 +30,15 @@ function missingArgument(field: string): Response {
|
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
function statusForCode(code: string): number {
|
|
25
|
-
if (
|
|
33
|
+
if (
|
|
34
|
+
code === ErrorCode.TASK_NOT_FOUND ||
|
|
35
|
+
code === ErrorCode.COLUMN_NOT_FOUND ||
|
|
36
|
+
code === ErrorCode.COMMENT_NOT_FOUND
|
|
37
|
+
)
|
|
38
|
+
return 404
|
|
26
39
|
if (code === ErrorCode.PROVIDER_AUTH_FAILED) return 401
|
|
27
40
|
if (code === ErrorCode.PROVIDER_RATE_LIMITED) return 429
|
|
41
|
+
if (code === ErrorCode.CONFLICT) return 409
|
|
28
42
|
if (code === ErrorCode.UNSUPPORTED_OPERATION) return 400
|
|
29
43
|
if (code === ErrorCode.PROVIDER_NOT_CONFIGURED) return 500
|
|
30
44
|
return 400
|
|
@@ -53,6 +67,20 @@ async function wrapHandler(fn: () => Promise<CliOutput> | CliOutput): Promise<Re
|
|
|
53
67
|
export interface ApiResult {
|
|
54
68
|
response: Response
|
|
55
69
|
mutated: boolean
|
|
70
|
+
event?: WsEvent
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function resolveColumnName(
|
|
74
|
+
provider: KanbanProvider,
|
|
75
|
+
columnId: string,
|
|
76
|
+
): Promise<string | null> {
|
|
77
|
+
const columns = await provider.listColumns()
|
|
78
|
+
return columns.find((column) => column.id === columnId)?.name ?? null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function upsertEvent(provider: KanbanProvider, task: Task): Promise<WsEvent | undefined> {
|
|
82
|
+
const columnName = await resolveColumnName(provider, task.column_id)
|
|
83
|
+
return columnName ? { type: 'task:upsert', task, columnName } : undefined
|
|
56
84
|
}
|
|
57
85
|
|
|
58
86
|
export async function handleRequest(provider: KanbanProvider, req: Request): Promise<ApiResult> {
|
|
@@ -109,9 +137,9 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
109
137
|
if (path === '/api/tasks' && method === 'POST') {
|
|
110
138
|
const body = (await req.json()) as Partial<CreateTaskInput>
|
|
111
139
|
if (!body.title) return { response: missingArgument('title'), mutated: false }
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
140
|
+
let created: Task | null = null
|
|
141
|
+
const response = await wrapHandler(async () => {
|
|
142
|
+
created = await provider.createTask({
|
|
115
143
|
title: body.title!,
|
|
116
144
|
description: body.description,
|
|
117
145
|
column: body.column,
|
|
@@ -119,9 +147,11 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
119
147
|
assignee: body.assignee,
|
|
120
148
|
project: body.project,
|
|
121
149
|
metadata: body.metadata,
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
|
|
150
|
+
})
|
|
151
|
+
return { ok: true, data: created }
|
|
152
|
+
})
|
|
153
|
+
const event = response.ok && created ? await upsertEvent(provider, created) : undefined
|
|
154
|
+
return { response, mutated: response.ok, event }
|
|
125
155
|
}
|
|
126
156
|
|
|
127
157
|
const taskMatch = path.match(/^\/api\/tasks\/([^/]+)$/)
|
|
@@ -137,11 +167,13 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
137
167
|
|
|
138
168
|
if (method === 'PATCH') {
|
|
139
169
|
const body = (await req.json()) as UpdateTaskInput
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
170
|
+
let updated: Task | null = null
|
|
171
|
+
const response = await wrapHandler(async () => {
|
|
172
|
+
updated = await provider.updateTask(id, body)
|
|
173
|
+
return { ok: true, data: updated }
|
|
174
|
+
})
|
|
175
|
+
const event = response.ok && updated ? await upsertEvent(provider, updated) : undefined
|
|
176
|
+
return { response, mutated: response.ok, event }
|
|
145
177
|
}
|
|
146
178
|
|
|
147
179
|
if (method === 'DELETE') {
|
|
@@ -149,7 +181,8 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
149
181
|
ok: true,
|
|
150
182
|
data: await provider.deleteTask(id),
|
|
151
183
|
}))
|
|
152
|
-
|
|
184
|
+
const event: WsEvent | undefined = response.ok ? { type: 'task:delete', id } : undefined
|
|
185
|
+
return { response, mutated: response.ok, event }
|
|
153
186
|
}
|
|
154
187
|
}
|
|
155
188
|
|
|
@@ -158,11 +191,53 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
158
191
|
const id = decodeURIComponent(moveMatch[1]!)
|
|
159
192
|
const body = (await req.json()) as MoveTaskBody
|
|
160
193
|
if (!body.column) return { response: missingArgument('column'), mutated: false }
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
194
|
+
let moved: Task | null = null
|
|
195
|
+
const response = await wrapHandler(async () => {
|
|
196
|
+
moved = await provider.moveTask(id, body.column!)
|
|
197
|
+
return { ok: true, data: moved }
|
|
198
|
+
})
|
|
199
|
+
const event = response.ok && moved ? await upsertEvent(provider, moved) : undefined
|
|
200
|
+
return { response, mutated: response.ok, event }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const commentsMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments$/)
|
|
204
|
+
if (commentsMatch) {
|
|
205
|
+
const id = decodeURIComponent(commentsMatch[1]!)
|
|
206
|
+
if (method === 'GET') {
|
|
207
|
+
return {
|
|
208
|
+
response: await wrapHandler(async () => ({
|
|
209
|
+
ok: true,
|
|
210
|
+
data: await provider.listComments(id),
|
|
211
|
+
})),
|
|
212
|
+
mutated: false,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (method === 'POST') {
|
|
217
|
+
const body = (await req.json()) as CommentBody
|
|
218
|
+
if (!body.body) return { response: missingArgument('body'), mutated: false }
|
|
219
|
+
const response = await wrapHandler(async () => ({
|
|
220
|
+
ok: true,
|
|
221
|
+
data: await provider.comment(id, body.body!),
|
|
222
|
+
}))
|
|
223
|
+
return { response, mutated: response.ok }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const commentMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments\/([^/]+)$/)
|
|
228
|
+
if (commentMatch) {
|
|
229
|
+
const id = decodeURIComponent(commentMatch[1]!)
|
|
230
|
+
const commentId = decodeURIComponent(commentMatch[2]!)
|
|
231
|
+
|
|
232
|
+
if (method === 'PATCH') {
|
|
233
|
+
const body = (await req.json()) as CommentBody
|
|
234
|
+
if (!body.body) return { response: missingArgument('body'), mutated: false }
|
|
235
|
+
const response = await wrapHandler(async () => ({
|
|
236
|
+
ok: true,
|
|
237
|
+
data: await provider.updateComment(id, commentId, body.body!),
|
|
238
|
+
}))
|
|
239
|
+
return { response, mutated: response.ok }
|
|
240
|
+
}
|
|
166
241
|
}
|
|
167
242
|
|
|
168
243
|
if (path === '/api/activity' && method === 'GET') {
|
|
@@ -199,6 +274,66 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
199
274
|
return { response, mutated: response.ok }
|
|
200
275
|
}
|
|
201
276
|
|
|
277
|
+
const webhookMatch = path.match(/^\/api\/webhooks\/([^/]+)$/)
|
|
278
|
+
if (webhookMatch && method === 'POST') {
|
|
279
|
+
const target = decodeURIComponent(webhookMatch[1]!)
|
|
280
|
+
if (target !== provider.type) {
|
|
281
|
+
return {
|
|
282
|
+
response: json(
|
|
283
|
+
{
|
|
284
|
+
ok: false,
|
|
285
|
+
error: {
|
|
286
|
+
code: 'UNSUPPORTED_OPERATION',
|
|
287
|
+
message: `Webhook target '${target}' does not match active provider '${provider.type}'`,
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
400,
|
|
291
|
+
),
|
|
292
|
+
mutated: false,
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (!provider.handleWebhook) {
|
|
296
|
+
return {
|
|
297
|
+
response: json(
|
|
298
|
+
{
|
|
299
|
+
ok: false,
|
|
300
|
+
error: {
|
|
301
|
+
code: 'UNSUPPORTED_OPERATION',
|
|
302
|
+
message: `Provider '${provider.type}' does not accept webhooks`,
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
400,
|
|
306
|
+
),
|
|
307
|
+
mutated: false,
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const rawBody = await req.text()
|
|
311
|
+
const headers: Record<string, string> = {}
|
|
312
|
+
req.headers.forEach((value, key) => {
|
|
313
|
+
headers[key] = value
|
|
314
|
+
})
|
|
315
|
+
const result = await provider.handleWebhook({ headers, rawBody })
|
|
316
|
+
if (result.unauthorized) {
|
|
317
|
+
return {
|
|
318
|
+
response: json(
|
|
319
|
+
{
|
|
320
|
+
ok: false,
|
|
321
|
+
error: { code: 'PROVIDER_AUTH_FAILED', message: result.message ?? 'Unauthorized' },
|
|
322
|
+
},
|
|
323
|
+
401,
|
|
324
|
+
),
|
|
325
|
+
mutated: false,
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
response: json({
|
|
330
|
+
ok: true,
|
|
331
|
+
data: { handled: result.handled, message: result.message ?? null },
|
|
332
|
+
}),
|
|
333
|
+
mutated: result.handled,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
202
337
|
return {
|
|
203
338
|
response: json(
|
|
204
339
|
{ ok: false, error: { code: 'NOT_FOUND', message: `No route: ${method} ${path}` } },
|
package/src/commands/board.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite'
|
|
2
|
-
import { initSchema, seedDefaultColumns, isInitialized,
|
|
3
|
-
import { ErrorCode, KanbanError } from '../errors
|
|
4
|
-
import { success } from '../output
|
|
5
|
-
import type { CliOutput } from '../types
|
|
2
|
+
import { initSchema, seedDefaultColumns, isInitialized, resetBoard } from '../db'
|
|
3
|
+
import { ErrorCode, KanbanError } from '../errors'
|
|
4
|
+
import { success } from '../output'
|
|
5
|
+
import type { CliOutput } from '../types'
|
|
6
6
|
|
|
7
7
|
export function boardInit(db: Database): CliOutput {
|
|
8
8
|
if (isInitialized(db)) {
|
|
@@ -13,16 +13,6 @@ export function boardInit(db: Database): CliOutput {
|
|
|
13
13
|
return success({ message: 'Board initialized with default columns.' })
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export function boardView(db: Database): CliOutput {
|
|
17
|
-
if (!isInitialized(db)) {
|
|
18
|
-
throw new KanbanError(
|
|
19
|
-
ErrorCode.BOARD_NOT_INITIALIZED,
|
|
20
|
-
'Board not initialized. Run: kanban board init',
|
|
21
|
-
)
|
|
22
|
-
}
|
|
23
|
-
return success(getBoardView(db))
|
|
24
|
-
}
|
|
25
|
-
|
|
26
16
|
export function boardReset(db: Database): CliOutput {
|
|
27
17
|
resetBoard(db)
|
|
28
18
|
return success({ message: 'Board reset. All data cleared and defaults restored.' })
|
package/src/commands/bulk.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite'
|
|
2
|
-
import { bulkMoveAll, bulkClearDone } from '../db
|
|
3
|
-
import { ErrorCode, KanbanError } from '../errors
|
|
4
|
-
import { success } from '../output
|
|
5
|
-
import type { CliOutput } from '../types
|
|
2
|
+
import { bulkMoveAll, bulkClearDone } from '../db'
|
|
3
|
+
import { ErrorCode, KanbanError } from '../errors'
|
|
4
|
+
import { success } from '../output'
|
|
5
|
+
import type { CliOutput } from '../types'
|
|
6
6
|
|
|
7
7
|
export function bulkMoveAllCmd(db: Database, args: { from?: string; to?: string }): CliOutput {
|
|
8
8
|
if (!args.from || !args.to) {
|
package/src/commands/column.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite'
|
|
2
|
-
import { addColumn, listColumns, renameColumn, reorderColumn, deleteColumn } from '../db
|
|
3
|
-
import { ErrorCode, KanbanError } from '../errors
|
|
4
|
-
import { success } from '../output
|
|
5
|
-
import type { CliOutput } from '../types
|
|
2
|
+
import { addColumn, listColumns, renameColumn, reorderColumn, deleteColumn } from '../db'
|
|
3
|
+
import { ErrorCode, KanbanError } from '../errors'
|
|
4
|
+
import { success } from '../output'
|
|
5
|
+
import type { CliOutput } from '../types'
|
|
6
6
|
|
|
7
7
|
export function columnAdd(
|
|
8
8
|
db: Database,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server'
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
3
|
+
import {
|
|
4
|
+
CallToolRequestSchema,
|
|
5
|
+
ListToolsRequestSchema,
|
|
6
|
+
type CallToolResult,
|
|
7
|
+
} from '@modelcontextprotocol/sdk/types.js'
|
|
8
|
+
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'
|
|
9
|
+
import { createTrackerCore, defaultTools, TrackerMcpError } from '../mcp/index'
|
|
10
|
+
import type { TrackerMcpPolicy, TrackerMcpTool } from '../mcp/index'
|
|
11
|
+
import type { KanbanProvider } from '../providers/types'
|
|
12
|
+
|
|
13
|
+
type LocalScope = Record<string, never>
|
|
14
|
+
|
|
15
|
+
const allowAllPolicy: TrackerMcpPolicy<LocalScope> = {
|
|
16
|
+
canReadTicket() {},
|
|
17
|
+
canPostComment() {},
|
|
18
|
+
canUpdateComment() {},
|
|
19
|
+
canMoveTicket() {},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toCallToolResult(result: unknown): CallToolResult {
|
|
23
|
+
const text = typeof result === 'string' ? result : JSON.stringify(result ?? null)
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: 'text', text }],
|
|
26
|
+
structuredContent: { result: result ?? null },
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function startStdioMcpServer(provider: KanbanProvider): Promise<void> {
|
|
31
|
+
const core = createTrackerCore<LocalScope>({ provider, policy: allowAllPolicy })
|
|
32
|
+
const scope: LocalScope = {} as LocalScope
|
|
33
|
+
const tools = defaultTools(core)
|
|
34
|
+
const byName = new Map<string, TrackerMcpTool<LocalScope>>(tools.map((tool) => [tool.name, tool]))
|
|
35
|
+
const validators = new AjvJsonSchemaValidator()
|
|
36
|
+
const validateByName = new Map(
|
|
37
|
+
tools.map((tool) => [tool.name, validators.getValidator(tool.inputSchema)]),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const server = new Server(
|
|
41
|
+
{ name: 'agent-kanban', version: '1.0.0' },
|
|
42
|
+
{ capabilities: { tools: {} } },
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
46
|
+
tools: tools.map((tool) => ({
|
|
47
|
+
name: tool.name,
|
|
48
|
+
description: tool.description,
|
|
49
|
+
inputSchema: tool.inputSchema,
|
|
50
|
+
})),
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
54
|
+
const tool = byName.get(request.params.name)
|
|
55
|
+
if (!tool) {
|
|
56
|
+
throw new TrackerMcpError({
|
|
57
|
+
code: 'validation_failed',
|
|
58
|
+
publicMessage: `Unknown tool '${request.params.name}'`,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
const validated = validateByName.get(tool.name)!(request.params.arguments ?? {})
|
|
62
|
+
if (!validated.valid) {
|
|
63
|
+
throw new TrackerMcpError({
|
|
64
|
+
code: 'validation_failed',
|
|
65
|
+
publicMessage: validated.errorMessage ?? `Invalid arguments for '${tool.name}'`,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
const result = await tool.handler({
|
|
69
|
+
scope,
|
|
70
|
+
args: validated.data as Record<string, unknown>,
|
|
71
|
+
})
|
|
72
|
+
return toCallToolResult(result)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const transport = new StdioServerTransport()
|
|
76
|
+
await server.connect(transport)
|
|
77
|
+
|
|
78
|
+
const shutdown = async (): Promise<void> => {
|
|
79
|
+
try {
|
|
80
|
+
await server.close()
|
|
81
|
+
} finally {
|
|
82
|
+
process.exit(0)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
process.on('SIGINT', shutdown)
|
|
86
|
+
process.on('SIGTERM', shutdown)
|
|
87
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
|
-
import type { BoardConfig } from './types
|
|
3
|
+
import type { BoardConfig } from './types'
|
|
4
4
|
|
|
5
5
|
const DEFAULT_CONFIG: BoardConfig = { members: [], projects: [] }
|
|
6
6
|
|
package/src/db.ts
CHANGED
|
@@ -2,10 +2,10 @@ import { Database } from 'bun:sqlite'
|
|
|
2
2
|
import { existsSync, mkdirSync } from 'node:fs'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
|
-
import { generateId } from './id
|
|
6
|
-
import { ErrorCode, KanbanError } from './errors
|
|
7
|
-
import type { Column,
|
|
8
|
-
import { logActivity, enterColumn, exitColumn } from './activity
|
|
5
|
+
import { generateId } from './id'
|
|
6
|
+
import { ErrorCode, KanbanError } from './errors'
|
|
7
|
+
import type { BoardView, Column, Priority, Task, TaskComment, TaskWithColumn } from './types'
|
|
8
|
+
import { logActivity, enterColumn, exitColumn } from './activity'
|
|
9
9
|
|
|
10
10
|
const DEFAULT_COLUMNS = [
|
|
11
11
|
{ name: 'recurring', position: 0 },
|
|
@@ -64,6 +64,7 @@ export function initSchema(db: Database): void {
|
|
|
64
64
|
assignee TEXT NOT NULL DEFAULT '',
|
|
65
65
|
project TEXT NOT NULL DEFAULT '',
|
|
66
66
|
metadata TEXT NOT NULL DEFAULT '{}',
|
|
67
|
+
revision INTEGER NOT NULL DEFAULT 0,
|
|
67
68
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
68
69
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
69
70
|
)
|
|
@@ -79,6 +80,16 @@ export function initSchema(db: Database): void {
|
|
|
79
80
|
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
80
81
|
)
|
|
81
82
|
`)
|
|
83
|
+
db.run(`
|
|
84
|
+
CREATE TABLE IF NOT EXISTS comments (
|
|
85
|
+
id TEXT PRIMARY KEY,
|
|
86
|
+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
87
|
+
body TEXT NOT NULL,
|
|
88
|
+
author TEXT,
|
|
89
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
90
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
91
|
+
)
|
|
92
|
+
`)
|
|
82
93
|
db.run(`
|
|
83
94
|
CREATE TABLE IF NOT EXISTS column_time_tracking (
|
|
84
95
|
id TEXT PRIMARY KEY,
|
|
@@ -97,6 +108,7 @@ export function initSchema(db: Database): void {
|
|
|
97
108
|
db.run('CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project)')
|
|
98
109
|
db.run('CREATE INDEX IF NOT EXISTS idx_activity_task_id ON activity_log(task_id)')
|
|
99
110
|
db.run('CREATE INDEX IF NOT EXISTS idx_activity_timestamp ON activity_log(timestamp)')
|
|
111
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_comments_task_id ON comments(task_id)')
|
|
100
112
|
db.run('CREATE INDEX IF NOT EXISTS idx_column_time_task_id ON column_time_tracking(task_id)')
|
|
101
113
|
}
|
|
102
114
|
|
|
@@ -112,6 +124,10 @@ export function migrateSchema(db: Database): void {
|
|
|
112
124
|
if (!hasProject) {
|
|
113
125
|
db.run("ALTER TABLE tasks ADD COLUMN project TEXT NOT NULL DEFAULT ''")
|
|
114
126
|
}
|
|
127
|
+
const hasRevision = columns.some((c) => c.name === 'revision')
|
|
128
|
+
if (!hasRevision) {
|
|
129
|
+
db.run('ALTER TABLE tasks ADD COLUMN revision INTEGER NOT NULL DEFAULT 0')
|
|
130
|
+
}
|
|
115
131
|
db.run('CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project)')
|
|
116
132
|
}
|
|
117
133
|
|
|
@@ -399,7 +415,7 @@ export function updateTask(
|
|
|
399
415
|
}
|
|
400
416
|
}
|
|
401
417
|
|
|
402
|
-
const sets: string[] = ["updated_at = datetime('now')"]
|
|
418
|
+
const sets: string[] = ["updated_at = datetime('now')", 'revision = revision + 1']
|
|
403
419
|
const params: Record<string, string> = { $id: id }
|
|
404
420
|
|
|
405
421
|
if (updates.title !== undefined) {
|
|
@@ -477,6 +493,101 @@ export function deleteTask(db: Database, id: string): TaskWithColumn {
|
|
|
477
493
|
return task
|
|
478
494
|
}
|
|
479
495
|
|
|
496
|
+
export function getComment(db: Database, taskId: string, commentId: string): TaskComment {
|
|
497
|
+
const row = db
|
|
498
|
+
.query(
|
|
499
|
+
`SELECT id, task_id, body, author, created_at, updated_at
|
|
500
|
+
FROM comments
|
|
501
|
+
WHERE id = $id AND task_id = $task_id`,
|
|
502
|
+
)
|
|
503
|
+
.get({
|
|
504
|
+
$id: commentId,
|
|
505
|
+
$task_id: taskId,
|
|
506
|
+
}) as TaskComment | null
|
|
507
|
+
if (!row) {
|
|
508
|
+
throw new KanbanError(
|
|
509
|
+
ErrorCode.COMMENT_NOT_FOUND,
|
|
510
|
+
`No comment '${commentId}' exists on task '${taskId}'`,
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
return row
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export function listComments(db: Database, taskId: string): TaskComment[] {
|
|
517
|
+
getTask(db, taskId)
|
|
518
|
+
return db
|
|
519
|
+
.query(
|
|
520
|
+
`SELECT id, task_id, body, author, created_at, updated_at
|
|
521
|
+
FROM comments
|
|
522
|
+
WHERE task_id = $task_id
|
|
523
|
+
ORDER BY created_at ASC, rowid ASC`,
|
|
524
|
+
)
|
|
525
|
+
.all({ $task_id: taskId }) as TaskComment[]
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function countComments(db: Database, taskId: string): number {
|
|
529
|
+
const row = db
|
|
530
|
+
.query('SELECT COUNT(*) as count FROM comments WHERE task_id = $task_id')
|
|
531
|
+
.get({ $task_id: taskId }) as { count: number }
|
|
532
|
+
return row.count
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function countCommentsByTask(db: Database): Map<string, number> {
|
|
536
|
+
const rows = db
|
|
537
|
+
.query('SELECT task_id, COUNT(*) as count FROM comments GROUP BY task_id')
|
|
538
|
+
.all() as Array<{ task_id: string; count: number }>
|
|
539
|
+
return new Map(rows.map((row) => [row.task_id, row.count]))
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function addComment(
|
|
543
|
+
db: Database,
|
|
544
|
+
taskId: string,
|
|
545
|
+
body: string,
|
|
546
|
+
author: string | null = null,
|
|
547
|
+
): TaskComment {
|
|
548
|
+
getTask(db, taskId)
|
|
549
|
+
const id = generateId('cm')
|
|
550
|
+
db.query(
|
|
551
|
+
`INSERT INTO comments (id, task_id, body, author)
|
|
552
|
+
VALUES ($id, $task_id, $body, $author)`,
|
|
553
|
+
).run({
|
|
554
|
+
$id: id,
|
|
555
|
+
$task_id: taskId,
|
|
556
|
+
$body: body,
|
|
557
|
+
$author: author,
|
|
558
|
+
})
|
|
559
|
+
logActivity(db, taskId, 'updated', {
|
|
560
|
+
field: 'comment',
|
|
561
|
+
new_value: body,
|
|
562
|
+
})
|
|
563
|
+
return getComment(db, taskId, id)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function updateComment(
|
|
567
|
+
db: Database,
|
|
568
|
+
taskId: string,
|
|
569
|
+
commentId: string,
|
|
570
|
+
body: string,
|
|
571
|
+
): TaskComment {
|
|
572
|
+
const existing = getComment(db, taskId, commentId)
|
|
573
|
+
db.query(
|
|
574
|
+
`UPDATE comments
|
|
575
|
+
SET body = $body,
|
|
576
|
+
updated_at = datetime('now')
|
|
577
|
+
WHERE id = $id AND task_id = $task_id`,
|
|
578
|
+
).run({
|
|
579
|
+
$id: commentId,
|
|
580
|
+
$task_id: taskId,
|
|
581
|
+
$body: body,
|
|
582
|
+
})
|
|
583
|
+
logActivity(db, taskId, 'updated', {
|
|
584
|
+
field: 'comment',
|
|
585
|
+
old_value: existing.body,
|
|
586
|
+
new_value: body,
|
|
587
|
+
})
|
|
588
|
+
return getComment(db, taskId, commentId)
|
|
589
|
+
}
|
|
590
|
+
|
|
480
591
|
export function moveTask(db: Database, id: string, columnIdOrName: string): TaskWithColumn {
|
|
481
592
|
const task = getTask(db, id)
|
|
482
593
|
const column = resolveColumn(db, columnIdOrName)
|
|
@@ -487,7 +598,7 @@ export function moveTask(db: Database, id: string, columnIdOrName: string): Task
|
|
|
487
598
|
|
|
488
599
|
const oldColumnId = task.column_id
|
|
489
600
|
db.query(
|
|
490
|
-
"UPDATE tasks SET column_id = $col, position = $pos, updated_at = datetime('now') WHERE id = $id",
|
|
601
|
+
"UPDATE tasks SET column_id = $col, position = $pos, updated_at = datetime('now'), revision = revision + 1 WHERE id = $id",
|
|
491
602
|
).run({ $col: column.id, $pos: maxPos.next, $id: id })
|
|
492
603
|
renumberTasksInColumn(db, oldColumnId)
|
|
493
604
|
|
|
@@ -578,6 +689,7 @@ export function bulkClearDone(db: Database): { deleted: number } {
|
|
|
578
689
|
}
|
|
579
690
|
|
|
580
691
|
export function resetBoard(db: Database): void {
|
|
692
|
+
db.run('DROP TABLE IF EXISTS comments')
|
|
581
693
|
db.run('DROP TABLE IF EXISTS column_time_tracking')
|
|
582
694
|
db.run('DROP TABLE IF EXISTS activity_log')
|
|
583
695
|
db.run('DROP TABLE IF EXISTS tasks')
|
package/src/errors.ts
CHANGED
|
@@ -2,12 +2,14 @@ export const ErrorCode = {
|
|
|
2
2
|
BOARD_NOT_INITIALIZED: 'BOARD_NOT_INITIALIZED',
|
|
3
3
|
BOARD_ALREADY_INITIALIZED: 'BOARD_ALREADY_INITIALIZED',
|
|
4
4
|
TASK_NOT_FOUND: 'TASK_NOT_FOUND',
|
|
5
|
+
COMMENT_NOT_FOUND: 'COMMENT_NOT_FOUND',
|
|
5
6
|
COLUMN_NOT_FOUND: 'COLUMN_NOT_FOUND',
|
|
6
7
|
COLUMN_NOT_EMPTY: 'COLUMN_NOT_EMPTY',
|
|
7
8
|
COLUMN_NAME_EXISTS: 'COLUMN_NAME_EXISTS',
|
|
8
9
|
INVALID_PRIORITY: 'INVALID_PRIORITY',
|
|
9
10
|
INVALID_METADATA: 'INVALID_METADATA',
|
|
10
11
|
INVALID_POSITION: 'INVALID_POSITION',
|
|
12
|
+
CONFLICT: 'CONFLICT',
|
|
11
13
|
MISSING_ARGUMENT: 'MISSING_ARGUMENT',
|
|
12
14
|
UNKNOWN_COMMAND: 'UNKNOWN_COMMAND',
|
|
13
15
|
UNSUPPORTED_OPERATION: 'UNSUPPORTED_OPERATION',
|
package/src/id.ts
CHANGED