@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.
Files changed (72) hide show
  1. package/README.md +120 -24
  2. package/package.json +4 -2
  3. package/src/__tests__/activity.test.ts +16 -10
  4. package/src/__tests__/api.test.ts +99 -3
  5. package/src/__tests__/board-utils.test.ts +100 -0
  6. package/src/__tests__/commands/board.test.ts +7 -14
  7. package/src/__tests__/commands/bulk.test.ts +3 -3
  8. package/src/__tests__/commands/column.test.ts +4 -4
  9. package/src/__tests__/conflict.test.ts +64 -0
  10. package/src/__tests__/db.test.ts +2 -2
  11. package/src/__tests__/id.test.ts +1 -1
  12. package/src/__tests__/index.test.ts +233 -56
  13. package/src/__tests__/jira-adf.test.ts +180 -0
  14. package/src/__tests__/jira-cache.test.ts +304 -0
  15. package/src/__tests__/jira-client.test.ts +169 -0
  16. package/src/__tests__/jira-provider-comment.test.ts +281 -0
  17. package/src/__tests__/jira-provider-mutations.test.ts +771 -0
  18. package/src/__tests__/jira-provider-read.test.ts +594 -0
  19. package/src/__tests__/jira-wiring.test.ts +187 -0
  20. package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
  21. package/src/__tests__/linear-provider-comment.test.ts +243 -0
  22. package/src/__tests__/linear-provider-sync.test.ts +488 -0
  23. package/src/__tests__/local-provider-comment.test.ts +60 -0
  24. package/src/__tests__/mcp-core.test.ts +164 -0
  25. package/src/__tests__/mcp-server.test.ts +252 -0
  26. package/src/__tests__/metrics.test.ts +2 -2
  27. package/src/__tests__/output.test.ts +1 -1
  28. package/src/__tests__/provider-capabilities.test.ts +40 -0
  29. package/src/__tests__/server.test.ts +291 -0
  30. package/src/__tests__/webhooks.test.ts +604 -0
  31. package/src/activity.ts +2 -12
  32. package/src/api.ts +156 -21
  33. package/src/commands/board.ts +4 -14
  34. package/src/commands/bulk.ts +4 -4
  35. package/src/commands/column.ts +4 -4
  36. package/src/commands/mcp.ts +87 -0
  37. package/src/config.ts +1 -1
  38. package/src/db.ts +118 -6
  39. package/src/errors.ts +2 -0
  40. package/src/id.ts +1 -1
  41. package/src/index.ts +83 -35
  42. package/src/mcp/core.ts +193 -0
  43. package/src/mcp/errors.ts +109 -0
  44. package/src/mcp/index.ts +13 -0
  45. package/src/mcp/server.ts +512 -0
  46. package/src/mcp/types.ts +72 -0
  47. package/src/metrics.ts +1 -1
  48. package/src/output.ts +1 -1
  49. package/src/providers/capabilities.ts +22 -17
  50. package/src/providers/errors.ts +1 -1
  51. package/src/providers/index.ts +36 -6
  52. package/src/providers/jira-adf.ts +275 -0
  53. package/src/providers/jira-cache.ts +625 -0
  54. package/src/providers/jira-client.ts +390 -0
  55. package/src/providers/jira.ts +773 -0
  56. package/src/providers/linear-cache.ts +250 -71
  57. package/src/providers/linear-client.ts +255 -15
  58. package/src/providers/linear.ts +338 -20
  59. package/src/providers/local.ts +74 -23
  60. package/src/providers/types.ts +19 -3
  61. package/src/server.ts +141 -13
  62. package/src/tunnel.ts +79 -0
  63. package/src/types.ts +19 -2
  64. package/src/webhooks.ts +36 -0
  65. package/ui/dist/assets/index-DBnoKL_k.css +1 -0
  66. package/ui/dist/assets/index-qNVJ6clH.js +40 -0
  67. package/ui/dist/index.html +2 -2
  68. package/src/__tests__/commands/task.test.ts +0 -144
  69. package/src/commands/task.ts +0 -117
  70. package/src/fixtures.ts +0 -128
  71. package/ui/dist/assets/index-B8f9NB4z.css +0 -1
  72. package/ui/dist/assets/index-zWp-rB7b.js +0 -40
