@andypai/agent-kanban 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -22
- package/package.json +4 -2
- package/src/__tests__/activity.test.ts +15 -9
- package/src/__tests__/api.test.ts +96 -0
- package/src/__tests__/board-utils.test.ts +100 -0
- package/src/__tests__/commands/board.test.ts +6 -13
- package/src/__tests__/conflict.test.ts +64 -0
- package/src/__tests__/index.test.ts +233 -56
- package/src/__tests__/jira-adf.test.ts +168 -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 +493 -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__/server.test.ts +298 -0
- package/src/__tests__/webhooks.test.ts +604 -0
- package/src/activity.ts +1 -11
- package/src/api.ts +154 -19
- package/src/commands/board.ts +1 -11
- package/src/commands/mcp.ts +87 -0
- package/src/db.ts +115 -3
- package/src/errors.ts +2 -0
- package/src/id.ts +1 -1
- package/src/index.ts +72 -18
- 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/providers/capabilities.ts +15 -0
- package/src/providers/index.ts +31 -1
- 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 +778 -0
- package/src/providers/linear-cache.ts +249 -70
- package/src/providers/linear-client.ts +256 -13
- package/src/providers/linear.ts +337 -14
- package/src/providers/local.ts +68 -17
- package/src/providers/types.ts +18 -2
- package/src/server.ts +139 -11
- package/src/tunnel.ts +79 -0
- package/src/types.ts +18 -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/README.md
CHANGED
|
@@ -6,13 +6,15 @@ Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structure
|
|
|
6
6
|
|
|
7
7
|
## Why
|
|
8
8
|
|
|
9
|
-
Most project-management tools are built for humans clicking through UIs. `agent-kanban` is built for **CLI-first workflows** — AI agents and scripts get deterministic JSON they can parse, humans get a pretty-printed view and a web dashboard. Runs against a local SQLite file or a
|
|
9
|
+
Most project-management tools are built for humans clicking through UIs. `agent-kanban` is built for **CLI-first workflows** — AI agents and scripts get deterministic JSON they can parse, humans get a pretty-printed view and a web dashboard. Runs against a local SQLite file, a Linear backend, or a Jira Cloud project.
|
|
10
10
|
|
|
11
11
|
## Documentation
|
|
12
12
|
|
|
13
|
-
- [`docs/
|
|
14
|
-
- [`docs/
|
|
13
|
+
- [`docs/readme.md`](docs/readme.md) for the documentation index
|
|
14
|
+
- [`docs/workflow.md`](docs/workflow.md) for a common day-to-day workflow
|
|
15
|
+
- [`docs/mcp.md`](docs/mcp.md) for the reusable tracker MCP module
|
|
15
16
|
- [`docs/providers/linear.md`](docs/providers/linear.md) for Linear provider details
|
|
17
|
+
- [`docs/providers/jira.md`](docs/providers/jira.md) for Jira provider details
|
|
16
18
|
- [`SKILL.md`](SKILL.md) for agent-specific repo usage instructions
|
|
17
19
|
|
|
18
20
|
## Install
|
|
@@ -50,12 +52,18 @@ Running `kanban` with no arguments is equivalent to `kanban board view`.
|
|
|
50
52
|
|
|
51
53
|
All operations route through a provider backend. Set `KANBAN_PROVIDER` to choose one.
|
|
52
54
|
|
|
53
|
-
| Variable
|
|
54
|
-
|
|
|
55
|
-
| `KANBAN_PROVIDER`
|
|
56
|
-
| `KANBAN_DB_PATH`
|
|
57
|
-
| `LINEAR_API_KEY`
|
|
58
|
-
| `LINEAR_TEAM_ID`
|
|
55
|
+
| Variable | Default | Description |
|
|
56
|
+
| ------------------ | ------------- | ------------------------------------------------------------------------ |
|
|
57
|
+
| `KANBAN_PROVIDER` | `local` | `local`, `linear`, or `jira` |
|
|
58
|
+
| `KANBAN_DB_PATH` | auto-resolved | SQLite database path |
|
|
59
|
+
| `LINEAR_API_KEY` | — | Required when `KANBAN_PROVIDER=linear` |
|
|
60
|
+
| `LINEAR_TEAM_ID` | — | Required when `KANBAN_PROVIDER=linear` |
|
|
61
|
+
| `JIRA_BASE_URL` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `https://acme.atlassian.net`) |
|
|
62
|
+
| `JIRA_EMAIL` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian account email) |
|
|
63
|
+
| `JIRA_API_TOKEN` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian API token) |
|
|
64
|
+
| `JIRA_PROJECT_KEY` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `ENG`) |
|
|
65
|
+
| `JIRA_BOARD_ID` | — | Optional when `KANBAN_PROVIDER=jira` (Agile board id for column order) |
|
|
66
|
+
| `JIRA_ISSUE_TYPE` | `Task` | Optional when `KANBAN_PROVIDER=jira` (default issue type for new tasks) |
|
|
59
67
|
|
|
60
68
|
Without `KANBAN_DB_PATH`, the local provider resolves the database in this order:
|
|
61
69
|
|
|
@@ -72,22 +80,43 @@ export LINEAR_TEAM_ID=<team-id>
|
|
|
72
80
|
kanban board view
|
|
73
81
|
```
|
|
74
82
|
|
|
83
|
+
### Jira quick start
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
export KANBAN_PROVIDER=jira
|
|
87
|
+
export JIRA_BASE_URL=https://your-domain.atlassian.net
|
|
88
|
+
export JIRA_EMAIL=you@example.com
|
|
89
|
+
export JIRA_API_TOKEN=...
|
|
90
|
+
export JIRA_PROJECT_KEY=ENG
|
|
91
|
+
export JIRA_BOARD_ID=123 # optional
|
|
92
|
+
kanban board view
|
|
93
|
+
```
|
|
94
|
+
|
|
75
95
|
### Capability matrix
|
|
76
96
|
|
|
77
|
-
| Capability
|
|
78
|
-
|
|
|
79
|
-
| task create/update/move
|
|
80
|
-
| task delete
|
|
81
|
-
|
|
|
82
|
-
|
|
|
83
|
-
|
|
|
84
|
-
|
|
|
85
|
-
|
|
|
97
|
+
| Capability | Local | Linear | Jira |
|
|
98
|
+
| -------------------------- | ----- | ------ | ---- |
|
|
99
|
+
| task create/update/move | yes | yes | yes |
|
|
100
|
+
| task delete | yes | no | no |
|
|
101
|
+
| comment read/create/update | yes | yes | yes |
|
|
102
|
+
| activity log | yes | no | no |
|
|
103
|
+
| metrics | yes | no | no |
|
|
104
|
+
| column CRUD | yes | no | no |
|
|
105
|
+
| bulk operations | yes | no | no |
|
|
106
|
+
| config edit | yes | no | no |
|
|
107
|
+
| webhooks | no | yes | yes |
|
|
86
108
|
|
|
87
109
|
Linear tasks carry an `externalRef` (e.g. `TEAM-123`) and a `url`. Commands accept either the internal ID or the external ref.
|
|
88
110
|
|
|
89
111
|
Unsupported operations return error code `UNSUPPORTED_OPERATION` with exit code 1.
|
|
90
112
|
|
|
113
|
+
Task comments are currently exposed through the REST API and dashboard task
|
|
114
|
+
detail flows rather than dedicated `kanban comment ...` CLI commands.
|
|
115
|
+
|
|
116
|
+
In Linear and Jira modes, webhooks update the cache immediately when configured,
|
|
117
|
+
and the normal poll loop still runs as a fallback so missed deliveries and
|
|
118
|
+
remote deletions are eventually reconciled.
|
|
119
|
+
|
|
91
120
|
## Commands
|
|
92
121
|
|
|
93
122
|
### board
|
|
@@ -191,6 +220,7 @@ Default columns: `recurring`, `backlog`, `in-progress`, `review`, `done`.
|
|
|
191
220
|
```bash
|
|
192
221
|
kanban serve # default port 3000
|
|
193
222
|
kanban serve --port 8080
|
|
223
|
+
kanban serve --tunnel # optional public URL for webhook testing
|
|
194
224
|
```
|
|
195
225
|
|
|
196
226
|
## Global flags
|
|
@@ -228,10 +258,31 @@ In Linear mode the dashboard hides unsupported actions and shows Linear issue id
|
|
|
228
258
|
|
|
229
259
|
Starts a Bun HTTP server with:
|
|
230
260
|
|
|
231
|
-
- **REST API** at `/api/*` —
|
|
232
|
-
- **WebSocket** at `/ws` — push notifications on board mutations (clients receive `
|
|
261
|
+
- **REST API** at `/api/*` — board, tasks, task comments, bootstrap/provider metadata, activity, metrics, config, and webhook endpoints
|
|
262
|
+
- **WebSocket** at `/ws` — push notifications on board mutations (clients receive `task:upsert`, `task:delete`, or a fallback `refresh` event)
|
|
233
263
|
- **Static UI** served from `ui/dist/` (build with `bun run build:ui` or `bun run ui:build`)
|
|
234
|
-
- **Health check** at `/api/health`
|
|
264
|
+
- **Health check** at `/api/health` — cheap process liveness only
|
|
265
|
+
- **Readiness check** at `/api/ready` — reports whether the cache has warmed at least once
|
|
266
|
+
- **Sync status** at `/api/sync-status` — reports background sync state plus provider sync metadata
|
|
267
|
+
|
|
268
|
+
In `serve` mode, remote providers now warm once on startup and continue syncing
|
|
269
|
+
in the background every 30 seconds. Full reconciliation is still handled by the
|
|
270
|
+
provider-specific logic on top of that steady cadence.
|
|
271
|
+
|
|
272
|
+
Comment routes:
|
|
273
|
+
|
|
274
|
+
- `GET /api/tasks/:id/comments`
|
|
275
|
+
- `POST /api/tasks/:id/comments`
|
|
276
|
+
- `PATCH /api/tasks/:id/comments/:commentId`
|
|
277
|
+
|
|
278
|
+
## Reusable MCP core
|
|
279
|
+
|
|
280
|
+
The repo also includes a reusable tracker MCP implementation under `src/mcp/`.
|
|
281
|
+
It is intended for sibling workspaces and in-repo consumers rather than the
|
|
282
|
+
`kanban` CLI itself.
|
|
283
|
+
|
|
284
|
+
See [`docs/mcp.md`](docs/mcp.md) for the current default tool set, the auth and
|
|
285
|
+
policy model, and the caveats around source-level imports and `kanban serve`.
|
|
235
286
|
|
|
236
287
|
## Scripts
|
|
237
288
|
|
|
@@ -287,6 +338,22 @@ docker run -d \
|
|
|
287
338
|
agent-kanban
|
|
288
339
|
```
|
|
289
340
|
|
|
341
|
+
### Jira mode
|
|
342
|
+
|
|
343
|
+
No volume needed — all state lives in Jira Cloud.
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
docker run -d \
|
|
347
|
+
-p 3000:3000 \
|
|
348
|
+
-e KANBAN_PROVIDER=jira \
|
|
349
|
+
-e JIRA_BASE_URL=https://your-domain.atlassian.net \
|
|
350
|
+
-e JIRA_EMAIL=you@example.com \
|
|
351
|
+
-e JIRA_API_TOKEN=... \
|
|
352
|
+
-e JIRA_PROJECT_KEY=ENG \
|
|
353
|
+
-e JIRA_BOARD_ID=123 \
|
|
354
|
+
agent-kanban
|
|
355
|
+
```
|
|
356
|
+
|
|
290
357
|
### Dokploy
|
|
291
358
|
|
|
292
359
|
Set the port via `PORT` env var (defaults to `3000`). Port resolution order: `--port` flag → `PORT` env → `3000`. Add provider env vars through Dokploy's environment configuration.
|
|
@@ -299,7 +366,7 @@ If you want to contribute or report an issue, start with these guides:
|
|
|
299
366
|
- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
|
|
300
367
|
- [SECURITY.md](SECURITY.md)
|
|
301
368
|
|
|
302
|
-
Longer product and workflow docs live under [`docs/`](docs/
|
|
369
|
+
Longer product and workflow docs live under [`docs/`](docs/readme.md).
|
|
303
370
|
|
|
304
371
|
## License
|
|
305
372
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andypai/agent-kanban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structured JSON output.",
|
|
5
5
|
"homepage": "https://github.com/abpai/agent-kanban#readme",
|
|
6
6
|
"repository": {
|
|
@@ -54,7 +54,9 @@
|
|
|
54
54
|
"prepare": "if [ \"${HUSKY:-1}\" != \"0\" ] && command -v husky >/dev/null 2>&1; then husky install; fi",
|
|
55
55
|
"prepack": "cd ui && bun install --frozen-lockfile && bun run build"
|
|
56
56
|
},
|
|
57
|
-
"dependencies": {
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
59
|
+
},
|
|
58
60
|
"devDependencies": {
|
|
59
61
|
"@eslint/js": "^9.39.1",
|
|
60
62
|
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
bulkMoveAll,
|
|
11
11
|
bulkClearDone,
|
|
12
12
|
} from '../db.ts'
|
|
13
|
-
import { listActivity
|
|
13
|
+
import { listActivity } from '../activity.ts'
|
|
14
14
|
|
|
15
15
|
let db: Database
|
|
16
16
|
|
|
@@ -21,10 +21,16 @@ beforeEach(() => {
|
|
|
21
21
|
seedDefaultColumns(db)
|
|
22
22
|
})
|
|
23
23
|
|
|
24
|
+
function getColumnTimeEntries(taskId: string): { exited_at: string | null }[] {
|
|
25
|
+
return db
|
|
26
|
+
.query('SELECT * FROM column_time_tracking WHERE task_id = $id ORDER BY entered_at')
|
|
27
|
+
.all({ $id: taskId }) as { exited_at: string | null }[]
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
describe('activity logging', () => {
|
|
25
31
|
test('addTask logs created activity', () => {
|
|
26
32
|
const task = addTask(db, 'Test task')
|
|
27
|
-
const activities =
|
|
33
|
+
const activities = listActivity(db, { taskId: task.id })
|
|
28
34
|
expect(activities).toHaveLength(1)
|
|
29
35
|
expect(activities[0]!.action).toBe('created')
|
|
30
36
|
expect(activities[0]!.new_value).toBe('Test task')
|
|
@@ -33,7 +39,7 @@ describe('activity logging', () => {
|
|
|
33
39
|
test('updateTask logs field changes', () => {
|
|
34
40
|
const task = addTask(db, 'Original')
|
|
35
41
|
updateTask(db, task.id, { title: 'Updated', priority: 'high' })
|
|
36
|
-
const activities =
|
|
42
|
+
const activities = listActivity(db, { taskId: task.id })
|
|
37
43
|
const actions = activities.map((a) => a.action)
|
|
38
44
|
expect(actions).toContain('updated')
|
|
39
45
|
expect(actions).toContain('prioritized')
|
|
@@ -42,7 +48,7 @@ describe('activity logging', () => {
|
|
|
42
48
|
test('updateTask logs assignee change as assigned', () => {
|
|
43
49
|
const task = addTask(db, 'Task')
|
|
44
50
|
updateTask(db, task.id, { assignee: 'alice' })
|
|
45
|
-
const activities =
|
|
51
|
+
const activities = listActivity(db, { taskId: task.id })
|
|
46
52
|
const assigned = activities.find((a) => a.action === 'assigned')
|
|
47
53
|
expect(assigned).toBeDefined()
|
|
48
54
|
expect(assigned!.new_value).toBe('alice')
|
|
@@ -51,7 +57,7 @@ describe('activity logging', () => {
|
|
|
51
57
|
test('updateTask does not log when value unchanged', () => {
|
|
52
58
|
const task = addTask(db, 'Task', { priority: 'high' })
|
|
53
59
|
updateTask(db, task.id, { priority: 'high' })
|
|
54
|
-
const activities =
|
|
60
|
+
const activities = listActivity(db, { taskId: task.id })
|
|
55
61
|
expect(activities.filter((a) => a.action === 'prioritized')).toHaveLength(0)
|
|
56
62
|
})
|
|
57
63
|
|
|
@@ -68,7 +74,7 @@ describe('activity logging', () => {
|
|
|
68
74
|
test('moveTask logs moved activity', () => {
|
|
69
75
|
const task = addTask(db, 'Mobile', { column: 'recurring' })
|
|
70
76
|
moveTask(db, task.id, 'in-progress')
|
|
71
|
-
const activities =
|
|
77
|
+
const activities = listActivity(db, { taskId: task.id })
|
|
72
78
|
const moved = activities.find((a) => a.action === 'moved')
|
|
73
79
|
expect(moved).toBeDefined()
|
|
74
80
|
expect(moved!.old_value).toBe('recurring')
|
|
@@ -96,7 +102,7 @@ describe('activity logging', () => {
|
|
|
96
102
|
test('listActivity returns most recent first', () => {
|
|
97
103
|
const task = addTask(db, 'Task')
|
|
98
104
|
updateTask(db, task.id, { title: 'Updated' })
|
|
99
|
-
const activities =
|
|
105
|
+
const activities = listActivity(db, { taskId: task.id })
|
|
100
106
|
expect(activities[0]!.action).toBe('updated')
|
|
101
107
|
expect(activities[1]!.action).toBe('created')
|
|
102
108
|
})
|
|
@@ -113,7 +119,7 @@ describe('activity logging', () => {
|
|
|
113
119
|
describe('column time tracking', () => {
|
|
114
120
|
test('addTask creates enter record', () => {
|
|
115
121
|
const task = addTask(db, 'Task', { column: 'recurring' })
|
|
116
|
-
const entries = getColumnTimeEntries(
|
|
122
|
+
const entries = getColumnTimeEntries(task.id)
|
|
117
123
|
expect(entries).toHaveLength(1)
|
|
118
124
|
expect(entries[0]!.exited_at).toBeNull()
|
|
119
125
|
})
|
|
@@ -121,7 +127,7 @@ describe('column time tracking', () => {
|
|
|
121
127
|
test('moveTask creates exit and enter records', () => {
|
|
122
128
|
const task = addTask(db, 'Task', { column: 'recurring' })
|
|
123
129
|
moveTask(db, task.id, 'in-progress')
|
|
124
|
-
const entries = getColumnTimeEntries(
|
|
130
|
+
const entries = getColumnTimeEntries(task.id)
|
|
125
131
|
expect(entries).toHaveLength(2)
|
|
126
132
|
expect(entries[0]!.exited_at).not.toBeNull()
|
|
127
133
|
expect(entries[1]!.exited_at).toBeNull()
|
|
@@ -58,6 +58,102 @@ describe('handleRequest', () => {
|
|
|
58
58
|
expect(result.mutated).toBe(true)
|
|
59
59
|
})
|
|
60
60
|
|
|
61
|
+
test('marks successful comment creation as mutated', async () => {
|
|
62
|
+
const task = addTask(db, 'Comment me')
|
|
63
|
+
const req = new Request(`http://localhost/api/tasks/${task.id}/comments`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'Content-Type': 'application/json' },
|
|
66
|
+
body: JSON.stringify({ body: 'hello from api' }),
|
|
67
|
+
})
|
|
68
|
+
const result = await handleRequest(provider, req)
|
|
69
|
+
const body = (await result.response.json()) as {
|
|
70
|
+
ok: boolean
|
|
71
|
+
data: { id: string; body: string }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
expect(result.response.status).toBe(200)
|
|
75
|
+
expect(result.mutated).toBe(true)
|
|
76
|
+
expect(body.ok).toBe(true)
|
|
77
|
+
expect(body.data.body).toBe('hello from api')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('lists comments without marking the request as mutated', async () => {
|
|
81
|
+
const task = addTask(db, 'Comment me')
|
|
82
|
+
await provider.comment(task.id, 'hello from api')
|
|
83
|
+
await provider.comment(task.id, 'second api comment')
|
|
84
|
+
|
|
85
|
+
const req = new Request(`http://localhost/api/tasks/${task.id}/comments`, {
|
|
86
|
+
method: 'GET',
|
|
87
|
+
})
|
|
88
|
+
const result = await handleRequest(provider, req)
|
|
89
|
+
const body = (await result.response.json()) as {
|
|
90
|
+
ok: boolean
|
|
91
|
+
data: Array<{ id: string; body: string }>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
expect(result.response.status).toBe(200)
|
|
95
|
+
expect(result.mutated).toBe(false)
|
|
96
|
+
expect(body.ok).toBe(true)
|
|
97
|
+
expect(body.data.map((comment) => comment.body)).toEqual([
|
|
98
|
+
'hello from api',
|
|
99
|
+
'second api comment',
|
|
100
|
+
])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('marks successful comment update as mutated', async () => {
|
|
104
|
+
const task = addTask(db, 'Comment me')
|
|
105
|
+
const created = await provider.comment(task.id, 'hello from api')
|
|
106
|
+
|
|
107
|
+
const updateReq = new Request(`http://localhost/api/tasks/${task.id}/comments/${created.id}`, {
|
|
108
|
+
method: 'PATCH',
|
|
109
|
+
headers: { 'Content-Type': 'application/json' },
|
|
110
|
+
body: JSON.stringify({ body: 'edited via api' }),
|
|
111
|
+
})
|
|
112
|
+
const updated = await handleRequest(provider, updateReq)
|
|
113
|
+
expect(updated.response.status).toBe(200)
|
|
114
|
+
expect(updated.mutated).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('emits task:upsert event on create', async () => {
|
|
118
|
+
const req = new Request('http://localhost/api/tasks', {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({ title: 'Optimistic', column: 'backlog' }),
|
|
122
|
+
})
|
|
123
|
+
const result = await handleRequest(provider, req)
|
|
124
|
+
|
|
125
|
+
expect(result.mutated).toBe(true)
|
|
126
|
+
expect(result.event?.type).toBe('task:upsert')
|
|
127
|
+
if (result.event?.type !== 'task:upsert') throw new Error('unreachable')
|
|
128
|
+
expect(result.event.task.title).toBe('Optimistic')
|
|
129
|
+
expect(result.event.columnName.toLowerCase()).toBe('backlog')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('emits task:upsert event on move across columns', async () => {
|
|
133
|
+
const task = addTask(db, 'Movable')
|
|
134
|
+
const req = new Request(`http://localhost/api/tasks/${task.id}/move`, {
|
|
135
|
+
method: 'PATCH',
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({ column: 'in-progress' }),
|
|
138
|
+
})
|
|
139
|
+
const result = await handleRequest(provider, req)
|
|
140
|
+
|
|
141
|
+
expect(result.mutated).toBe(true)
|
|
142
|
+
expect(result.event?.type).toBe('task:upsert')
|
|
143
|
+
if (result.event?.type !== 'task:upsert') throw new Error('unreachable')
|
|
144
|
+
expect(result.event.task.id).toBe(task.id)
|
|
145
|
+
expect(result.event.columnName.toLowerCase()).toBe('in-progress')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('emits task:delete event on delete', async () => {
|
|
149
|
+
const task = addTask(db, 'Goodbye')
|
|
150
|
+
const req = new Request(`http://localhost/api/tasks/${task.id}`, { method: 'DELETE' })
|
|
151
|
+
const result = await handleRequest(provider, req)
|
|
152
|
+
|
|
153
|
+
expect(result.mutated).toBe(true)
|
|
154
|
+
expect(result.event).toEqual({ type: 'task:delete', id: task.id })
|
|
155
|
+
})
|
|
156
|
+
|
|
61
157
|
test('returns bootstrap payload', async () => {
|
|
62
158
|
const req = new Request('http://localhost/api/bootstrap', { method: 'GET' })
|
|
63
159
|
const result = await handleRequest(provider, req)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { replaceTask, upsertTaskInColumn } from '../../ui/src/components/boardUtils'
|
|
3
|
+
import type { BoardView, Task } from '../../ui/src/types'
|
|
4
|
+
|
|
5
|
+
function makeTask(
|
|
6
|
+
overrides: Partial<Task> & Pick<Task, 'id' | 'title' | 'column_id' | 'position'>,
|
|
7
|
+
): Task {
|
|
8
|
+
const { id, title, column_id, position, ...rest } = overrides
|
|
9
|
+
return {
|
|
10
|
+
id,
|
|
11
|
+
providerId: id,
|
|
12
|
+
externalRef: id,
|
|
13
|
+
url: null,
|
|
14
|
+
title,
|
|
15
|
+
description: '',
|
|
16
|
+
column_id,
|
|
17
|
+
position,
|
|
18
|
+
priority: 'medium',
|
|
19
|
+
assignee: '',
|
|
20
|
+
assignees: [],
|
|
21
|
+
labels: [],
|
|
22
|
+
comment_count: 0,
|
|
23
|
+
project: '',
|
|
24
|
+
metadata: '{}',
|
|
25
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
26
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
27
|
+
version: '0',
|
|
28
|
+
source_updated_at: null,
|
|
29
|
+
...rest,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeBoard(): BoardView {
|
|
34
|
+
return {
|
|
35
|
+
columns: [
|
|
36
|
+
{
|
|
37
|
+
id: 'c-backlog',
|
|
38
|
+
name: 'backlog',
|
|
39
|
+
position: 0,
|
|
40
|
+
color: null,
|
|
41
|
+
created_at: '',
|
|
42
|
+
updated_at: '',
|
|
43
|
+
tasks: [
|
|
44
|
+
makeTask({ id: 't-1', title: 'First', column_id: 'c-backlog', position: 0 }),
|
|
45
|
+
makeTask({ id: 'tmp-1', title: 'Temp', column_id: 'c-backlog', position: 1 }),
|
|
46
|
+
makeTask({ id: 't-2', title: 'Second', column_id: 'c-backlog', position: 2 }),
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'c-done',
|
|
51
|
+
name: 'done',
|
|
52
|
+
position: 1,
|
|
53
|
+
color: null,
|
|
54
|
+
created_at: '',
|
|
55
|
+
updated_at: '',
|
|
56
|
+
tasks: [],
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('boardUtils', () => {
|
|
63
|
+
test('replaceTask removes duplicate real task ids when resolving an optimistic create', () => {
|
|
64
|
+
const board = makeBoard()
|
|
65
|
+
const withWsInsertedReal: BoardView = {
|
|
66
|
+
columns: board.columns.map((column) =>
|
|
67
|
+
column.id === 'c-backlog'
|
|
68
|
+
? {
|
|
69
|
+
...column,
|
|
70
|
+
tasks: [
|
|
71
|
+
...column.tasks,
|
|
72
|
+
makeTask({ id: 't-real', title: 'Created', column_id: 'c-backlog', position: 1 }),
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
: column,
|
|
76
|
+
),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const nextBoard = replaceTask(
|
|
80
|
+
withWsInsertedReal,
|
|
81
|
+
'tmp-1',
|
|
82
|
+
makeTask({ id: 't-real', title: 'Created', column_id: 'c-backlog', position: 1 }),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
expect(nextBoard.columns[0]!.tasks.map((task) => task.id)).toEqual(['t-1', 't-real', 't-2'])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('upsertTaskInColumn preserves same-column ordering for in-place edits', () => {
|
|
89
|
+
const board = makeBoard()
|
|
90
|
+
|
|
91
|
+
const nextBoard = upsertTaskInColumn(
|
|
92
|
+
board,
|
|
93
|
+
makeTask({ id: 't-1', title: 'First edited', column_id: 'c-backlog', position: 0 }),
|
|
94
|
+
'backlog',
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
expect(nextBoard.columns[0]!.tasks.map((task) => task.id)).toEqual(['t-1', 'tmp-1', 't-2'])
|
|
98
|
+
expect(nextBoard.columns[0]!.tasks[0]!.title).toBe('First edited')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test, beforeEach } from 'bun:test'
|
|
2
2
|
import { Database } from 'bun:sqlite'
|
|
3
|
-
import { initSchema, seedDefaultColumns } from '../../db.ts'
|
|
4
|
-
import { boardInit,
|
|
3
|
+
import { getBoardView, initSchema, seedDefaultColumns } from '../../db.ts'
|
|
4
|
+
import { boardInit, boardReset } from '../../commands/board.ts'
|
|
5
5
|
import { KanbanError } from '../../errors.ts'
|
|
6
6
|
|
|
7
7
|
let db: Database
|
|
@@ -24,20 +24,12 @@ describe('boardInit', () => {
|
|
|
24
24
|
})
|
|
25
25
|
})
|
|
26
26
|
|
|
27
|
-
describe('
|
|
27
|
+
describe('getBoardView', () => {
|
|
28
28
|
test('returns board view after init', () => {
|
|
29
29
|
initSchema(db)
|
|
30
30
|
seedDefaultColumns(db)
|
|
31
|
-
const
|
|
32
|
-
expect(
|
|
33
|
-
if (result.ok) {
|
|
34
|
-
const data = result.data as { columns: unknown[] }
|
|
35
|
-
expect(data.columns).toHaveLength(5)
|
|
36
|
-
}
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
test('throws if not initialized', () => {
|
|
40
|
-
expect(() => boardView(db)).toThrow(KanbanError)
|
|
31
|
+
const data = getBoardView(db)
|
|
32
|
+
expect(data.columns).toHaveLength(5)
|
|
41
33
|
})
|
|
42
34
|
})
|
|
43
35
|
|
|
@@ -47,5 +39,6 @@ describe('boardReset', () => {
|
|
|
47
39
|
seedDefaultColumns(db)
|
|
48
40
|
const result = boardReset(db)
|
|
49
41
|
expect(result.ok).toBe(true)
|
|
42
|
+
expect(getBoardView(db).columns).toHaveLength(5)
|
|
50
43
|
})
|
|
51
44
|
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Database } from 'bun:sqlite'
|
|
3
|
+
import { initSchema, seedDefaultColumns } from '../db.ts'
|
|
4
|
+
import { LocalProvider } from '../providers/local.ts'
|
|
5
|
+
import { ErrorCode, KanbanError } from '../errors.ts'
|
|
6
|
+
|
|
7
|
+
let db: Database
|
|
8
|
+
let provider: LocalProvider
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
db = new Database(':memory:')
|
|
12
|
+
initSchema(db)
|
|
13
|
+
seedDefaultColumns(db)
|
|
14
|
+
provider = new LocalProvider(db, ':memory:')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('local provider conflict detection', () => {
|
|
18
|
+
test('update without expectedVersion succeeds', async () => {
|
|
19
|
+
const created = await provider.createTask({ title: 'T1' })
|
|
20
|
+
const updated = await provider.updateTask(created.id, { title: 'T1-a' })
|
|
21
|
+
expect(updated.title).toBe('T1-a')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('update with matching expectedVersion succeeds', async () => {
|
|
25
|
+
const created = await provider.createTask({ title: 'T1' })
|
|
26
|
+
const updated = await provider.updateTask(created.id, {
|
|
27
|
+
title: 'T1-a',
|
|
28
|
+
expectedVersion: created.version ?? undefined,
|
|
29
|
+
})
|
|
30
|
+
expect(updated.title).toBe('T1-a')
|
|
31
|
+
expect(updated.version).not.toBe(created.version)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('update with stale expectedVersion throws CONFLICT', async () => {
|
|
35
|
+
const created = await provider.createTask({ title: 'T1' })
|
|
36
|
+
await provider.updateTask(created.id, { title: 'bumped' })
|
|
37
|
+
let err: unknown
|
|
38
|
+
try {
|
|
39
|
+
await provider.updateTask(created.id, {
|
|
40
|
+
title: 'stale',
|
|
41
|
+
expectedVersion: created.version ?? undefined,
|
|
42
|
+
})
|
|
43
|
+
} catch (e) {
|
|
44
|
+
err = e
|
|
45
|
+
}
|
|
46
|
+
expect(err).toBeInstanceOf(KanbanError)
|
|
47
|
+
expect((err as KanbanError).code).toBe(ErrorCode.CONFLICT)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('task exposes version and source_updated_at', async () => {
|
|
51
|
+
const created = await provider.createTask({ title: 'T1' })
|
|
52
|
+
expect(created.version).toBe('0')
|
|
53
|
+
expect(created.source_updated_at).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('version bumps on each update', async () => {
|
|
57
|
+
const created = await provider.createTask({ title: 'T1' })
|
|
58
|
+
expect(created.version).toBe('0')
|
|
59
|
+
const v1 = await provider.updateTask(created.id, { title: 'a' })
|
|
60
|
+
expect(v1.version).toBe('1')
|
|
61
|
+
const v2 = await provider.updateTask(created.id, { title: 'b' })
|
|
62
|
+
expect(v2.version).toBe('2')
|
|
63
|
+
})
|
|
64
|
+
})
|