@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.
- package/README.md +120 -24
- package/package.json +4 -2
- package/src/__tests__/activity.test.ts +16 -10
- package/src/__tests__/api.test.ts +99 -3
- package/src/__tests__/board-utils.test.ts +100 -0
- package/src/__tests__/commands/board.test.ts +7 -14
- package/src/__tests__/commands/bulk.test.ts +3 -3
- package/src/__tests__/commands/column.test.ts +4 -4
- package/src/__tests__/conflict.test.ts +64 -0
- package/src/__tests__/db.test.ts +2 -2
- package/src/__tests__/id.test.ts +1 -1
- package/src/__tests__/index.test.ts +233 -56
- package/src/__tests__/jira-adf.test.ts +180 -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 +488 -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__/metrics.test.ts +2 -2
- package/src/__tests__/output.test.ts +1 -1
- package/src/__tests__/provider-capabilities.test.ts +40 -0
- package/src/__tests__/server.test.ts +291 -0
- package/src/__tests__/webhooks.test.ts +604 -0
- package/src/activity.ts +2 -12
- package/src/api.ts +156 -21
- package/src/commands/board.ts +4 -14
- package/src/commands/bulk.ts +4 -4
- package/src/commands/column.ts +4 -4
- package/src/commands/mcp.ts +87 -0
- package/src/config.ts +1 -1
- package/src/db.ts +118 -6
- package/src/errors.ts +2 -0
- package/src/id.ts +1 -1
- package/src/index.ts +83 -35
- 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/metrics.ts +1 -1
- package/src/output.ts +1 -1
- package/src/providers/capabilities.ts +22 -17
- package/src/providers/errors.ts +1 -1
- package/src/providers/index.ts +36 -6
- 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 +773 -0
- package/src/providers/linear-cache.ts +250 -71
- package/src/providers/linear-client.ts +255 -15
- package/src/providers/linear.ts +338 -20
- package/src/providers/local.ts +74 -23
- package/src/providers/types.ts +19 -3
- package/src/server.ts +141 -13
- package/src/tunnel.ts +79 -0
- package/src/types.ts +19 -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
|
@@ -2,17 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/abpai/agent-kanban/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
14
|
-
- [`docs/
|
|
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
|
|
54
|
-
|
|
|
55
|
-
| `KANBAN_PROVIDER`
|
|
56
|
-
| `KANBAN_DB_PATH`
|
|
57
|
-
| `LINEAR_API_KEY`
|
|
58
|
-
| `LINEAR_TEAM_ID`
|
|
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
|
|
78
|
-
|
|
|
79
|
-
| task create/update/move
|
|
80
|
-
| task delete
|
|
81
|
-
|
|
|
82
|
-
|
|
|
83
|
-
|
|
|
84
|
-
|
|
|
85
|
-
|
|
|
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/*` —
|
|
232
|
-
- **WebSocket** at `/ws` — push notifications on board mutations (clients receive `
|
|
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/
|
|
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.
|
|
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
|
|
13
|
-
import { listActivity
|
|
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 =
|
|
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()
|
|
@@ -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
|
|
4
|
-
import { handleRequest } from '../api
|
|
5
|
-
import { createProvider } from '../providers/index
|
|
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
|
|
4
|
-
import { boardInit,
|
|
5
|
-
import { KanbanError } from '../../errors
|
|
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('
|
|
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
|
})
|
|
@@ -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
|
|
4
|
-
import { bulkMoveAllCmd, bulkClearDoneCmd } from '../../commands/bulk
|
|
5
|
-
import { KanbanError } from '../../errors
|
|
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
|
|
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
|
|
11
|
-
import { KanbanError } from '../../errors
|
|
12
|
-
import type { Column } from '../../types
|
|
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
|
+
})
|