@andypai/agent-kanban 0.6.2 → 0.6.3
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 +1 -1
- package/src/__tests__/api.test.ts +48 -0
- package/src/__tests__/conflict.test.ts +25 -1
- package/src/__tests__/linear-provider-sync.test.ts +517 -40
- package/src/__tests__/mcp-core.test.ts +54 -0
- package/src/__tests__/mcp-server.test.ts +17 -0
- package/src/__tests__/metrics-spec.test.ts +121 -0
- package/src/__tests__/postgres-linear-provider.test.ts +10 -2
- package/src/__tests__/postgres-local-provider.test.ts +140 -0
- package/src/__tests__/tracker-config-columns.test.ts +23 -0
- package/src/__tests__/transport-input.test.ts +35 -0
- package/src/api.ts +35 -31
- package/src/commands/mcp.ts +2 -1
- package/src/db.ts +1 -1
- package/src/errors.ts +2 -0
- package/src/index.ts +6 -1
- package/src/mcp/core.ts +24 -2
- package/src/mcp/server.ts +2 -1
- package/src/mcp/types.ts +12 -1
- package/src/metrics-spec.ts +87 -0
- package/src/metrics.ts +29 -60
- package/src/providers/capabilities.ts +12 -0
- package/src/providers/linear-client.ts +172 -95
- package/src/providers/linear-core.ts +89 -59
- package/src/providers/postgres-local.ts +136 -89
- package/src/tracker-config.ts +17 -1
- package/src/transport-input.ts +30 -0
- package/src/version.ts +8 -0
- package/ui/dist/assets/index-Cigv8a9S.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-65LcV0R7.js +0 -40
|
@@ -5,6 +5,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
|
|
|
5
5
|
import { addTask, initSchema, seedDefaultColumns } from '../db'
|
|
6
6
|
import { createTrackerCore, createTrackerMcpServer, TrackerMcpError } from '../mcp/index'
|
|
7
7
|
import { LocalProvider } from '../providers/local'
|
|
8
|
+
import { VERSION } from '../version'
|
|
8
9
|
|
|
9
10
|
interface TestScope {
|
|
10
11
|
actor: string
|
|
@@ -130,6 +131,22 @@ describe('createTrackerMcpServer', () => {
|
|
|
130
131
|
}
|
|
131
132
|
})
|
|
132
133
|
|
|
134
|
+
test('advertises the package version rather than a hard-coded one', async () => {
|
|
135
|
+
const runtime = startTrackerServer()
|
|
136
|
+
const transport = new StreamableHTTPClientTransport(runtime.url, {
|
|
137
|
+
requestInit: { headers: { Authorization: 'Bearer good-token' } },
|
|
138
|
+
})
|
|
139
|
+
const client = new Client({ name: 'test-client', version: '1.0.0' })
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
await client.connect(transport)
|
|
143
|
+
expect(client.getServerVersion()?.version).toBe(VERSION)
|
|
144
|
+
} finally {
|
|
145
|
+
await client.close()
|
|
146
|
+
await runtime.close()
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
133
150
|
test('round-trips updateComment end-to-end, handing the existing comment to the policy', async () => {
|
|
134
151
|
const task = addTask(db, 'MCP task')
|
|
135
152
|
const created = await provider.comment(task.id, 'original body')
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { assembleBoardMetrics, classifyColumnRoles } from '../metrics-spec'
|
|
3
|
+
import type { ActivityEntry } from '../types'
|
|
4
|
+
|
|
5
|
+
const columnCounts = [
|
|
6
|
+
{ id: 'c1', name: 'Backlog', position: 0, count: 4 },
|
|
7
|
+
{ id: 'c2', name: 'In Progress', position: 1, count: 2 },
|
|
8
|
+
{ id: 'c3', name: 'Done', position: 2, count: 3 },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
describe('assembleBoardMetrics', () => {
|
|
12
|
+
test('normalizes priority order by severity regardless of input order', () => {
|
|
13
|
+
const metrics = assembleBoardMetrics({
|
|
14
|
+
columnCounts,
|
|
15
|
+
// Deliberately unsorted / alphabetical-ish to prove the spec re-orders.
|
|
16
|
+
priorityCounts: [
|
|
17
|
+
{ priority: 'low', count: 1 },
|
|
18
|
+
{ priority: 'urgent', count: 2 },
|
|
19
|
+
{ priority: 'medium', count: 3 },
|
|
20
|
+
{ priority: 'high', count: 4 },
|
|
21
|
+
],
|
|
22
|
+
totalTasks: 9,
|
|
23
|
+
tasksCreatedThisWeek: 5,
|
|
24
|
+
avgCompletionHours: 12,
|
|
25
|
+
recentActivity: [],
|
|
26
|
+
assignees: ['amy'],
|
|
27
|
+
projects: ['Dispatch'],
|
|
28
|
+
})
|
|
29
|
+
expect(metrics.tasksByPriority.map((row) => row.priority)).toEqual([
|
|
30
|
+
'urgent',
|
|
31
|
+
'high',
|
|
32
|
+
'medium',
|
|
33
|
+
'low',
|
|
34
|
+
])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('derives completed / in-progress counts and completion percent from column roles', () => {
|
|
38
|
+
const metrics = assembleBoardMetrics({
|
|
39
|
+
columnCounts,
|
|
40
|
+
priorityCounts: [],
|
|
41
|
+
totalTasks: 9,
|
|
42
|
+
tasksCreatedThisWeek: 0,
|
|
43
|
+
avgCompletionHours: null,
|
|
44
|
+
recentActivity: [],
|
|
45
|
+
assignees: [],
|
|
46
|
+
projects: [],
|
|
47
|
+
})
|
|
48
|
+
expect(metrics.completedTasks).toBe(3) // Done column
|
|
49
|
+
expect(metrics.inProgressCount).toBe(2) // In Progress column
|
|
50
|
+
expect(metrics.completionPercent).toBe(33) // round(3/9 * 100)
|
|
51
|
+
expect(metrics.tasksByColumn).toEqual([
|
|
52
|
+
{ column_name: 'Backlog', count: 4 },
|
|
53
|
+
{ column_name: 'In Progress', count: 2 },
|
|
54
|
+
{ column_name: 'Done', count: 3 },
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('completion percent is zero when there are no tasks', () => {
|
|
59
|
+
const metrics = assembleBoardMetrics({
|
|
60
|
+
columnCounts: [{ id: 'c1', name: 'Backlog', position: 0, count: 0 }],
|
|
61
|
+
priorityCounts: [],
|
|
62
|
+
totalTasks: 0,
|
|
63
|
+
tasksCreatedThisWeek: 0,
|
|
64
|
+
avgCompletionHours: null,
|
|
65
|
+
recentActivity: [],
|
|
66
|
+
assignees: [],
|
|
67
|
+
projects: [],
|
|
68
|
+
})
|
|
69
|
+
expect(metrics.completionPercent).toBe(0)
|
|
70
|
+
expect(metrics.completedTasks).toBe(0)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('passes through avg hours, activity, assignees, and projects unchanged', () => {
|
|
74
|
+
const activity: ActivityEntry[] = [
|
|
75
|
+
{
|
|
76
|
+
id: 'a1',
|
|
77
|
+
task_id: 't1',
|
|
78
|
+
action: 'created',
|
|
79
|
+
field_changed: null,
|
|
80
|
+
old_value: null,
|
|
81
|
+
new_value: 'Task',
|
|
82
|
+
timestamp: '2026-01-01T00:00:00Z',
|
|
83
|
+
},
|
|
84
|
+
]
|
|
85
|
+
const metrics = assembleBoardMetrics({
|
|
86
|
+
columnCounts,
|
|
87
|
+
priorityCounts: [],
|
|
88
|
+
totalTasks: 9,
|
|
89
|
+
tasksCreatedThisWeek: 5,
|
|
90
|
+
avgCompletionHours: 7.5,
|
|
91
|
+
recentActivity: activity,
|
|
92
|
+
assignees: ['amy', 'bob'],
|
|
93
|
+
projects: ['Dispatch'],
|
|
94
|
+
})
|
|
95
|
+
expect(metrics.avgCompletionHours).toBe(7.5)
|
|
96
|
+
expect(metrics.recentActivity).toBe(activity)
|
|
97
|
+
expect(metrics.tasksCreatedThisWeek).toBe(5)
|
|
98
|
+
expect(metrics.assignees).toEqual(['amy', 'bob'])
|
|
99
|
+
expect(metrics.projects).toEqual(['Dispatch'])
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('classifyColumnRoles', () => {
|
|
104
|
+
test('recognizes done/in-progress synonyms and falls back to the terminal column', () => {
|
|
105
|
+
const roles = classifyColumnRoles([
|
|
106
|
+
{ id: 'c1', name: 'Backlog', position: 0 },
|
|
107
|
+
{ id: 'c2', name: 'WIP', position: 1 },
|
|
108
|
+
{ id: 'c3', name: 'Shipped', position: 2 },
|
|
109
|
+
])
|
|
110
|
+
expect(roles.doneColumnIds).toEqual(['c3'])
|
|
111
|
+
expect(roles.inProgressColumnIds).toEqual(['c2'])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('falls back to the last column when no name reads as done', () => {
|
|
115
|
+
const roles = classifyColumnRoles([
|
|
116
|
+
{ id: 'c1', name: 'Backlog', position: 0 },
|
|
117
|
+
{ id: 'c2', name: 'Later', position: 1 },
|
|
118
|
+
])
|
|
119
|
+
expect(roles.doneColumnIds).toEqual(['c2'])
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -106,10 +106,14 @@ function linearFetchStub(calls: LinearStubCall[] = []): typeof fetch {
|
|
|
106
106
|
return Response.json({ data: { team } })
|
|
107
107
|
}
|
|
108
108
|
if (query.includes('query Users')) {
|
|
109
|
-
return Response.json({
|
|
109
|
+
return Response.json({
|
|
110
|
+
data: { users: { ...users, pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
111
|
+
})
|
|
110
112
|
}
|
|
111
113
|
if (query.includes('query Projects')) {
|
|
112
|
-
return Response.json({
|
|
114
|
+
return Response.json({
|
|
115
|
+
data: { projects: { ...projects, pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
116
|
+
})
|
|
113
117
|
}
|
|
114
118
|
if (query.includes('query IssueLabels')) {
|
|
115
119
|
return Response.json({
|
|
@@ -121,6 +125,10 @@ function linearFetchStub(calls: LinearStubCall[] = []): typeof fetch {
|
|
|
121
125
|
},
|
|
122
126
|
})
|
|
123
127
|
}
|
|
128
|
+
if (query.includes('query IssueById')) {
|
|
129
|
+
const issue = issues.find((candidate) => candidate.id === variables.id) ?? null
|
|
130
|
+
return Response.json({ data: { issue } })
|
|
131
|
+
}
|
|
124
132
|
if (query.includes('query Issues')) {
|
|
125
133
|
return Response.json({
|
|
126
134
|
data: {
|
|
@@ -113,6 +113,146 @@ describe('postgres local provider', () => {
|
|
|
113
113
|
expect(comments[0]!.body).toBe('Projection comment stored in Postgres')
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
+
pgTest('logs activity for project/description/metadata updates and comment writes', async () => {
|
|
117
|
+
const runtime = await openKanbanRuntime()
|
|
118
|
+
try {
|
|
119
|
+
const provider = runtime.provider
|
|
120
|
+
const created = await provider.createTask({ title: 'Activity task' })
|
|
121
|
+
await provider.updateTask(created.id, {
|
|
122
|
+
project: 'Dispatch',
|
|
123
|
+
description: 'updated description',
|
|
124
|
+
metadata: '{"k":"v"}',
|
|
125
|
+
})
|
|
126
|
+
const comment = await provider.comment(created.id, 'first comment')
|
|
127
|
+
await provider.updateComment(created.id, comment.id, 'edited comment')
|
|
128
|
+
|
|
129
|
+
const activity = await provider.getActivity(50, created.id)
|
|
130
|
+
const fields = activity.map((entry) => entry.field_changed)
|
|
131
|
+
expect(fields).toContain('project')
|
|
132
|
+
expect(fields).toContain('description')
|
|
133
|
+
expect(fields).toContain('metadata')
|
|
134
|
+
|
|
135
|
+
const commentEntries = activity.filter((entry) => entry.field_changed === 'comment')
|
|
136
|
+
expect(commentEntries).toHaveLength(2)
|
|
137
|
+
expect(commentEntries.some((entry) => entry.new_value === 'first comment')).toBe(true)
|
|
138
|
+
expect(commentEntries.some((entry) => entry.new_value === 'edited comment')).toBe(true)
|
|
139
|
+
} finally {
|
|
140
|
+
await runtime.close()
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
pgTest('listTasks pushes filter, sort, and limit down to SQL', async () => {
|
|
145
|
+
const runtime = await openKanbanRuntime()
|
|
146
|
+
try {
|
|
147
|
+
const provider = runtime.provider
|
|
148
|
+
const alpha = await provider.createTask({
|
|
149
|
+
title: 'Alpha',
|
|
150
|
+
priority: 'low',
|
|
151
|
+
assignee: 'amy',
|
|
152
|
+
project: 'Dispatch',
|
|
153
|
+
})
|
|
154
|
+
await provider.createTask({
|
|
155
|
+
title: 'Beta',
|
|
156
|
+
priority: 'high',
|
|
157
|
+
assignee: 'amy',
|
|
158
|
+
project: 'Dispatch',
|
|
159
|
+
})
|
|
160
|
+
await provider.createTask({
|
|
161
|
+
title: 'Gamma',
|
|
162
|
+
priority: 'high',
|
|
163
|
+
assignee: 'bob',
|
|
164
|
+
project: 'Other',
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// Filter pushdown: only amy's Dispatch tasks come back.
|
|
168
|
+
const byAssignee = await provider.listTasks({ assignee: 'amy', project: 'Dispatch' })
|
|
169
|
+
expect(byAssignee.map((task) => task.title).sort()).toEqual(['Alpha', 'Beta'])
|
|
170
|
+
|
|
171
|
+
// Filter + priority pushdown.
|
|
172
|
+
const highPriority = await provider.listTasks({ priority: 'high' })
|
|
173
|
+
expect(highPriority.map((task) => task.title).sort()).toEqual(['Beta', 'Gamma'])
|
|
174
|
+
|
|
175
|
+
// Sort + limit pushdown: title order, capped at one row.
|
|
176
|
+
const firstByTitle = await provider.listTasks({ sort: 'title', limit: 1 })
|
|
177
|
+
expect(firstByTitle).toHaveLength(1)
|
|
178
|
+
expect(firstByTitle[0]?.title).toBe('Alpha')
|
|
179
|
+
|
|
180
|
+
// Comment counts are scoped per returned row.
|
|
181
|
+
await provider.comment(alpha.id, 'a note')
|
|
182
|
+
const withCounts = await provider.listTasks({ assignee: 'amy', project: 'Dispatch' })
|
|
183
|
+
const alphaRow = withCounts.find((task) => task.id === alpha.id)
|
|
184
|
+
const betaRow = withCounts.find((task) => task.title === 'Beta')
|
|
185
|
+
expect(alphaRow?.comment_count).toBe(1)
|
|
186
|
+
expect(betaRow?.comment_count).toBe(0)
|
|
187
|
+
} finally {
|
|
188
|
+
await runtime.close()
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
pgTest('reports configEdit:false and refuses config edits', async () => {
|
|
193
|
+
const runtime = await openKanbanRuntime()
|
|
194
|
+
try {
|
|
195
|
+
const context = await runtime.provider.getContext()
|
|
196
|
+
expect(context.capabilities.configEdit).toBe(false)
|
|
197
|
+
// Board administration has no Postgres provider path.
|
|
198
|
+
expect(context.capabilities.columnCrud).toBe(false)
|
|
199
|
+
expect(context.capabilities.bulk).toBe(false)
|
|
200
|
+
|
|
201
|
+
await expect(runtime.provider.patchConfig({ projects: ['Anything'] })).rejects.toMatchObject({
|
|
202
|
+
code: 'UNSUPPORTED_OPERATION',
|
|
203
|
+
})
|
|
204
|
+
} finally {
|
|
205
|
+
await runtime.close()
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
pgTest('migrates a pre-existing tasks table missing project/labels/revision', async () => {
|
|
210
|
+
// Simulate an older Postgres-local database created before project, labels,
|
|
211
|
+
// and revision columns existed. CREATE TABLE IF NOT EXISTS would not add
|
|
212
|
+
// them, so the provider's migration must backfill before any insert.
|
|
213
|
+
await sql!`
|
|
214
|
+
CREATE TABLE columns (
|
|
215
|
+
id TEXT PRIMARY KEY,
|
|
216
|
+
name TEXT UNIQUE NOT NULL,
|
|
217
|
+
position INTEGER NOT NULL,
|
|
218
|
+
color TEXT,
|
|
219
|
+
created_at TEXT NOT NULL,
|
|
220
|
+
updated_at TEXT NOT NULL
|
|
221
|
+
)
|
|
222
|
+
`
|
|
223
|
+
await sql!`
|
|
224
|
+
CREATE TABLE tasks (
|
|
225
|
+
id TEXT PRIMARY KEY,
|
|
226
|
+
title TEXT NOT NULL,
|
|
227
|
+
description TEXT NOT NULL DEFAULT '',
|
|
228
|
+
column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE RESTRICT,
|
|
229
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
230
|
+
priority TEXT NOT NULL DEFAULT 'medium',
|
|
231
|
+
assignee TEXT NOT NULL DEFAULT '',
|
|
232
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
233
|
+
created_at TEXT NOT NULL,
|
|
234
|
+
updated_at TEXT NOT NULL
|
|
235
|
+
)
|
|
236
|
+
`
|
|
237
|
+
|
|
238
|
+
// First provider use runs ensureSchema/migrateTasksTable and must succeed.
|
|
239
|
+
const created = expectOk<TaskWithColumn>(
|
|
240
|
+
await run(['task', 'add', 'After migration', '--project', 'Dispatch', '--label', 'smoke']),
|
|
241
|
+
)
|
|
242
|
+
expect(created.title).toBe('After migration')
|
|
243
|
+
expect(created.project).toBe('Dispatch')
|
|
244
|
+
expect(created.labels).toEqual(['smoke'])
|
|
245
|
+
expect(created.version).toBe('0')
|
|
246
|
+
|
|
247
|
+
const columnRows = await sql!<{ column_name: string }[]>`
|
|
248
|
+
SELECT column_name FROM information_schema.columns WHERE table_name = 'tasks'
|
|
249
|
+
`
|
|
250
|
+
const columnNames = columnRows.map((row) => row.column_name)
|
|
251
|
+
expect(columnNames).toContain('project')
|
|
252
|
+
expect(columnNames).toContain('labels')
|
|
253
|
+
expect(columnNames).toContain('revision')
|
|
254
|
+
})
|
|
255
|
+
|
|
116
256
|
pgTest('seeds custom default columns for Garage local compose', async () => {
|
|
117
257
|
process.env['KANBAN_DEFAULT_COLUMNS'] = 'Todo,In Progress,Human Review,Merging,Done'
|
|
118
258
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { trackerConfigFromEnv } from '../tracker-config'
|
|
3
|
+
import { KanbanError } from '../errors'
|
|
4
|
+
|
|
5
|
+
describe('trackerConfigFromEnv default columns', () => {
|
|
6
|
+
test('parses comma-separated default columns for the local provider', () => {
|
|
7
|
+
const config = trackerConfigFromEnv({ KANBAN_DEFAULT_COLUMNS: 'Todo, Doing , Done' })
|
|
8
|
+
expect(config).toMatchObject({ provider: 'local', defaultColumns: ['Todo', 'Doing', 'Done'] })
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('rejects case-insensitive duplicate default columns', () => {
|
|
12
|
+
expect(() => trackerConfigFromEnv({ KANBAN_DEFAULT_COLUMNS: 'Done,done' })).toThrow(KanbanError)
|
|
13
|
+
expect(() => trackerConfigFromEnv({ KANBAN_DEFAULT_COLUMNS: 'Done,done' })).toThrow(
|
|
14
|
+
/duplicate column name/i,
|
|
15
|
+
)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('leaves default columns unset when the variable is absent', () => {
|
|
19
|
+
const config = trackerConfigFromEnv({})
|
|
20
|
+
expect(config).toMatchObject({ provider: 'local' })
|
|
21
|
+
expect((config as { defaultColumns?: string[] }).defaultColumns).toBeUndefined()
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { parsePositiveInt } from '../transport-input'
|
|
3
|
+
|
|
4
|
+
describe('parsePositiveInt', () => {
|
|
5
|
+
test('returns undefined for absent values', () => {
|
|
6
|
+
expect(parsePositiveInt(null)).toBeUndefined()
|
|
7
|
+
expect(parsePositiveInt(undefined)).toBeUndefined()
|
|
8
|
+
expect(parsePositiveInt('')).toBeUndefined()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('accepts positive integers', () => {
|
|
12
|
+
expect(parsePositiveInt('1')).toBe(1)
|
|
13
|
+
expect(parsePositiveInt('100')).toBe(100)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test.each([
|
|
17
|
+
'0',
|
|
18
|
+
'-5',
|
|
19
|
+
'3.9',
|
|
20
|
+
'abc',
|
|
21
|
+
'5abc',
|
|
22
|
+
'NaN',
|
|
23
|
+
'Infinity',
|
|
24
|
+
'1e100',
|
|
25
|
+
'1e3',
|
|
26
|
+
'0x10',
|
|
27
|
+
'1000000000000000000000000',
|
|
28
|
+
])('rejects invalid value %p', (value) => {
|
|
29
|
+
expect(() => parsePositiveInt(value)).toThrow(/positive integer/)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('uses the provided field name in the message', () => {
|
|
33
|
+
expect(() => parsePositiveInt('-1', 'count')).toThrow(/count must be a positive integer/)
|
|
34
|
+
})
|
|
35
|
+
})
|
package/src/api.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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 { parsePositiveInt } from './transport-input'
|
|
4
5
|
import * as useCases from './use-cases'
|
|
5
6
|
|
|
6
7
|
export type WsEvent =
|
|
@@ -21,15 +22,21 @@ function json(data: unknown, status = 200): Response {
|
|
|
21
22
|
return Response.json(data, { status })
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
function
|
|
25
|
-
|
|
25
|
+
function requireArgument(value: unknown, field: string): void {
|
|
26
|
+
if (!value) {
|
|
27
|
+
throw new KanbanError(ErrorCode.MISSING_ARGUMENT, `${field} is required`)
|
|
28
|
+
}
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// Parse a JSON request body inside a wrapHandler scope so malformed/empty/
|
|
32
|
+
// wrong-content-type bodies surface through the same { ok:false, error }
|
|
33
|
+
// envelope as every other validation failure instead of escaping as a raw 500.
|
|
34
|
+
async function parseJsonBody<T>(req: Request): Promise<T> {
|
|
35
|
+
try {
|
|
36
|
+
return (await req.json()) as T
|
|
37
|
+
} catch {
|
|
38
|
+
throw new KanbanError(ErrorCode.INVALID_REQUEST_BODY, 'Request body must be valid JSON')
|
|
39
|
+
}
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
function statusForCode(code: string): number {
|
|
@@ -131,7 +138,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
131
138
|
const assignee = url.searchParams.get('assignee') ?? undefined
|
|
132
139
|
const project = url.searchParams.get('project') ?? undefined
|
|
133
140
|
const sort = url.searchParams.get('sort') ?? undefined
|
|
134
|
-
const limit =
|
|
141
|
+
const limit = parsePositiveInt(url.searchParams.get('limit'))
|
|
135
142
|
return {
|
|
136
143
|
ok: true,
|
|
137
144
|
data: await useCases.listTasks(provider, {
|
|
@@ -149,10 +156,10 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
if (path === '/api/tasks' && method === 'POST') {
|
|
152
|
-
const body = (await req.json()) as Partial<CreateTaskInput>
|
|
153
|
-
if (!body.title) return { response: missingArgument('title'), mutated: false }
|
|
154
159
|
let created: Task | null = null
|
|
155
160
|
const response = await wrapHandler(async () => {
|
|
161
|
+
const body = await parseJsonBody<Partial<CreateTaskInput>>(req)
|
|
162
|
+
requireArgument(body.title, 'title')
|
|
156
163
|
created = await useCases.createTask(provider, {
|
|
157
164
|
title: body.title!,
|
|
158
165
|
description: body.description,
|
|
@@ -184,9 +191,9 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
184
191
|
}
|
|
185
192
|
|
|
186
193
|
if (method === 'PATCH') {
|
|
187
|
-
const body = (await req.json()) as UpdateTaskInput
|
|
188
194
|
let updated: Task | null = null
|
|
189
195
|
const response = await wrapHandler(async () => {
|
|
196
|
+
const body = await parseJsonBody<UpdateTaskInput>(req)
|
|
190
197
|
updated = await useCases.updateTask(provider, id, body)
|
|
191
198
|
return { ok: true, data: updated }
|
|
192
199
|
})
|
|
@@ -207,10 +214,10 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
207
214
|
const moveMatch = path.match(/^\/api\/tasks\/([^/]+)\/move$/)
|
|
208
215
|
if (moveMatch && method === 'PATCH') {
|
|
209
216
|
const id = decodeURIComponent(moveMatch[1]!)
|
|
210
|
-
const body = (await req.json()) as MoveTaskBody
|
|
211
|
-
if (!body.column) return { response: missingArgument('column'), mutated: false }
|
|
212
217
|
let moved: Task | null = null
|
|
213
218
|
const response = await wrapHandler(async () => {
|
|
219
|
+
const body = await parseJsonBody<MoveTaskBody>(req)
|
|
220
|
+
requireArgument(body.column, 'column')
|
|
214
221
|
moved = await useCases.moveTask(provider, id, body.column!)
|
|
215
222
|
return { ok: true, data: moved }
|
|
216
223
|
})
|
|
@@ -232,12 +239,11 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
232
239
|
}
|
|
233
240
|
|
|
234
241
|
if (method === 'POST') {
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
ok: true,
|
|
239
|
-
|
|
240
|
-
}))
|
|
242
|
+
const response = await wrapHandler(async () => {
|
|
243
|
+
const body = await parseJsonBody<CommentBody>(req)
|
|
244
|
+
requireArgument(body.body, 'body')
|
|
245
|
+
return { ok: true, data: await useCases.addComment(provider, id, body.body!) }
|
|
246
|
+
})
|
|
241
247
|
return { response, mutated: response.ok }
|
|
242
248
|
}
|
|
243
249
|
}
|
|
@@ -248,12 +254,11 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
248
254
|
const commentId = decodeURIComponent(commentMatch[2]!)
|
|
249
255
|
|
|
250
256
|
if (method === 'PATCH') {
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
ok: true,
|
|
255
|
-
|
|
256
|
-
}))
|
|
257
|
+
const response = await wrapHandler(async () => {
|
|
258
|
+
const body = await parseJsonBody<CommentBody>(req)
|
|
259
|
+
requireArgument(body.body, 'body')
|
|
260
|
+
return { ok: true, data: await useCases.updateComment(provider, id, commentId, body.body!) }
|
|
261
|
+
})
|
|
257
262
|
return { response, mutated: response.ok }
|
|
258
263
|
}
|
|
259
264
|
}
|
|
@@ -262,7 +267,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
262
267
|
return {
|
|
263
268
|
response: await wrapHandler(async () => {
|
|
264
269
|
const taskId = url.searchParams.get('taskId') ?? undefined
|
|
265
|
-
const limit =
|
|
270
|
+
const limit = parsePositiveInt(url.searchParams.get('limit'))
|
|
266
271
|
return { ok: true, data: await useCases.getActivity(provider, limit, taskId) }
|
|
267
272
|
}),
|
|
268
273
|
mutated: false,
|
|
@@ -290,11 +295,10 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
|
|
|
290
295
|
}
|
|
291
296
|
|
|
292
297
|
if (path === '/api/config' && method === 'PATCH') {
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
ok: true,
|
|
296
|
-
|
|
297
|
-
}))
|
|
298
|
+
const response = await wrapHandler(async () => {
|
|
299
|
+
const body = await parseJsonBody<Partial<BoardConfig>>(req)
|
|
300
|
+
return { ok: true, data: await useCases.patchConfig(provider, body) }
|
|
301
|
+
})
|
|
298
302
|
return { response, mutated: response.ok }
|
|
299
303
|
}
|
|
300
304
|
|
package/src/commands/mcp.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv
|
|
|
9
9
|
import { createTrackerCore, defaultTools, TrackerMcpError } from '../mcp/index'
|
|
10
10
|
import type { TrackerMcpPolicy, TrackerMcpTool } from '../mcp/index'
|
|
11
11
|
import type { KanbanProvider } from '../providers/types'
|
|
12
|
+
import { VERSION } from '../version'
|
|
12
13
|
|
|
13
14
|
type LocalScope = Record<string, never>
|
|
14
15
|
|
|
@@ -38,7 +39,7 @@ export async function startStdioMcpServer(provider: KanbanProvider): Promise<voi
|
|
|
38
39
|
)
|
|
39
40
|
|
|
40
41
|
const server = new Server(
|
|
41
|
-
{ name: 'agent-kanban', version:
|
|
42
|
+
{ name: 'agent-kanban', version: VERSION },
|
|
42
43
|
{ capabilities: { tools: {} } },
|
|
43
44
|
)
|
|
44
45
|
|
package/src/db.ts
CHANGED
|
@@ -697,7 +697,7 @@ export function bulkMoveAll(
|
|
|
697
697
|
.all({ $col: fromCol.id }) as { id: string }[]
|
|
698
698
|
|
|
699
699
|
const stmt = db.prepare(
|
|
700
|
-
"UPDATE tasks SET column_id = $toCol, position = $pos, updated_at = datetime('now') WHERE id = $id",
|
|
700
|
+
"UPDATE tasks SET column_id = $toCol, position = $pos, updated_at = datetime('now'), revision = revision + 1 WHERE id = $id",
|
|
701
701
|
)
|
|
702
702
|
db.transaction(() => {
|
|
703
703
|
tasks.forEach(({ id }, i) => {
|
package/src/errors.ts
CHANGED
|
@@ -11,6 +11,8 @@ export const ErrorCode = {
|
|
|
11
11
|
INVALID_METADATA: 'INVALID_METADATA',
|
|
12
12
|
INVALID_POSITION: 'INVALID_POSITION',
|
|
13
13
|
INVALID_CONFIG: 'INVALID_CONFIG',
|
|
14
|
+
INVALID_REQUEST_BODY: 'INVALID_REQUEST_BODY',
|
|
15
|
+
INVALID_ARGUMENT: 'INVALID_ARGUMENT',
|
|
14
16
|
CONFLICT: 'CONFLICT',
|
|
15
17
|
MISSING_ARGUMENT: 'MISSING_ARGUMENT',
|
|
16
18
|
UNKNOWN_COMMAND: 'UNKNOWN_COMMAND',
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { boardInit, boardReset } from './commands/board'
|
|
|
9
9
|
import { columnAdd, columnDelete, columnList, columnRename, columnReorder } from './commands/column'
|
|
10
10
|
import { bulkClearDoneCmd, bulkMoveAllCmd } from './commands/bulk'
|
|
11
11
|
import { getConfigPath, loadConfig, saveConfig } from './config'
|
|
12
|
+
import { parsePositiveInt } from './transport-input'
|
|
12
13
|
import type { CliOutput, Priority } from './types'
|
|
13
14
|
import { unsupportedOperation } from './providers/errors'
|
|
14
15
|
import { openKanbanRuntime } from './provider-runtime'
|
|
@@ -83,7 +84,7 @@ async function routeTask(
|
|
|
83
84
|
priority: values.p as string | undefined,
|
|
84
85
|
assignee: values.a as string | undefined,
|
|
85
86
|
project: values.project as string | undefined,
|
|
86
|
-
limit:
|
|
87
|
+
limit: parsePositiveInt(values.l as string | undefined),
|
|
87
88
|
sort: values.sort as string | undefined,
|
|
88
89
|
}),
|
|
89
90
|
)
|
|
@@ -374,6 +375,10 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
|
|
|
374
375
|
output = routeBulk(sqliteDb, provider.type, action, positionals)
|
|
375
376
|
break
|
|
376
377
|
case 'config':
|
|
378
|
+
// routeConfig persists to the SQLite-side config file, so it only runs
|
|
379
|
+
// when a SQLite database is present. Postgres-local has no config
|
|
380
|
+
// repository (configEdit:false), so edits are refused here — matching the
|
|
381
|
+
// HTTP API, which fails through the provider's patchConfig.
|
|
377
382
|
if (sqliteDb) {
|
|
378
383
|
output = await routeConfig(provider, dbPath, action, positionals, values)
|
|
379
384
|
} else {
|
package/src/mcp/core.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { KanbanProvider } from '../providers/types'
|
|
2
|
-
import type { Task, TaskComment } from '../types'
|
|
2
|
+
import type { BoardView, Task, TaskComment } from '../types'
|
|
3
3
|
import * as useCases from '../use-cases'
|
|
4
4
|
import { TrackerMcpError, toTrackerMcpError } from './errors'
|
|
5
5
|
import type { TrackerMcpHooks, TrackerMcpPolicy } from './types'
|
|
@@ -52,6 +52,21 @@ async function filterComments<TScope>(
|
|
|
52
52
|
return comments.filter((_, index) => allowed[index])
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
async function filterBoard<TScope>(
|
|
56
|
+
scope: TScope,
|
|
57
|
+
board: BoardView,
|
|
58
|
+
policy: TrackerMcpPolicy<TScope>,
|
|
59
|
+
): Promise<BoardView> {
|
|
60
|
+
if (!policy.filterTask) return board
|
|
61
|
+
const columns = await Promise.all(
|
|
62
|
+
board.columns.map(async (column) => {
|
|
63
|
+
const allowed = await Promise.all(column.tasks.map((task) => policy.filterTask!(scope, task)))
|
|
64
|
+
return { ...column, tasks: column.tasks.filter((_, index) => allowed[index]) }
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
return { ...board, columns }
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
export function createTrackerCore<TScope>(input: {
|
|
56
71
|
provider: KanbanProvider
|
|
57
72
|
policy: TrackerMcpPolicy<TScope>
|
|
@@ -146,7 +161,14 @@ export function createTrackerCore<TScope>(input: {
|
|
|
146
161
|
return runTool({
|
|
147
162
|
scope,
|
|
148
163
|
tool: 'getBoard',
|
|
149
|
-
execute: async () =>
|
|
164
|
+
execute: async () => {
|
|
165
|
+
await policy.canReadBoard?.(scope)
|
|
166
|
+
const board = await useCases.getBoard(provider)
|
|
167
|
+
return filterBoard(scope, board, policy)
|
|
168
|
+
},
|
|
169
|
+
resultMeta: (board) => ({
|
|
170
|
+
taskCount: board.columns.reduce((total, column) => total + column.tasks.length, 0),
|
|
171
|
+
}),
|
|
150
172
|
})
|
|
151
173
|
},
|
|
152
174
|
|
package/src/mcp/server.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
import type { TrackerCore } from './core'
|
|
17
17
|
import { TrackerMcpError, toMcpError, toTrackerMcpError, trackerMcpJsonRpcCode } from './errors'
|
|
18
18
|
import type { TrackerMcpAuthResolver, TrackerMcpServer, TrackerMcpTool } from './types'
|
|
19
|
+
import { VERSION } from '../version'
|
|
19
20
|
|
|
20
21
|
const EMPTY_OBJECT_SCHEMA = {
|
|
21
22
|
type: 'object',
|
|
@@ -169,7 +170,7 @@ function createSessionServer<TScope>(
|
|
|
169
170
|
entry: SessionEntry<TScope>,
|
|
170
171
|
): Server {
|
|
171
172
|
const server = new Server(
|
|
172
|
-
{ name: 'agent-kanban-tracker-mcp', version:
|
|
173
|
+
{ name: 'agent-kanban-tracker-mcp', version: VERSION },
|
|
173
174
|
{ capabilities: { tools: {} } },
|
|
174
175
|
)
|
|
175
176
|
|