@andypai/agent-kanban 0.1.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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +306 -0
  3. package/package.json +80 -0
  4. package/src/__tests__/activity.test.ts +139 -0
  5. package/src/__tests__/api.test.ts +74 -0
  6. package/src/__tests__/commands/board.test.ts +51 -0
  7. package/src/__tests__/commands/bulk.test.ts +51 -0
  8. package/src/__tests__/commands/column.test.ts +78 -0
  9. package/src/__tests__/commands/task.test.ts +144 -0
  10. package/src/__tests__/db.test.ts +327 -0
  11. package/src/__tests__/id.test.ts +19 -0
  12. package/src/__tests__/index.test.ts +75 -0
  13. package/src/__tests__/metrics.test.ts +64 -0
  14. package/src/__tests__/output.test.ts +39 -0
  15. package/src/activity.ts +73 -0
  16. package/src/api.ts +209 -0
  17. package/src/commands/board.ts +29 -0
  18. package/src/commands/bulk.ts +19 -0
  19. package/src/commands/column.ts +60 -0
  20. package/src/commands/task.ts +117 -0
  21. package/src/config.ts +29 -0
  22. package/src/db.ts +587 -0
  23. package/src/errors.ts +32 -0
  24. package/src/fixtures.ts +128 -0
  25. package/src/id.ts +8 -0
  26. package/src/index.ts +413 -0
  27. package/src/metrics.ts +98 -0
  28. package/src/output.ts +105 -0
  29. package/src/providers/capabilities.ts +25 -0
  30. package/src/providers/errors.ts +16 -0
  31. package/src/providers/index.ts +24 -0
  32. package/src/providers/linear-cache.ts +385 -0
  33. package/src/providers/linear-client.ts +329 -0
  34. package/src/providers/linear.ts +305 -0
  35. package/src/providers/local.ts +135 -0
  36. package/src/providers/types.ts +65 -0
  37. package/src/server.ts +91 -0
  38. package/src/types.ts +123 -0
  39. package/ui/dist/assets/index-DEnUD0fq.css +1 -0
  40. package/ui/dist/assets/index-DMRjw1nI.js +40 -0
  41. package/ui/dist/index.html +13 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 abpai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,306 @@