package/README.md CHANGED
@@ -2,17 +2,29 @@
2
2
 
3
3
  [![CI](https://github.com/abpai/agent-kanban/actions/workflows/ci.yml/badge.svg)](https://github.com/abpai/agent-kanban/actions/workflows/ci.yml)
4
4
 
5
- Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structured JSON output.
5
+ `agent-kanban` exists because browser-first project tools are a bad control
6
+ plane for agents, shell scripts, and CI jobs. If automation has to click
7
+ through a web app, scrape HTML, or learn a different integration for every
8
+ tracker, the setup gets brittle fast.
6
9
 
7
- ## Why
10
+ This repo gives you one small contract across three modes: a local SQLite board,
11
+ Linear, and Jira Cloud. The CLI stays the same. The JSON envelope stays the
12
+ same. Humans still get an optional dashboard when they want a visual pass.
8
13
 
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.
14
+ That buys you a few things that are easy to miss at first:
15
+
16
+ - You can prototype an agent against a local board, then point the same workflow at Linear or Jira later.
17
+ - Remote modes use webhooks plus polling fallback, so missed events are less painful than with one-shot scripts or browser automation.
18
+ - Local mode needs no external database or service, which makes scratch boards, demos, and CI setups much easier to spin up.
19
+ - The repo also includes a reusable MCP layer, so sibling tools can reuse the same tracker semantics instead of growing their own tracker adapter.
10
20
 
11
21
  ## Documentation
12
22
 
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
23
+ - [`docs/readme.md`](docs/readme.md) for the documentation index
24
+ - [`docs/workflow.md`](docs/workflow.md) for a common day-to-day workflow
25
+ - [`docs/mcp.md`](docs/mcp.md) for the reusable tracker MCP module
15
26
  - [`docs/providers/linear.md`](docs/providers/linear.md) for Linear provider details
27
+ - [`docs/providers/jira.md`](docs/providers/jira.md) for Jira provider details
16
28
  - [`SKILL.md`](SKILL.md) for agent-specific repo usage instructions
17
29
 
18
30
  ## Install
@@ -50,12 +62,18 @@ Running `kanban` with no arguments is equivalent to `kanban board view`.
50
62
 
51
63
  All operations route through a provider backend. Set `KANBAN_PROVIDER` to choose one.
52
64
 
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` |
65
+ | Variable | Default | Description |
66
+ | ------------------ | ------------- | ------------------------------------------------------------------------ |
67
+ | `KANBAN_PROVIDER` | `local` | `local`, `linear`, or `jira` |
68
+ | `KANBAN_DB_PATH` | auto-resolved | SQLite database path |
69
+ | `LINEAR_API_KEY` | — | Required when `KANBAN_PROVIDER=linear` |
70
+ | `LINEAR_TEAM_ID` | — | Required when `KANBAN_PROVIDER=linear` |
71
+ | `JIRA_BASE_URL` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `https://acme.atlassian.net`) |
72
+ | `JIRA_EMAIL` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian account email) |
73
+ | `JIRA_API_TOKEN` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian API token) |
74
+ | `JIRA_PROJECT_KEY` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `ENG`) |
75
+ | `JIRA_BOARD_ID` | — | Optional when `KANBAN_PROVIDER=jira` (Agile board id for column order) |
76
+ | `JIRA_ISSUE_TYPE` | `Task` | Optional when `KANBAN_PROVIDER=jira` (default issue type for new tasks) |
59
77
 
60
78
  Without `KANBAN_DB_PATH`, the local provider resolves the database in this order:
61
79
 
@@ -72,22 +90,50 @@ export LINEAR_TEAM_ID=<team-id>
72
90
  kanban board view
