@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.
Files changed (59) hide show
  1. package/README.md +89 -22
  2. package/package.json +4 -2
  3. package/src/__tests__/activity.test.ts +15 -9
  4. package/src/__tests__/api.test.ts +96 -0
  5. package/src/__tests__/board-utils.test.ts +100 -0
  6. package/src/__tests__/commands/board.test.ts +6 -13
  7. package/src/__tests__/conflict.test.ts +64 -0
  8. package/src/__tests__/index.test.ts +233 -56
  9. package/src/__tests__/jira-adf.test.ts +168 -0
  10. package/src/__tests__/jira-cache.test.ts +304 -0
  11. package/src/__tests__/jira-client.test.ts +169 -0
  12. package/src/__tests__/jira-provider-comment.test.ts +281 -0
  13. package/src/__tests__/jira-provider-mutations.test.ts +771 -0
  14. package/src/__tests__/jira-provider-read.test.ts +594 -0
  15. package/src/__tests__/jira-wiring.test.ts +187 -0
  16. package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
  17. package/src/__tests__/linear-provider-comment.test.ts +243 -0
  18. package/src/__tests__/linear-provider-sync.test.ts +493 -0
  19. package/src/__tests__/local-provider-comment.test.ts +60 -0
  20. package/src/__tests__/mcp-core.test.ts +164 -0
  21. package/src/__tests__/mcp-server.test.ts +252 -0
  22. package/src/__tests__/server.test.ts +298 -0
  23. package/src/__tests__/webhooks.test.ts +604 -0
  24. package/src/activity.ts +1 -11
  25. package/src/api.ts +154 -19
  26. package/src/commands/board.ts +1 -11
  27. package/src/commands/mcp.ts +87 -0
  28. package/src/db.ts +115 -3
  29. package/src/errors.ts +2 -0
  30. package/src/id.ts +1 -1
  31. package/src/index.ts +72 -18
  32. package/src/mcp/core.ts +193 -0
  33. package/src/mcp/errors.ts +109 -0
  34. package/src/mcp/index.ts +13 -0
  35. package/src/mcp/server.ts +512 -0
  36. package/src/mcp/types.ts +72 -0
  37. package/src/providers/capabilities.ts +15 -0
  38. package/src/providers/index.ts +31 -1
  39. package/src/providers/jira-adf.ts +275 -0
  40. package/src/providers/jira-cache.ts +625 -0
  41. package/src/providers/jira-client.ts +390 -0
  42. package/src/providers/jira.ts +778 -0
  43. package/src/providers/linear-cache.ts +249 -70
  44. package/src/providers/linear-client.ts +256 -13
  45. package/src/providers/linear.ts +337 -14
  46. package/src/providers/local.ts +68 -17
  47. package/src/providers/types.ts +18 -2
  48. package/src/server.ts +139 -11
  49. package/src/tunnel.ts +79 -0
  50. package/src/types.ts +18 -2
  51. package/src/webhooks.ts +36 -0
  52. package/ui/dist/assets/index-DBnoKL_k.css +1 -0
  53. package/ui/dist/assets/index-qNVJ6clH.js +40 -0
  54. package/ui/dist/index.html +2 -2
  55. package/src/__tests__/commands/task.test.ts +0 -144
  56. package/src/commands/task.ts +0 -117
  57. package/src/fixtures.ts +0 -128
  58. package/ui/dist/assets/index-B8f9NB4z.css +0 -1
  59. 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 Linear backend.
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/README.md`](docs/README.md) for the documentation index
14
- - [`docs/WORKFLOW.md`](docs/WORKFLOW.md) for a common day-to-day workflow
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 | Default | Description |
54
- | ----------------- | ------------- | -------------------------------------- |
55
- | `KANBAN_PROVIDER` | `local` | `local` or `linear` |
56
- | `KANBAN_DB_PATH` | auto-resolved | SQLite database path |
57
- | `LINEAR_API_KEY` | — | Required when `KANBAN_PROVIDER=linear` |
58
- | `LINEAR_TEAM_ID` | — | Required when `KANBAN_PROVIDER=linear` |
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 | Local | Linear |
78
- | ----------------------- | ----- | ------ |
79
- | task create/update/move | yes | yes |
80
- | task delete | yes | no |
81
- | activity log | yes | no |
82
- | metrics | yes | no |
83
- | column CRUD | yes | no |
84
- | bulk operations | yes | no |
85
- | config edit | yes | no |
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/*` — same operations as the CLI (board, tasks, columns, activity, metrics, config)
232
- - **WebSocket** at `/ws` — push notifications on board mutations (clients receive `{"type":"refresh"}`)
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/README.md).
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.2.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, getTaskActivity, getColumnTimeEntries } from '../activity.ts'
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 = getTaskActivity(db, task.id)
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 = getTaskActivity(db, task.id)
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 = getTaskActivity(db, task.id)
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 = getTaskActivity(db, task.id)
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 = getTaskActivity(db, task.id)
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 = getTaskActivity(db, task.id)
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(db, task.id)
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(db, task.id)
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, boardView, boardReset } from '../../commands/board.ts'
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('boardView', () => {
27
+ describe('getBoardView', () => {
28
28
  test('returns board view after init', () => {
29
29
  initSchema(db)
30
30
  seedDefaultColumns(db)
31
- const result = boardView(db)
32
- expect(result.ok).toBe(true)
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
+ })