1
+ # agent-kanban
2
+
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
+
5
+ Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structured JSON output.
6
+
7
+ ## Why
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.
10
+
11
+ ## Documentation
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
15
+ - [`docs/providers/linear.md`](docs/providers/linear.md) for Linear provider details
16
+ - [`SKILL.md`](SKILL.md) for agent-specific repo usage instructions
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ bun install -g @andypai/agent-kanban
22
+ ```
23
+
24
+ `agent-kanban` targets the Bun runtime. Install Bun first if it is not already available on your machine.
25
+
26
+ ### Local development
27
+
28
+ ```bash
29
+ git clone https://github.com/abpai/agent-kanban.git
30
+ cd agent-kanban
31
+ bun install
32
+ cd ui && bun install && cd ..
33
+ bun link
34
+ ```
35
+
36
+ `bun link` makes `kanban` available globally while you work on the source checkout.
37
+
38
+ ## Getting started
39
+
40
+ ```bash
41
+ kanban board init
42
+ kanban task add "Set up CI pipeline" -p high -a alice
43
+ kanban task add "Write integration tests" -c backlog
44
+ kanban board view --pretty
45
+ ```
46
+
47
+ Running `kanban` with no arguments is equivalent to `kanban board view`.
48
+
49
+ ## Providers
50
+
51
+ All operations route through a provider backend. Set `KANBAN_PROVIDER` to choose one.
52
+
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` |
59
+
60
+ Without `KANBAN_DB_PATH`, the local provider resolves the database in this order:
61
+
62
+ 1. `./.kanban/board.db` if it exists in the current working directory
63
+ 2. `~/.kanban/board.db` if it exists
64
+ 3. `./.kanban/board.db` as the path to create
65
+
66
+ ### Linear quick start
67
+
68
+ ```bash
69
+ export KANBAN_PROVIDER=linear
70
+ export LINEAR_API_KEY=lin_api_...
71
+ export LINEAR_TEAM_ID=<team-id>
72
+ kanban board view
73
+ ```
74
+
75
+ ### Capability matrix
76
+
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 |
86
+
87
+ Linear tasks carry an `externalRef` (e.g. `TEAM-123`) and a `url`. Commands accept either the internal ID or the external ref.
88
+
89
+ Unsupported operations return error code `UNSUPPORTED_OPERATION` with exit code 1.
90
+
91
+ ## Commands
92
+
93
+ ### board
94
+
95
+ | Command | Description |
96
+ | -------------------- | -------------------------------------------------- |
97
+ | `kanban board init` | Initialize a new board with default columns |
98
+ | `kanban board view` | View the full board (default command) |
99
+ | `kanban board reset` | Reset board — drops all data and restores defaults |
100
+
101
+ Default columns: `recurring`, `backlog`, `in-progress`, `review`, `done`.
102
+
103
+ ### task
104
+
105
+ | Command | Description |
106
+ | ------------------------------------- | ------------------------------------------------ |
107
+ | `kanban task add <title>` | Add a task |
108
+ | `kanban task list` | List tasks |
109
+ | `kanban task view <id>` | View task details |
110
+ | `kanban task update <id>` | Update task fields |
111
+ | `kanban task delete <id>` | Delete a task |
112
+ | `kanban task move <id> <column>` | Move task to a column |
113
+ | `kanban task assign <id> <user>` | Assign task to a user |
114
+ | `kanban task prioritize <id> <level>` | Set priority (`low`, `medium`, `high`, `urgent`) |
115
+
116
+ **Flags for `task add`:**
117
+
118
+ | Flag | Description |
119
+ | ------------------ | --------------------------------------------------------------- |
120
+ | `-d <text>` | Description |
121
+ | `-c <column>` | Target column (default: `backlog`) |
122
+ | `-p <level>` | Priority: `low`, `medium`, `high`, `urgent` (default: `medium`) |
123
+ | `-a <user>` | Assignee |
124
+ | `--project <name>` | Project tag |
125
+ | `-m <json>` | Arbitrary metadata (must be valid JSON) |
126
+
127
+ **Flags for `task list`:**
128
+
129
+ | Flag | Description |
130
+ | ------------------ | -------------------------------------------------------------- |
131
+ | `-c <column>` | Filter by column |
132
+ | `-p <level>` | Filter by priority |
133
+ | `-a <user>` | Filter by assignee |
134
+ | `--project <name>` | Filter by project |
135
+ | `-l <n>` | Limit results |
136
+ | `--sort <field>` | Sort by: `priority`, `created`, `updated`, `position`, `title` |
137
+
138
+ **Flags for `task update`:**
139
+
140
+ | Flag | Description |
141
+ | ------------------ | --------------- |
142
+ | `--title <text>` | New title |
143
+ | `-d <text>` | New description |
144
+ | `-p <level>` | New priority |
145
+ | `-a <user>` | New assignee |
146
+ | `--project <name>` | New project |
147
+ | `-m <json>` | New metadata |
148
+
149
+ ### column
150
+
151
+ | Command | Description |
152
+ | --------------------------------------------- | ----------------------- |
153
+ | `kanban column add <name>` | Add a column |
154
+ | `kanban column list` | List all columns |
155
+ | `kanban column rename <id\|name> <new-name>` | Rename a column |
156
+ | `kanban column reorder <id\|name> <position>` | Move column to position |
157
+ | `kanban column delete <id\|name>` | Delete an empty column |
158
+
159
+ **Flags for `column add`:**
160
+
161
+ | Flag | Description |
162
+ | ---------------- | ------------------------------ |
163
+ | `--position <n>` | Insert at position (0-indexed) |
164
+ | `--color <hex>` | Column color |
165
+
166
+ ### bulk
167
+
168
+ | Command | Description |
169
+ | ---------------------------------- | ----------------------------------------- |
170
+ | `kanban bulk move-all <from> <to>` | Move all tasks from one column to another |
171
+ | `kanban bulk clear-done` | Delete all tasks in the `done` column |
172
+
173
+ ### config
174
+
175
+ | Command | Description |
176
+ | ------------------------------------- | --------------------------- |
177
+ | `kanban config show` | Show board config (default) |
178
+ | `kanban config set-member <name>` | Add or update a team member |
179
+ | `kanban config remove-member <name>` | Remove a team member |
180
+ | `kanban config add-project <name>` | Register a project |
181
+ | `kanban config remove-project <name>` | Remove a project |
182
+
183
+ **Flags for `config set-member`:**
184
+
185
+ | Flag | Description |
186
+ | ----------------------- | ------------------------------ |
187
+ | `--role <human\|agent>` | Member role (default: `human`) |
188
+
189
+ ### serve
190
+
191
+ ```bash
192
+ kanban serve # default port 3000
193
+ kanban serve --port 8080
194
+ ```
195
+
196
+ ## Global flags
197
+
198
+ | Flag | Description |
199
+ | ------------------ | ------------------------------------------------------------------------------------------------ |
200
+ | `--pretty` | Human-readable output instead of JSON |
201
+ | `--db <path>` | Database path (default: local first, then `~/.kanban`, else create local; env: `KANBAN_DB_PATH`) |
202
+ | `--project <name>` | Filter or set project context |
203
+ | `-h`, `--help` | Show help text |
204
+
205
+ ## Output format
206
+
207
+ Every command returns a JSON envelope on stdout:
208
+
209
+ ```json
210
+ { "ok": true, "data": { ... } }
211
+ ```
212
+
213
+ ```json
214
+ { "ok": false, "error": { "code": "TASK_NOT_FOUND", "message": "No task with id 't_abc123'" } }
215
+ ```
216
+
217
+ Exit codes: `0` success, `1` known error, `2` internal error.
218
+
219
+ Pass `--pretty` for human-readable output — board view, task lists, and details are formatted for the terminal.
220
+
221
+ ## Web dashboard
222
+
223
+ ```bash
224
+ kanban serve
225
+ ```
226
+
227
+ In Linear mode the dashboard hides unsupported actions and shows Linear issue identifiers and links on task cards.
228
+
229
+ Starts a Bun HTTP server with:
230
+
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"}`)
233
+ - **Static UI** served from `ui/dist/` (build with `bun run build:ui` or `bun run ui:build`)
234
+ - **Health check** at `/api/health`
235
+
236
+ ## Scripts
237
+
238
+ | Script | Description |
239
+ | -------------------- | ---------------------- |
240
+ | `bun run dev` | Run with watch mode |
241
+ | `bun run start` | Run once |
242
+ | `bun run build` | Bundle to `dist/` |
243
+ | `bun run lint` | ESLint |
244
+ | `bun run format` | Prettier write |
245
+ | `bun run typecheck` | `tsc --noEmit` |
246
+ | `bun run check` | Lint + typecheck |
247
+ | `bun run test` | Bun test runner |
248
+ | `bun run test:watch` | Tests in watch mode |
249
+ | `bun run serve` | Start web dashboard |
250
+ | `bun run ui:dev` | UI dev server |
251
+ | `bun run dev:ui` | API + UI dev servers |
252
+ | `bun run ui:build` | Build UI to `ui/dist/` |
253
+ | `bun run build:ui` | Alias for `ui:build` |
254
+
255
+ ## Deployment
256
+
257
+ Build the Docker image:
258
+
259
+ ```bash
260
+ docker build -t agent-kanban .
261
+ ```
262
+
263
+ The same image works for both provider modes — only runtime env/volume config differs.
264
+
265
+ ### Local mode (SQLite)
266
+
267
+ Mount a volume for the database directory. WAL mode creates `-wal` and `-shm` sibling files, so the volume must cover the directory, not just the `.db` file.
268
+
269
+ ```bash
270
+ docker run -d \
271
+ -p 3000:3000 \
272
+ -v kanban-data:/data \
273
+ -e KANBAN_DB_PATH=/data/board.db \
274
+ agent-kanban
275
+ ```
276
+
277
+ ### Linear mode
278
+
279
+ No volume needed — all state lives in Linear.
280
+
281
+ ```bash
282
+ docker run -d \
283
+ -p 3000:3000 \
284
+ -e KANBAN_PROVIDER=linear \
285
+ -e LINEAR_API_KEY=lin_api_... \
286
+ -e LINEAR_TEAM_ID=team-id \
287
+ agent-kanban
288
+ ```
289
+
290
+ ### Dokploy
291
+
292
+ 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.
293
+
294
+ ## Community
295
+
296
+ If you want to contribute or report an issue, start with these guides:
297
+
298
+ - [CONTRIBUTING.md](CONTRIBUTING.md)
299
+ - [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
300
+ - [SECURITY.md](SECURITY.md)
301
+
302
+ Longer product and workflow docs live under [`docs/`](docs/README.md).
303
+
304
+ ## License
305
+
306
+ MIT
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@andypai/agent-kanban",
3
+ "version": "0.1.0",
4
+ "description": "Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structured JSON output.",
5
+ "homepage": "https://github.com/abpai/agent-kanban#readme",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/abpai/agent-kanban.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/abpai/agent-kanban/issues"
12
+ },
13
+ "keywords": [
14
+ "agent",
15
+ "kanban",
16
+ "cli",
17
+ "bun",
18
+ "sqlite",
19
+ "linear",
20
+ "automation"
21
+ ],
22
+ "bin": {
23
+ "kanban": "src/index.ts"
24
+ },
25
+ "type": "module",
26
+ "main": "src/index.ts",
27
+ "module": "src/index.ts",
28
+ "files": [
29
+ "src",
30
+ "ui/dist"
31
+ ],
32
+ "engines": {
33
+ "bun": ">=1.1.0"
34
+ },
35
+ "packageManager": "bun@1.3.3",
36
+ "license": "MIT",
37
+ "scripts": {
38
+ "dev": "bun --watch src/index.ts",
39
+ "start": "bun src/index.ts",
40
+ "build": "bun build ./src/index.ts --target bun --outdir ./dist",
41
+ "link": "bun link",
42
+ "lint": "bunx eslint .",
43
+ "format": "bunx prettier --write .",
44
+ "typecheck": "bunx tsc --noEmit",
45
+ "check": "bun run lint && bun run typecheck",
46
+ "test": "bun test",
47
+ "test:watch": "bun test --watch",
48
+ "seed:test": "bun --env-file=.env.test scripts/seed-test-db.ts",
49
+ "serve": "bun src/index.ts serve",
50
+ "ui:dev": "cd ui && bun run dev",
51
+ "ui:build": "cd ui && bun run build",
52
+ "dev:ui": "bun scripts/dev.ts",
53
+ "build:ui": "bun run ui:build",
54
+ "prepare": "if [ \"${HUSKY:-1}\" != \"0\" ] && command -v husky >/dev/null 2>&1; then husky install; fi",
55
+ "prepack": "cd ui && bun install --frozen-lockfile && bun run build"
56
+ },
57
+ "dependencies": {},
58
+ "devDependencies": {
59
+ "@eslint/js": "^9.39.1",
60
+ "@typescript-eslint/eslint-plugin": "^8.18.0",
61
+ "@typescript-eslint/parser": "^8.18.0",
62
+ "@types/bun": "^1.1.22",
63
+ "eslint": "^9.39.1",
64
+ "eslint-config-prettier": "^10.1.8",
65
+ "eslint-plugin-prettier": "^5.5.4",
66
+ "husky": "^8.0.3",
67
+ "lint-staged": "^15.2.0",
68
+ "prettier": "^3.7.4",
69
+ "typescript": "^5.7.2"
70
+ },
71
+ "lint-staged": {
72
+ "*.{js,jsx,ts,tsx}": [
73
+ "eslint --fix",
74
+ "prettier --write"
75
+ ],
76
+ "*.{css,html,json,md,scss,yaml,yml}": [
77
+ "prettier --write"
78
+ ]
79
+ }
80
+ }
@@ -0,0 +1,139 @@
1
+ import { describe, expect, test, beforeEach } from 'bun:test'
2
+ import { Database } from 'bun:sqlite'
3
+ import {
4
+ initSchema,
5
+ seedDefaultColumns,
6
+ addTask,
7
+ updateTask,
8
+ deleteTask,
9
+ moveTask,
10
+ bulkMoveAll,
11
+ bulkClearDone,
12
+ } from '../db.ts'
13
+ import { listActivity, getTaskActivity, getColumnTimeEntries } from '../activity.ts'
14
+
15
+ let db: Database
16
+
17
+ beforeEach(() => {
18
+ db = new Database(':memory:')
19
+ db.run('PRAGMA foreign_keys = ON')
20
+ initSchema(db)
21
+ seedDefaultColumns(db)
22
+ })
23
+
24
+ describe('activity logging', () => {
25
+ test('addTask logs created activity', () => {
26
+ const task = addTask(db, 'Test task')
27
+ const activities = getTaskActivity(db, task.id)
28
+ expect(activities).toHaveLength(1)
29
+ expect(activities[0]!.action).toBe('created')
30
+ expect(activities[0]!.new_value).toBe('Test task')
31
+ })
32
+
33
+ test('updateTask logs field changes', () => {
34
+ const task = addTask(db, 'Original')
35
+ updateTask(db, task.id, { title: 'Updated', priority: 'high' })
36
+ const activities = getTaskActivity(db, task.id)
37
+ const actions = activities.map((a) => a.action)
38
+ expect(actions).toContain('updated')
39
+ expect(actions).toContain('prioritized')
40
+ })
41
+
42
+ test('updateTask logs assignee change as assigned', () => {
43
+ const task = addTask(db, 'Task')
44
+ updateTask(db, task.id, { assignee: 'alice' })
45
+ const activities = getTaskActivity(db, task.id)
46
+ const assigned = activities.find((a) => a.action === 'assigned')
47
+ expect(assigned).toBeDefined()
48
+ expect(assigned!.new_value).toBe('alice')
49
+ })
50
+
51
+ test('updateTask does not log when value unchanged', () => {
52
+ const task = addTask(db, 'Task', { priority: 'high' })
53
+ updateTask(db, task.id, { priority: 'high' })
54
+ const activities = getTaskActivity(db, task.id)
55
+ expect(activities.filter((a) => a.action === 'prioritized')).toHaveLength(0)
56
+ })
57
+
58
+ test('deleteTask logs deleted activity', () => {
59
+ const task = addTask(db, 'Doomed')
60
+ const taskId = task.id
61
+ deleteTask(db, taskId)
62
+ const activities = listActivity(db)
63
+ const deleted = activities.find((a) => a.task_id === taskId && a.action === 'deleted')
64
+ expect(deleted).toBeDefined()
65
+ expect(deleted!.old_value).toBe('Doomed')
66
+ })
67
+
68
+ test('moveTask logs moved activity', () => {
69
+ const task = addTask(db, 'Mobile', { column: 'recurring' })
70
+ moveTask(db, task.id, 'in-progress')
71
+ const activities = getTaskActivity(db, task.id)
72
+ const moved = activities.find((a) => a.action === 'moved')
73
+ expect(moved).toBeDefined()
74
+ expect(moved!.old_value).toBe('recurring')
75
+ expect(moved!.new_value).toBe('in-progress')
76
+ })
77
+
78
+ test('bulkMoveAll logs moved activity for each task', () => {
79
+ addTask(db, 'A', { column: 'recurring' })
80
+ addTask(db, 'B', { column: 'recurring' })
81
+ bulkMoveAll(db, 'recurring', 'in-progress')
82
+ const activities = listActivity(db)
83
+ const moves = activities.filter((a) => a.action === 'moved')
84
+ expect(moves).toHaveLength(2)
85
+ })
86
+
87
+ test('bulkClearDone logs deleted activity for each task', () => {
88
+ addTask(db, 'Done A', { column: 'done' })
89
+ addTask(db, 'Done B', { column: 'done' })
90
+ bulkClearDone(db)
91
+ const activities = listActivity(db)
92
+ const deletes = activities.filter((a) => a.action === 'deleted')
93
+ expect(deletes).toHaveLength(2)
94
+ })
95
+
96
+ test('listActivity returns most recent first', () => {
97
+ const task = addTask(db, 'Task')
98
+ updateTask(db, task.id, { title: 'Updated' })
99
+ const activities = getTaskActivity(db, task.id)
100
+ expect(activities[0]!.action).toBe('updated')
101
+ expect(activities[1]!.action).toBe('created')
102
+ })
103
+
104
+ test('listActivity respects limit', () => {
105
+ const task = addTask(db, 'Task')
106
+ updateTask(db, task.id, { title: 'V2' })
107
+ updateTask(db, task.id, { title: 'V3' })
108
+ const activities = listActivity(db, { limit: 2 })
109
+ expect(activities).toHaveLength(2)
110
+ })
111
+ })
112
+
113
+ describe('column time tracking', () => {
114
+ test('addTask creates enter record', () => {
115
+ const task = addTask(db, 'Task', { column: 'recurring' })
116
+ const entries = getColumnTimeEntries(db, task.id)
117
+ expect(entries).toHaveLength(1)
118
+ expect(entries[0]!.exited_at).toBeNull()
119
+ })
120
+
121
+ test('moveTask creates exit and enter records', () => {
122
+ const task = addTask(db, 'Task', { column: 'recurring' })
123
+ moveTask(db, task.id, 'in-progress')
124
+ const entries = getColumnTimeEntries(db, task.id)
125
+ expect(entries).toHaveLength(2)
126
+ expect(entries[0]!.exited_at).not.toBeNull()
127
+ expect(entries[1]!.exited_at).toBeNull()
128
+ })
129
+
130
+ test('deleteTask closes open time entry', () => {
131
+ const task = addTask(db, 'Task', { column: 'recurring' })
132
+ const taskId = task.id
133
+ deleteTask(db, taskId)
134
+ const entries = db
135
+ .query('SELECT * FROM column_time_tracking WHERE task_id = $id')
136
+ .all({ $id: taskId }) as { exited_at: string | null }[]
137
+ expect(entries[0]!.exited_at).not.toBeNull()
138
+ })
139
+ })
@@ -0,0 +1,74 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
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'
6
+
7
+ let db: Database
8
+ let provider: ReturnType<typeof createProvider>
9
+
10
+ beforeEach(() => {
11
+ process.env['KANBAN_PROVIDER'] = 'local'
12
+ db = new Database(':memory:')
13
+ db.run('PRAGMA foreign_keys = ON')
14
+ initSchema(db)
15
+ seedDefaultColumns(db)
16
+ provider = createProvider(db, ':memory:')
17
+ })
18
+
19
+ describe('handleRequest', () => {
20
+ test('returns API 404 envelope for unknown route', async () => {
21
+ const req = new Request('http://localhost/api/not-a-route', { method: 'GET' })
22
+ const result = await handleRequest(provider, req)
23
+
24
+ expect(result.mutated).toBe(false)
25
+ expect(result.response.status).toBe(404)
26
+ })
27
+
28
+ test('marks failed PATCH mutation as not mutated', async () => {
29
+ const req = new Request('http://localhost/api/tasks/t_missing', {
30
+ method: 'PATCH',
31
+ headers: { 'Content-Type': 'application/json' },
32
+ body: JSON.stringify({ title: 'Nope' }),
33
+ })
34
+ const result = await handleRequest(provider, req)
35
+
36
+ expect(result.response.status).toBe(404)
37
+ expect(result.mutated).toBe(false)
38
+ })
39
+
40
+ test('marks successful task creation as mutated', async () => {
41
+ const req = new Request('http://localhost/api/tasks', {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json' },
44
+ body: JSON.stringify({ title: 'Created via API' }),
45
+ })
46
+ const result = await handleRequest(provider, req)
47
+
48
+ expect(result.response.status).toBe(200)
49
+ expect(result.mutated).toBe(true)
50
+ })
51
+
52
+ test('marks successful task delete as mutated', async () => {
53
+ const task = addTask(db, 'Delete me')
54
+ const req = new Request(`http://localhost/api/tasks/${task.id}`, { method: 'DELETE' })
55
+ const result = await handleRequest(provider, req)
56
+
57
+ expect(result.response.status).toBe(200)
58
+ expect(result.mutated).toBe(true)
59
+ })
60
+
61
+ test('returns bootstrap payload', async () => {
62
+ const req = new Request('http://localhost/api/bootstrap', { method: 'GET' })
63
+ const result = await handleRequest(provider, req)
64
+ const body = (await result.response.json()) as {
65
+ ok: boolean
66
+ data: { provider: string; capabilities: { taskDelete: boolean } }
67
+ }
68
+
69
+ expect(result.response.status).toBe(200)
70
+ expect(body.ok).toBe(true)
71
+ expect(body.data.provider).toBe('local')
72
+ expect(body.data.capabilities.taskDelete).toBe(true)
73
+ })
74
+ })
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test, beforeEach } from 'bun:test'
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'
6
+
7
+ let db: Database
8
+
9
+ beforeEach(() => {
10
+ db = new Database(':memory:')
11
+ db.run('PRAGMA foreign_keys = ON')
12
+ })
13
+
14
+ describe('boardInit', () => {
15
+ test('initializes a fresh database', () => {
16
+ const result = boardInit(db)
17
+ expect(result.ok).toBe(true)
18
+ })
19
+
20
+ test('throws if already initialized', () => {
21
+ initSchema(db)
22
+ seedDefaultColumns(db)
23
+ expect(() => boardInit(db)).toThrow(KanbanError)
24
+ })
25
+ })
26
+
27
+ describe('boardView', () => {
28
+ test('returns board view after init', () => {
29
+ initSchema(db)
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)
41
+ })
42
+ })
43
+
44
+ describe('boardReset', () => {
45
+ test('resets board to defaults', () => {
46
+ initSchema(db)
47
+ seedDefaultColumns(db)
48
+ const result = boardReset(db)
49
+ expect(result.ok).toBe(true)
50
+ })
51
+ })