73
91
  ```
74
92
 
93
+ ### Jira quick start
94
+
95
+ ```bash
96
+ export KANBAN_PROVIDER=jira
97
+ export JIRA_BASE_URL=https://your-domain.atlassian.net
98
+ export JIRA_EMAIL=you@example.com
99
+ export JIRA_API_TOKEN=...
100
+ export JIRA_PROJECT_KEY=ENG
101
+ export JIRA_BOARD_ID=123 # optional
102
+ kanban board view
103
+ ```
104
+
75
105
  ### Capability matrix
76
106
 
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 |
107
+ | Capability | Local | Linear | Jira |
108
+ | -------------------------- | ----- | ------ | ---- |
109
+ | task create/update/move | yes | yes | yes |
110
+ | task delete | yes | no | no |
111
+ | comment read/create/update | yes | yes | yes |
112
+ | activity log | yes | no | no |
113
+ | metrics | yes | no | no |
114
+ | column CRUD | yes | no | no |
115
+ | bulk operations | yes | no | no |
116
+ | config edit | yes | no | no |
117
+ | webhooks | no | yes | yes |
86
118
 
87
119
  Linear tasks carry an `externalRef` (e.g. `TEAM-123`) and a `url`. Commands accept either the internal ID or the external ref.
120
+ Jira tasks can also be addressed by issue key (for example `ENG-123`).
121
+
122
+ Local mode is still the only mode with built-in metrics, config mutation, bulk
123
+ cleanup, and the dashboard/bootstrap activity feed. Linear and Jira do keep
124
+ remote issue history and comment counts in their cache tables for sync and
125
+ provider-backed flows, but those modes do not expose the same local analytics
126
+ surface.
88
127
 
89
128
  Unsupported operations return error code `UNSUPPORTED_OPERATION` with exit code 1.
90
129
 
130
+ Task comments are currently exposed through the REST API and dashboard task
131
+ detail flows rather than dedicated `kanban comment ...` CLI commands.
132
+
133
+ In Linear and Jira modes, webhooks update the cache immediately when configured,
134
+ and the normal poll loop still runs as a fallback so missed deliveries and
135
+ remote deletions are eventually reconciled.
136
+
91
137
  ## Commands
92
138
 
93
139
  ### board
@@ -191,8 +237,19 @@ Default columns: `recurring`, `backlog`, `in-progress`, `review`, `done`.
191
237
  ```bash
192
238
  kanban serve # default port 3000
193
239
  kanban serve --port 8080
240
+ kanban serve --tunnel # optional public URL for webhook testing
194
241
  ```
195
242
 
243
+ ### mcp
244
+
245
+ ```bash
246
+ kanban mcp
247
+ kanban mcp --db /path/to/board.db
248
+ ```
249
+
250
+ Runs the bundled MCP server over stdio for local MCP clients such as Claude
251
+ Desktop. See [`docs/mcp.md`](docs/mcp.md) for the tool surface and caveats.
252
+
196
253
  ## Global flags
197
254
 
198
255
  | Flag | Description |
@@ -228,10 +285,33 @@ In Linear mode the dashboard hides unsupported actions and shows Linear issue id
228
285
 
229
286
  Starts a Bun HTTP server with:
230
287
 
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"}`)
288
+ - **REST API** at `/api/*` — board, tasks, task comments, bootstrap/provider metadata, activity, metrics, config, and webhook endpoints
289
+ - **WebSocket** at `/ws` — push notifications on board mutations (clients receive `task:upsert`, `task:delete`, or a fallback `refresh` event)
233
290
  - **Static UI** served from `ui/dist/` (build with `bun run build:ui` or `bun run ui:build`)
234
- - **Health check** at `/api/health`
291
+ - **Health check** at `/api/health` — cheap process liveness only
292
+ - **Readiness check** at `/api/ready` — reports whether the cache has warmed at least once
293
+ - **Sync status** at `/api/sync-status` — reports background sync state plus provider sync metadata
294
+
295
+ In `serve` mode, remote providers now warm once on startup and continue syncing
296
+ in the background every 30 seconds. Full reconciliation is still handled by the
297
+ provider-specific logic on top of that steady cadence.
298
+
299
+ Comment routes:
300
+
301
+ - `GET /api/tasks/:id/comments`
302
+ - `POST /api/tasks/:id/comments`
303
+ - `PATCH /api/tasks/:id/comments/:commentId`
304
+
305
+ ## Reusable MCP core
306
+
307
+ The repo also includes a reusable tracker MCP implementation under `src/mcp/`.
308
+ There are two ways to use it today:
309
+
310
+ - run `kanban mcp` for a bundled stdio MCP server
311
+ - import the helpers in `src/mcp/` from a sibling workspace or in-repo consumer
312
+
313
+ See [`docs/mcp.md`](docs/mcp.md) for the current default tool set, the auth and
314
+ policy model, and the caveats around source-level imports and `kanban serve`.
235
315
 
236
316
  ## Scripts
237
317
 
@@ -287,6 +367,22 @@ docker run -d \
287
367
  agent-kanban
288
368
  ```
289
369
 
370
+ ### Jira mode
371
+
372
+ No volume needed — all state lives in Jira Cloud.
373
+
374
+ ```bash
375
+ docker run -d \
376
+ -p 3000:3000 \
377
+ -e KANBAN_PROVIDER=jira \
378
+ -e JIRA_BASE_URL=https://your-domain.atlassian.net \
379
+ -e JIRA_EMAIL=you@example.com \
380
+ -e JIRA_API_TOKEN=... \
381
+ -e JIRA_PROJECT_KEY=ENG \
382
+ -e JIRA_BOARD_ID=123 \
383
+ agent-kanban
384
+ ```
385
+
290
386
  ### Dokploy
291
387
 
292
388
  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 +395,7 @@ If you want to contribute or report an issue, start with these guides:
299
395
  - [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
300
396
  - [SECURITY.md](SECURITY.md)
301
397
 
302
- Longer product and workflow docs live under [`docs/`](docs/README.md).
398
+ Longer product and workflow docs live under [`docs/`](docs/readme.md).
303
399
 
304
400
  ## License
305
401
 
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.1",
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",
@@ -9,8 +9,8 @@ import {
9
9
  moveTask,
10
10
  bulkMoveAll,
11
11
  bulkClearDone,
12
- } from '../db.ts'
13
- import { listActivity, getTaskActivity, getColumnTimeEntries } from '../activity.ts'
12
+ } from '../db'
13
+ import { listActivity } from '../activity'
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()
@@ -1,8 +1,8 @@
1
1
  import { beforeEach, describe, expect, test } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
- import { initSchema, seedDefaultColumns, addTask } from '../db.ts'
4
- import { handleRequest } from '../api.ts'
5
- import { createProvider } from '../providers/index.ts'
3
+ import { initSchema, seedDefaultColumns, addTask } from '../db'
4
+ import { handleRequest } from '../api'
5
+ import { createProvider } from '../providers/index'
6
6
 
7
7
  let db: Database
8
8
  let provider: ReturnType<typeof createProvider>
@@ -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,8 +1,8 @@
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'
5
- import { KanbanError } from '../../errors.ts'
3
+ import { getBoardView, initSchema, seedDefaultColumns } from '../../db'
4
+ import { boardInit, boardReset } from '../../commands/board'
5
+ import { KanbanError } from '../../errors'
6
6
 
7
7
  let db: Database
8
8
 
@@ -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
  })
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, test, beforeEach } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
- import { initSchema, seedDefaultColumns, addTask, listTasks } from '../../db.ts'
4
- import { bulkMoveAllCmd, bulkClearDoneCmd } from '../../commands/bulk.ts'
5
- import { KanbanError } from '../../errors.ts'
3
+ import { initSchema, seedDefaultColumns, addTask, listTasks } from '../../db'
4
+ import { bulkMoveAllCmd, bulkClearDoneCmd } from '../../commands/bulk'
5
+ import { KanbanError } from '../../errors'
6
6
 
7
7
  let db: Database
8
8
 
@@ -1,15 +1,15 @@
1
1
  import { describe, expect, test, beforeEach } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
- import { initSchema, seedDefaultColumns, addTask } from '../../db.ts'
3
+ import { initSchema, seedDefaultColumns, addTask } from '../../db'
4
4
  import {
5
5
  columnAdd,
6
6
  columnList,
7
7
  columnRename,
8
8
  columnReorder,
9
9
  columnDelete,
10
- } from '../../commands/column.ts'
11
- import { KanbanError } from '../../errors.ts'
12
- import type { Column } from '../../types.ts'
10
+ } from '../../commands/column'
11
+ import { KanbanError } from '../../errors'
12
+ import type { Column } from '../../types'
13
13
 
14
14
  let db: Database
15
15
 
@@ -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'
4
+ import { LocalProvider } from '../providers/local'
5
+ import { ErrorCode, KanbanError } from '../errors'
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
+ })