@andypai/agent-kanban 0.6.0 → 0.6.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 +42 -19
- package/package.json +3 -2
- package/src/__tests__/activity.test.ts +12 -0
- package/src/__tests__/api.test.ts +4 -2
- package/src/__tests__/board-utils.test.ts +16 -3
- package/src/__tests__/column-roles.test.ts +34 -0
- package/src/__tests__/commands/board.test.ts +7 -0
- package/src/__tests__/conflict.test.ts +11 -1
- package/src/__tests__/db.test.ts +70 -0
- package/src/__tests__/index.test.ts +55 -0
- package/src/__tests__/jira-cache.test.ts +56 -0
- package/src/__tests__/jira-jql.test.ts +51 -0
- package/src/__tests__/jira-provider-read.test.ts +50 -0
- package/src/__tests__/mcp-server.test.ts +2 -2
- package/src/__tests__/metrics.test.ts +37 -1
- package/src/__tests__/postgres-local-provider.test.ts +90 -1
- package/src/__tests__/server.test.ts +126 -0
- package/src/__tests__/use-cases.test.ts +77 -0
- package/src/__tests__/webhooks.test.ts +75 -22
- package/src/api.ts +58 -36
- package/src/column-roles.ts +52 -0
- package/src/commands/board.ts +4 -4
- package/src/db.ts +145 -114
- package/src/errors.ts +1 -0
- package/src/index.ts +84 -33
- package/src/mcp/core.ts +8 -7
- package/src/metrics.ts +48 -23
- package/src/provider-runtime.ts +9 -2
- package/src/providers/index.ts +4 -1
- package/src/providers/jira-cache.ts +23 -6
- package/src/providers/jira-core.ts +921 -0
- package/src/providers/jira-jql.ts +48 -0
- package/src/providers/jira.ts +111 -759
- package/src/providers/linear-cache.ts +5 -3
- package/src/providers/linear-core.ts +688 -0
- package/src/providers/linear.ts +70 -554
- package/src/providers/postgres-jira-cache.ts +597 -0
- package/src/providers/postgres-jira.ts +15 -1312
- package/src/providers/postgres-linear-cache.ts +500 -0
- package/src/providers/postgres-linear.ts +22 -1088
- package/src/providers/postgres-local.ts +181 -74
- package/src/providers/types.ts +71 -2
- package/src/server.ts +118 -32
- package/src/use-cases.ts +139 -0
- package/src/webhooks.ts +26 -0
- package/ui/dist/assets/index-65LcV0R7.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-CFhtfqCn.js +0 -40
package/README.md
CHANGED
|
@@ -62,23 +62,25 @@ Running `kanban` with no arguments is equivalent to `kanban board view`.
|
|
|
62
62
|
|
|
63
63
|
All operations route through a provider backend. Set `KANBAN_PROVIDER` to choose one.
|
|
64
64
|
|
|
65
|
-
| Variable | Default | Description
|
|
66
|
-
| ---------------------------- | ------------- |
|
|
67
|
-
| `KANBAN_PROVIDER` | `local` | `local`, `linear`, or `jira`
|
|
68
|
-
| `KANBAN_STORAGE` | `sqlite` | `sqlite` or `postgres`
|
|
69
|
-
| `KANBAN_DATABASE_URL` | — | Required when `KANBAN_STORAGE=postgres`
|
|
70
|
-
| `KANBAN_DB_PATH` | auto-resolved | SQLite database path
|
|
71
|
-
| `KANBAN_DEFAULT_COLUMNS` | — | Optional bootstrap column names for local/Postgres caches
|
|
72
|
-
| `KANBAN_DEFAULT_TASK_COLUMN` | — | Optional created-task column override for local/Postgres caches
|
|
73
|
-
| `KANBAN_SYNC_INTERVAL_MS` | `30000` | Polling sync interval for remote providers; integer milliseconds >= 1000
|
|
74
|
-
| `
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
79
|
-
| `
|
|
80
|
-
| `
|
|
81
|
-
| `
|
|
65
|
+
| Variable | Default | Description |
|
|
66
|
+
| ---------------------------- | ------------- | ------------------------------------------------------------------------------------------- |
|
|
67
|
+
| `KANBAN_PROVIDER` | `local` | `local`, `linear`, or `jira` |
|
|
68
|
+
| `KANBAN_STORAGE` | `sqlite` | `sqlite` or `postgres` |
|
|
69
|
+
| `KANBAN_DATABASE_URL` | — | Required when `KANBAN_STORAGE=postgres` |
|
|
70
|
+
| `KANBAN_DB_PATH` | auto-resolved | SQLite database path |
|
|
71
|
+
| `KANBAN_DEFAULT_COLUMNS` | — | Optional bootstrap column names for local/Postgres caches |
|
|
72
|
+
| `KANBAN_DEFAULT_TASK_COLUMN` | — | Optional created-task column override for local/Postgres caches |
|
|
73
|
+
| `KANBAN_SYNC_INTERVAL_MS` | `30000` | Polling sync interval for remote providers; integer milliseconds >= 1000 |
|
|
74
|
+
| `KANBAN_API_TOKEN` | — | When set, `kanban serve` requires `Authorization: Bearer <token>` (required for `--tunnel`) |
|
|
75
|
+
| `KANBAN_ALLOWED_ORIGIN` | — | Allowed CORS origin for `kanban serve`; unset means same-origin only |
|
|
76
|
+
| `LINEAR_API_KEY` | — | Required when `KANBAN_PROVIDER=linear` |
|
|
77
|
+
| `LINEAR_TEAM_ID` | — | Required when `KANBAN_PROVIDER=linear` |
|
|
78
|
+
| `JIRA_BASE_URL` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `https://acme.atlassian.net`) |
|
|
79
|
+
| `JIRA_EMAIL` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian account email) |
|
|
80
|
+
| `JIRA_API_TOKEN` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian API token) |
|
|
81
|
+
| `JIRA_PROJECT_KEY` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `ENG`) |
|
|
82
|
+
| `JIRA_BOARD_ID` | — | Optional when `KANBAN_PROVIDER=jira` (Agile board id for column order) |
|
|
83
|
+
| `JIRA_ISSUE_TYPE` | `Task` | Optional when `KANBAN_PROVIDER=jira` (default issue type for new tasks) |
|
|
82
84
|
|
|
83
85
|
When `KANBAN_STORAGE=sqlite` and `KANBAN_DB_PATH` is unset, the local provider
|
|
84
86
|
resolves the database in this order:
|
|
@@ -252,9 +254,30 @@ Default columns: `recurring`, `backlog`, `in-progress`, `review`, `done`.
|
|
|
252
254
|
kanban serve # default port 3000
|
|
253
255
|
kanban serve --port 8080
|
|
254
256
|
kanban serve --sync-interval-ms 300000
|
|
255
|
-
kanban serve --
|
|
257
|
+
kanban serve --token "$(openssl rand -hex 32)" # require an API token
|
|
258
|
+
kanban serve --tunnel --token "$SECRET" # public URL (token required)
|
|
256
259
|
```
|
|
257
260
|
|
|
261
|
+
#### Authentication & CORS
|
|
262
|
+
|
|
263
|
+
By default `serve` binds locally and leaves the API open for backward
|
|
264
|
+
compatibility. To require authentication, set a token via `--token` or the
|
|
265
|
+
`KANBAN_API_TOKEN` env var. When a token is set:
|
|
266
|
+
|
|
267
|
+
- All `/api/*` routes (reads and mutations) and the `/ws` upgrade require
|
|
268
|
+
`Authorization: Bearer <token>`. The WebSocket, which can't send headers,
|
|
269
|
+
accepts `?token=<token>` instead.
|
|
270
|
+
- `/api/health` stays public (liveness probe).
|
|
271
|
+
- `/api/webhooks/*` are **not** covered by this token — they authenticate with
|
|
272
|
+
the provider webhook secret (e.g. `JIRA_WEBHOOK_SECRET`).
|
|
273
|
+
- The bundled UI picks the token up once from `?token=` / `#token=` and stores
|
|
274
|
+
it in `localStorage`, so you can open `https://<host>/?token=<token>`.
|
|
275
|
+
|
|
276
|
+
`--tunnel` exposes the dashboard publicly, so it **refuses to start without a
|
|
277
|
+
token**. CORS is off by default (same-origin); set `KANBAN_ALLOWED_ORIGIN` (or
|
|
278
|
+
`--allowed-origin`) to allow a specific cross-origin browser client. CORS is
|
|
279
|
+
origin hygiene, not an auth control — the token is the security boundary.
|
|
280
|
+
|
|
258
281
|
### mcp
|
|
259
282
|
|
|
260
283
|
```bash
|
|
@@ -301,7 +324,7 @@ In Linear mode the dashboard hides unsupported actions and shows Linear issue id
|
|
|
301
324
|
Starts a Bun HTTP server with:
|
|
302
325
|
|
|
303
326
|
- **REST API** at `/api/*` — board, tasks, task comments, bootstrap/provider metadata, activity, metrics, config, and webhook endpoints
|
|
304
|
-
- **WebSocket** at `/ws` — push notifications on board mutations (clients receive `task:upsert`, `task:delete`, or a fallback `refresh` event)
|
|
327
|
+
- **WebSocket** at `/ws` — push notifications on board mutations (clients receive `task:upsert` with `{ task, columnId }`, `task:delete` with `{ id }`, or a fallback `refresh` event)
|
|
305
328
|
- **Static UI** served from `ui/dist/` (build with `bun run build:ui` or `bun run ui:build`)
|
|
306
329
|
- **Health check** at `/api/health` — cheap process liveness only
|
|
307
330
|
- **Readiness check** at `/api/ready` — reports whether the cache has warmed at least once
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andypai/agent-kanban",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.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": {
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"lint": "bunx eslint .",
|
|
43
43
|
"format": "bunx prettier --write .",
|
|
44
44
|
"typecheck": "bunx tsc --noEmit",
|
|
45
|
-
"
|
|
45
|
+
"ui:typecheck": "cd ui && bunx tsc --noEmit",
|
|
46
|
+
"check": "bun run lint && bun run typecheck && bun run ui:typecheck",
|
|
46
47
|
"test": "bun test",
|
|
47
48
|
"test:watch": "bun test --watch",
|
|
48
49
|
"seed:test": "bun --env-file=.env.test scripts/seed-test-db.ts",
|
|
@@ -90,6 +90,18 @@ describe('activity logging', () => {
|
|
|
90
90
|
expect(moves).toHaveLength(2)
|
|
91
91
|
})
|
|
92
92
|
|
|
93
|
+
test('bulkMoveAll to the same column does not add moved activity or tracking rows', () => {
|
|
94
|
+
const task = addTask(db, 'A', { column: 'recurring' })
|
|
95
|
+
|
|
96
|
+
bulkMoveAll(db, 'recurring', 'recurring')
|
|
97
|
+
|
|
98
|
+
const activities = listActivity(db)
|
|
99
|
+
expect(activities.filter((a) => a.action === 'moved')).toHaveLength(0)
|
|
100
|
+
const entries = getColumnTimeEntries(task.id)
|
|
101
|
+
expect(entries).toHaveLength(1)
|
|
102
|
+
expect(entries[0]!.exited_at).toBeNull()
|
|
103
|
+
})
|
|
104
|
+
|
|
93
105
|
test('bulkClearDone logs deleted activity for each task', () => {
|
|
94
106
|
addTask(db, 'Done A', { column: 'done' })
|
|
95
107
|
addTask(db, 'Done B', { column: 'done' })
|
|
@@ -132,7 +132,8 @@ describe('handleRequest', () => {
|
|
|
132
132
|
expect(result.event?.type).toBe('task:upsert')
|
|
133
133
|
if (result.event?.type !== 'task:upsert') throw new Error('unreachable')
|
|
134
134
|
expect(result.event.task.title).toBe('Optimistic')
|
|
135
|
-
expect(result.event
|
|
135
|
+
expect('columnId' in result.event).toBe(true)
|
|
136
|
+
expect('columnName' in result.event).toBe(false)
|
|
136
137
|
})
|
|
137
138
|
|
|
138
139
|
test('emits task:upsert event on move across columns', async () => {
|
|
@@ -148,7 +149,8 @@ describe('handleRequest', () => {
|
|
|
148
149
|
expect(result.event?.type).toBe('task:upsert')
|
|
149
150
|
if (result.event?.type !== 'task:upsert') throw new Error('unreachable')
|
|
150
151
|
expect(result.event.task.id).toBe(task.id)
|
|
151
|
-
expect(result.event
|
|
152
|
+
expect('columnId' in result.event).toBe(true)
|
|
153
|
+
expect('columnName' in result.event).toBe(false)
|
|
152
154
|
})
|
|
153
155
|
|
|
154
156
|
test('emits task:delete event on delete', async () => {
|
|
@@ -95,11 +95,24 @@ describe('boardUtils', () => {
|
|
|
95
95
|
const nextBoard = upsertTaskInColumn(
|
|
96
96
|
board,
|
|
97
97
|
makeTask({ id: 't-1', title: 'First edited', column_id: 'c-backlog', position: 0 }),
|
|
98
|
-
'backlog',
|
|
98
|
+
'c-backlog',
|
|
99
99
|
)
|
|
100
100
|
|
|
101
|
-
expect(nextBoard
|
|
102
|
-
expect(nextBoard
|
|
101
|
+
expect(nextBoard).not.toBeNull()
|
|
102
|
+
expect(nextBoard!.columns[0]!.tasks.map((task) => task.id)).toEqual(['t-1', 'tmp-1', 't-2'])
|
|
103
|
+
expect(nextBoard!.columns[0]!.tasks[0]!.title).toBe('First edited')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('upsertTaskInColumn returns null when the target column is not on the board', () => {
|
|
107
|
+
const board = makeBoard()
|
|
108
|
+
|
|
109
|
+
const nextBoard = upsertTaskInColumn(
|
|
110
|
+
board,
|
|
111
|
+
makeTask({ id: 't-9', title: 'Unknown column', column_id: 'status:10001', position: 0 }),
|
|
112
|
+
'status:10001',
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
expect(nextBoard).toBeNull()
|
|
103
116
|
})
|
|
104
117
|
|
|
105
118
|
test('moveTaskInBoard accepts a column id when column names repeat', () => {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { selectDoneColumnIds, selectInProgressColumnIds } from '../column-roles'
|
|
3
|
+
|
|
4
|
+
const cols = (names: string[]) => names.map((name, i) => ({ id: `c${i}`, name, position: i }))
|
|
5
|
+
|
|
6
|
+
describe('selectDoneColumnIds', () => {
|
|
7
|
+
test('matches done synonyms regardless of case/separators', () => {
|
|
8
|
+
const columns = cols(['Backlog', 'In Progress', 'Done'])
|
|
9
|
+
expect(selectDoneColumnIds(columns)).toEqual(['c2'])
|
|
10
|
+
expect(selectDoneColumnIds(cols(['todo', 'completed']))).toEqual(['c1'])
|
|
11
|
+
expect(selectDoneColumnIds(cols(['Open', 'Merged']))).toEqual(['c1'])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('falls back to the terminal column when no name matches', () => {
|
|
15
|
+
// Custom names with no recognizable "done" -> last column by position.
|
|
16
|
+
expect(selectDoneColumnIds(cols(['Todo', 'Doing', 'Shipping Soon']))).toEqual(['c2'])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('returns empty for no columns', () => {
|
|
20
|
+
expect(selectDoneColumnIds([])).toEqual([])
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('selectInProgressColumnIds', () => {
|
|
25
|
+
test('matches in-progress synonyms regardless of case/separators', () => {
|
|
26
|
+
expect(selectInProgressColumnIds(cols(['Todo', 'In Progress', 'Done']))).toEqual(['c1'])
|
|
27
|
+
expect(selectInProgressColumnIds(cols(['in-progress']))).toEqual(['c0'])
|
|
28
|
+
expect(selectInProgressColumnIds(cols(['WIP', 'Doing']))).toEqual(['c0', 'c1'])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('returns empty when nothing matches (no positional fallback)', () => {
|
|
32
|
+
expect(selectInProgressColumnIds(cols(['Backlog', 'Done']))).toEqual([])
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -17,6 +17,13 @@ describe('boardInit', () => {
|
|
|
17
17
|
expect(result.ok).toBe(true)
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
+
test('initializes when the schema exists without seeded columns', () => {
|
|
21
|
+
initSchema(db)
|
|
22
|
+
const result = boardInit(db)
|
|
23
|
+
expect(result.ok).toBe(true)
|
|
24
|
+
expect(getBoardView(db).columns).toHaveLength(5)
|
|
25
|
+
})
|
|
26
|
+
|
|
20
27
|
test('throws if already initialized', () => {
|
|
21
28
|
initSchema(db)
|
|
22
29
|
seedDefaultColumns(db)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
import { Database } from 'bun:sqlite'
|
|
3
|
-
import { initSchema, seedDefaultColumns } from '../db'
|
|
3
|
+
import { initSchema, seedDefaultColumns, resolveColumn } from '../db'
|
|
4
4
|
import { LocalProvider } from '../providers/local'
|
|
5
5
|
import { ErrorCode, KanbanError } from '../errors'
|
|
6
6
|
|
|
@@ -61,4 +61,14 @@ describe('local provider conflict detection', () => {
|
|
|
61
61
|
const v2 = await provider.updateTask(created.id, { title: 'b' })
|
|
62
62
|
expect(v2.version).toBe('2')
|
|
63
63
|
})
|
|
64
|
+
|
|
65
|
+
// moveTask(id, column) intentionally has no expectedVersion parameter, so moves
|
|
66
|
+
// are last-write-wins by design (drag-and-drop has no loaded version to check).
|
|
67
|
+
// This locks in that contract so a move is never rejected for a stale version.
|
|
68
|
+
test('moveTask has no expectedVersion contract (move after an update still succeeds)', async () => {
|
|
69
|
+
const created = await provider.createTask({ title: 'T1', column: 'backlog' })
|
|
70
|
+
await provider.updateTask(created.id, { title: 'bumped' })
|
|
71
|
+
const moved = await provider.moveTask(created.id, 'in-progress')
|
|
72
|
+
expect(moved.column_id).toBe(resolveColumn(db, 'in-progress').id)
|
|
73
|
+
})
|
|
64
74
|
})
|
package/src/__tests__/db.test.ts
CHANGED
|
@@ -25,6 +25,9 @@ import {
|
|
|
25
25
|
bulkClearDone,
|
|
26
26
|
resetBoard,
|
|
27
27
|
migrateSchema,
|
|
28
|
+
addComment,
|
|
29
|
+
updateComment,
|
|
30
|
+
listComments,
|
|
28
31
|
} from '../db'
|
|
29
32
|
import { KanbanError } from '../errors'
|
|
30
33
|
|
|
@@ -79,6 +82,27 @@ describe('schema', () => {
|
|
|
79
82
|
expect(listColumns(db)).toHaveLength(5)
|
|
80
83
|
})
|
|
81
84
|
|
|
85
|
+
test('seedDefaultColumns honors provided column names (KANBAN_DEFAULT_COLUMNS)', () => {
|
|
86
|
+
const fresh = new Database(':memory:')
|
|
87
|
+
initSchema(fresh)
|
|
88
|
+
seedDefaultColumns(fresh, ['Todo', 'In Progress', 'Done'])
|
|
89
|
+
const cols = listColumns(fresh)
|
|
90
|
+
expect(cols.map((c) => c.name)).toEqual(['Todo', 'In Progress', 'Done'])
|
|
91
|
+
expect(cols.map((c) => c.position)).toEqual([0, 1, 2])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('seedDefaultColumns falls back to defaults for an empty list', () => {
|
|
95
|
+
const fresh = new Database(':memory:')
|
|
96
|
+
initSchema(fresh)
|
|
97
|
+
seedDefaultColumns(fresh, [])
|
|
98
|
+
expect(listColumns(fresh)).toHaveLength(5)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('resetBoard reseeds with provided column names', () => {
|
|
102
|
+
resetBoard(db, ['Alpha', 'Beta'])
|
|
103
|
+
expect(listColumns(db).map((c) => c.name)).toEqual(['Alpha', 'Beta'])
|
|
104
|
+
})
|
|
105
|
+
|
|
82
106
|
test('migrateSchema adds project and labels columns to legacy tasks table', () => {
|
|
83
107
|
const legacy = new Database(':memory:')
|
|
84
108
|
legacy.run(
|
|
@@ -308,6 +332,16 @@ describe('bulk operations', () => {
|
|
|
308
332
|
expect(listTasks(db, { column: 'recurring' })).toHaveLength(0)
|
|
309
333
|
})
|
|
310
334
|
|
|
335
|
+
test('bulkMoveAll to the same column is a no-op', () => {
|
|
336
|
+
const a = addTask(db, 'A', { column: 'recurring' })
|
|
337
|
+
const b = addTask(db, 'B', { column: 'recurring' })
|
|
338
|
+
|
|
339
|
+
const result = bulkMoveAll(db, 'recurring', 'recurring')
|
|
340
|
+
|
|
341
|
+
expect(result.moved).toBe(0)
|
|
342
|
+
expect(listTasks(db, { column: 'recurring' }).map((task) => task.id)).toEqual([a.id, b.id])
|
|
343
|
+
})
|
|
344
|
+
|
|
311
345
|
test('bulkClearDone removes tasks in done column', () => {
|
|
312
346
|
addTask(db, 'Finished', { column: 'done' })
|
|
313
347
|
addTask(db, 'Also done', { column: 'done' })
|
|
@@ -326,3 +360,39 @@ describe('resetBoard', () => {
|
|
|
326
360
|
expect(listTasks(db)).toHaveLength(0)
|
|
327
361
|
})
|
|
328
362
|
})
|
|
363
|
+
|
|
364
|
+
describe('comment atomicity', () => {
|
|
365
|
+
const countActivity = (taskId: string): number =>
|
|
366
|
+
(
|
|
367
|
+
db
|
|
368
|
+
.query('SELECT COUNT(*) AS count FROM activity_log WHERE task_id = $task_id')
|
|
369
|
+
.get({ $task_id: taskId }) as { count: number }
|
|
370
|
+
).count
|
|
371
|
+
|
|
372
|
+
test('addComment writes the comment and its activity entry together', () => {
|
|
373
|
+
const task = addTask(db, 'Has comments')
|
|
374
|
+
const before = countActivity(task.id)
|
|
375
|
+
addComment(db, task.id, 'hello')
|
|
376
|
+
expect(listComments(db, task.id)).toHaveLength(1)
|
|
377
|
+
expect(countActivity(task.id)).toBe(before + 1)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
test('addComment rolls back the comment if the activity write fails', () => {
|
|
381
|
+
const task = addTask(db, 'Atomic add')
|
|
382
|
+
// Force the second write (logActivity) to fail mid-transaction.
|
|
383
|
+
db.run('DROP TABLE activity_log')
|
|
384
|
+
expect(() => addComment(db, task.id, 'orphan?')).toThrow()
|
|
385
|
+
// The comment INSERT must not survive the failed transaction.
|
|
386
|
+
expect(listComments(db, task.id)).toHaveLength(0)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
test('updateComment rolls back the body change if the activity write fails', () => {
|
|
390
|
+
const task = addTask(db, 'Atomic update')
|
|
391
|
+
const comment = addComment(db, task.id, 'original')
|
|
392
|
+
db.run('DROP TABLE activity_log')
|
|
393
|
+
expect(() => updateComment(db, task.id, comment.id, 'changed')).toThrow()
|
|
394
|
+
// The body must remain unchanged after the rolled-back transaction.
|
|
395
|
+
const [persisted] = listComments(db, task.id)
|
|
396
|
+
expect(persisted?.body).toBe('original')
|
|
397
|
+
})
|
|
398
|
+
})
|
|
@@ -47,7 +47,11 @@ async function expectKanbanError(
|
|
|
47
47
|
describe('parseServeArgs', () => {
|
|
48
48
|
test('defaults: no tunnel, port from PORT env or 3000', () => {
|
|
49
49
|
const prev = process.env['PORT']
|
|
50
|
+
const prevToken = process.env['KANBAN_API_TOKEN']
|
|
51
|
+
const prevOrigin = process.env['KANBAN_ALLOWED_ORIGIN']
|
|
50
52
|
delete process.env['PORT']
|
|
53
|
+
delete process.env['KANBAN_API_TOKEN']
|
|
54
|
+
delete process.env['KANBAN_ALLOWED_ORIGIN']
|
|
51
55
|
try {
|
|
52
56
|
expect(parseServeArgs(['serve'])).toEqual({ db: undefined, port: 3000, tunnel: false })
|
|
53
57
|
process.env['PORT'] = '4001'
|
|
@@ -55,6 +59,10 @@ describe('parseServeArgs', () => {
|
|
|
55
59
|
} finally {
|
|
56
60
|
if (prev === undefined) delete process.env['PORT']
|
|
57
61
|
else process.env['PORT'] = prev
|
|
62
|
+
if (prevToken === undefined) delete process.env['KANBAN_API_TOKEN']
|
|
63
|
+
else process.env['KANBAN_API_TOKEN'] = prevToken
|
|
64
|
+
if (prevOrigin === undefined) delete process.env['KANBAN_ALLOWED_ORIGIN']
|
|
65
|
+
else process.env['KANBAN_ALLOWED_ORIGIN'] = prevOrigin
|
|
58
66
|
}
|
|
59
67
|
})
|
|
60
68
|
|
|
@@ -82,9 +90,56 @@ describe('parseServeArgs', () => {
|
|
|
82
90
|
expect(() => parseServeArgs(['serve', '--sync-interval-ms', raw])).toThrow(KanbanError)
|
|
83
91
|
}
|
|
84
92
|
})
|
|
93
|
+
|
|
94
|
+
test('--token / --allowed-origin flags and env are parsed; flags win over env', () => {
|
|
95
|
+
const prevToken = process.env['KANBAN_API_TOKEN']
|
|
96
|
+
const prevOrigin = process.env['KANBAN_ALLOWED_ORIGIN']
|
|
97
|
+
delete process.env['KANBAN_API_TOKEN']
|
|
98
|
+
delete process.env['KANBAN_ALLOWED_ORIGIN']
|
|
99
|
+
try {
|
|
100
|
+
const flags = parseServeArgs([
|
|
101
|
+
'serve',
|
|
102
|
+
'--token',
|
|
103
|
+
'flag-token',
|
|
104
|
+
'--allowed-origin',
|
|
105
|
+
'https://flag.example',
|
|
106
|
+
])
|
|
107
|
+
expect(flags.authToken).toBe('flag-token')
|
|
108
|
+
expect(flags.allowedOrigin).toBe('https://flag.example')
|
|
109
|
+
|
|
110
|
+
process.env['KANBAN_API_TOKEN'] = 'env-token'
|
|
111
|
+
process.env['KANBAN_ALLOWED_ORIGIN'] = 'https://env.example'
|
|
112
|
+
expect(parseServeArgs(['serve']).authToken).toBe('env-token')
|
|
113
|
+
expect(parseServeArgs(['serve']).allowedOrigin).toBe('https://env.example')
|
|
114
|
+
// Flag overrides env.
|
|
115
|
+
expect(parseServeArgs(['serve', '--token', 'flag-token']).authToken).toBe('flag-token')
|
|
116
|
+
} finally {
|
|
117
|
+
if (prevToken === undefined) delete process.env['KANBAN_API_TOKEN']
|
|
118
|
+
else process.env['KANBAN_API_TOKEN'] = prevToken
|
|
119
|
+
if (prevOrigin === undefined) delete process.env['KANBAN_ALLOWED_ORIGIN']
|
|
120
|
+
else process.env['KANBAN_ALLOWED_ORIGIN'] = prevOrigin
|
|
121
|
+
}
|
|
122
|
+
})
|
|
85
123
|
})
|
|
86
124
|
|
|
87
125
|
describe('run', () => {
|
|
126
|
+
test('initializes a fresh board through the CLI path', async () => {
|
|
127
|
+
await withTempDb(async (dbPath) => {
|
|
128
|
+
const result = expectOk<{ message: string }>(await run(['--db', dbPath, 'board', 'init']))
|
|
129
|
+
expect(result.message).toContain('Board initialized')
|
|
130
|
+
|
|
131
|
+
const db = new Database(dbPath)
|
|
132
|
+
const columns = db.query('SELECT COUNT(*) as count FROM columns').get() as { count: number }
|
|
133
|
+
expect(columns.count).toBeGreaterThan(0)
|
|
134
|
+
db.close()
|
|
135
|
+
|
|
136
|
+
await expectKanbanError(
|
|
137
|
+
run(['--db', dbPath, 'board', 'init']),
|
|
138
|
+
ErrorCode.BOARD_ALREADY_INITIALIZED,
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
88
143
|
test('applies schema migration before task commands', async () => {
|
|
89
144
|
await withTempDb(async (dbPath) => {
|
|
90
145
|
const legacy = new Database(dbPath)
|
|
@@ -62,6 +62,62 @@ describe('jira-cache', () => {
|
|
|
62
62
|
expect(decodeColumnStatusIds(col)).toEqual(['10001', '10002', '10003'])
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
+
test('catalog replace prune=false upserts without deleting obsolete rows', () => {
|
|
66
|
+
// Seed a full catalog (prune defaults to true → full replace).
|
|
67
|
+
replaceJiraColumns(db, [
|
|
68
|
+
{ id: 'status:1', name: 'To Do', position: 0, statusIds: ['1'], source: 'status' },
|
|
69
|
+
{ id: 'status:2', name: 'Done', position: 1, statusIds: ['2'], source: 'status' },
|
|
70
|
+
])
|
|
71
|
+
replaceJiraPriorities(db, [
|
|
72
|
+
{ id: 'p1', name: 'High' },
|
|
73
|
+
{ id: 'p2', name: 'Low' },
|
|
74
|
+
])
|
|
75
|
+
replaceJiraIssueTypes(db, [
|
|
76
|
+
{ id: 't1', name: 'Bug' },
|
|
77
|
+
{ id: 't2', name: 'Task' },
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
// A delta sync (prune=false) carries a partial snapshot: it should upsert the
|
|
81
|
+
// rows it sees and leave the others in place (self-healing), not delete them.
|
|
82
|
+
replaceJiraColumns(
|
|
83
|
+
db,
|
|
84
|
+
[
|
|
85
|
+
{
|
|
86
|
+
id: 'status:1',
|
|
87
|
+
name: 'To Do (renamed)',
|
|
88
|
+
position: 0,
|
|
89
|
+
statusIds: ['1'],
|
|
90
|
+
source: 'status',
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
false,
|
|
94
|
+
)
|
|
95
|
+
replaceJiraPriorities(db, [{ id: 'p1', name: 'Highest' }], false)
|
|
96
|
+
replaceJiraIssueTypes(db, [{ id: 't1', name: 'Defect' }], false)
|
|
97
|
+
|
|
98
|
+
const columns = getCachedColumns(db)
|
|
99
|
+
expect(columns.map((c) => c.id).sort()).toEqual(['status:1', 'status:2'])
|
|
100
|
+
expect(columns.find((c) => c.id === 'status:1')?.name).toBe('To Do (renamed)')
|
|
101
|
+
|
|
102
|
+
const { priorities, issueTypes } = getCachedConfig(db)
|
|
103
|
+
expect(priorities.map((p) => p.id).sort()).toEqual(['p1', 'p2'])
|
|
104
|
+
expect(priorities.find((p) => p.id === 'p1')?.name).toBe('Highest')
|
|
105
|
+
expect(issueTypes.map((t) => t.id).sort()).toEqual(['t1', 't2'])
|
|
106
|
+
expect(issueTypes.find((t) => t.id === 't1')?.name).toBe('Defect')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('catalog replace prune=true (default) removes obsolete rows', () => {
|
|
110
|
+
replaceJiraColumns(db, [
|
|
111
|
+
{ id: 'status:1', name: 'To Do', position: 0, statusIds: ['1'], source: 'status' },
|
|
112
|
+
{ id: 'status:2', name: 'Done', position: 1, statusIds: ['2'], source: 'status' },
|
|
113
|
+
])
|
|
114
|
+
// A full reconcile (prune=true) replaces the catalog: status:2 is gone.
|
|
115
|
+
replaceJiraColumns(db, [
|
|
116
|
+
{ id: 'status:1', name: 'To Do', position: 0, statusIds: ['1'], source: 'status' },
|
|
117
|
+
])
|
|
118
|
+
expect(getCachedColumns(db).map((c) => c.id)).toEqual(['status:1'])
|
|
119
|
+
})
|
|
120
|
+
|
|
65
121
|
test('decodeColumnStatusIds handles 3-element array without ordering loss', () => {
|
|
66
122
|
expect(decodeColumnStatusIds({ status_ids: JSON.stringify(['c', 'a', 'b']) })).toEqual([
|
|
67
123
|
'c',
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { buildDeltaJql, assertSafeProjectKey, safeDeltaSince } from '../providers/jira-jql'
|
|
3
|
+
import { KanbanError } from '../errors'
|
|
4
|
+
|
|
5
|
+
describe('buildDeltaJql', () => {
|
|
6
|
+
test('preserves the existing query format for a full scan (null since)', () => {
|
|
7
|
+
expect(buildDeltaJql('ENG', null)).toBe(
|
|
8
|
+
'project = ENG AND updated >= "1970-01-01 00:00" ORDER BY updated ASC',
|
|
9
|
+
)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('interpolates a valid ISO timestamp unchanged', () => {
|
|
13
|
+
expect(buildDeltaJql('ENG', '2026-01-05T00:00:00Z')).toBe(
|
|
14
|
+
'project = ENG AND updated >= "2026-01-05T00:00:00Z" ORDER BY updated ASC',
|
|
15
|
+
)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('accepts the ISO offset form Jira returns in issue.fields.updated', () => {
|
|
19
|
+
expect(buildDeltaJql('ENG', '2026-06-08T12:34:56.789+0000')).toContain(
|
|
20
|
+
'updated >= "2026-06-08T12:34:56.789+0000"',
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('rejects an injection attempt in the since cursor and falls back to a full scan', () => {
|
|
25
|
+
const malicious = '2026-01-01" OR project = OTHER ORDER BY updated ASC -- '
|
|
26
|
+
expect(buildDeltaJql('ENG', malicious)).toBe(
|
|
27
|
+
'project = ENG AND updated >= "1970-01-01 00:00" ORDER BY updated ASC',
|
|
28
|
+
)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('rejects an invalid project key', () => {
|
|
32
|
+
expect(() => buildDeltaJql('ENG" OR 1=1', null)).toThrow(KanbanError)
|
|
33
|
+
expect(() => assertSafeProjectKey('has space')).toThrow(KanbanError)
|
|
34
|
+
expect(assertSafeProjectKey('ENG')).toBe('ENG')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('safeDeltaSince', () => {
|
|
39
|
+
test('passes through valid cursors and null', () => {
|
|
40
|
+
expect(safeDeltaSince(null)).toBeNull()
|
|
41
|
+
expect(safeDeltaSince('2026-01-05T00:00:00Z')).toBe('2026-01-05T00:00:00Z')
|
|
42
|
+
expect(safeDeltaSince('1970-01-01 00:00')).toBe('1970-01-01 00:00')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('nulls out an unsafe cursor so it is not carried forward or re-persisted', () => {
|
|
46
|
+
// A lexicographically-high injection value would otherwise survive the
|
|
47
|
+
// `issue.fields.updated > newestUpdatedAt` comparison and re-persist itself,
|
|
48
|
+
// trapping every future sync into a full scan.
|
|
49
|
+
expect(safeDeltaSince('9999" OR project = OTHER')).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -845,4 +845,54 @@ describe('JiraProvider read path', () => {
|
|
|
845
845
|
saveTeamInfo(db, null)
|
|
846
846
|
expect(loadTeamInfo(db)).toBeNull()
|
|
847
847
|
})
|
|
848
|
+
|
|
849
|
+
// Regression: in server (background-managed) mode, request-path reads must serve
|
|
850
|
+
// the warm cache instead of blocking on a Jira sync — otherwise a stale-throttle
|
|
851
|
+
// read triggers a foreground network sync that can exceed the HTTP idle timeout
|
|
852
|
+
// (observed as /api/board ERR_EMPTY_RESPONSE).
|
|
853
|
+
const seedSyncedMeta = () =>
|
|
854
|
+
saveJiraSyncMeta(db, {
|
|
855
|
+
projectKey: 'ENG',
|
|
856
|
+
lastSyncAt: '2026-01-01T00:00:00.000Z',
|
|
857
|
+
lastFullSyncAt: '2026-01-01T00:00:00.000Z',
|
|
858
|
+
lastIssueUpdatedAt: '2026-01-01T00:00:00.000Z',
|
|
859
|
+
})
|
|
860
|
+
const wellPastThrottle = () => {
|
|
861
|
+
Date.now = () => Date.parse('2026-01-01T01:00:00.000Z') // +1h, throttle long expired
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
test('background-managed reads do NOT foreground-sync once the cache is warm', async () => {
|
|
865
|
+
const { provider, calls } = makeProvider(standardRoutes({}))
|
|
866
|
+
provider.setBackgroundManaged(true)
|
|
867
|
+
seedSyncedMeta()
|
|
868
|
+
wellPastThrottle()
|
|
869
|
+
|
|
870
|
+
await provider.listTasks()
|
|
871
|
+
|
|
872
|
+
expect(calls.some((c) => c.url.includes('/rest/api/3/search/jql'))).toBe(false)
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
test('non-managed reads still foreground-sync when the throttle has expired', async () => {
|
|
876
|
+
const { provider, calls } = makeProvider(standardRoutes({}))
|
|
877
|
+
// backgroundManaged left false (CLI / default)
|
|
878
|
+
seedSyncedMeta()
|
|
879
|
+
wellPastThrottle()
|
|
880
|
+
|
|
881
|
+
await provider.listTasks()
|
|
882
|
+
|
|
883
|
+
expect(calls.some((c) => c.url.includes('/rest/api/3/search/jql'))).toBe(true)
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
test('background warmer (syncCache) still syncs when background-managed', async () => {
|
|
887
|
+
const { provider, calls } = makeProvider(standardRoutes({}))
|
|
888
|
+
provider.setBackgroundManaged(true)
|
|
889
|
+
seedSyncedMeta()
|
|
890
|
+
wellPastThrottle()
|
|
891
|
+
|
|
892
|
+
// syncCache bypasses the managed read-path gate (viaWarmer), so the warmer
|
|
893
|
+
// keeps refreshing the cache even though plain reads would be suppressed.
|
|
894
|
+
await provider.syncCache()
|
|
895
|
+
|
|
896
|
+
expect(calls.some((c) => c.url.includes('/rest/api/3/search/jql'))).toBe(true)
|
|
897
|
+
})
|
|
848
898
|
})
|
|
@@ -85,7 +85,7 @@ function startTrackerServer(
|
|
|
85
85
|
url,
|
|
86
86
|
async close() {
|
|
87
87
|
await tracker.close()
|
|
88
|
-
httpServer.stop(true)
|
|
88
|
+
void httpServer.stop(true)
|
|
89
89
|
},
|
|
90
90
|
}
|
|
91
91
|
}
|
|
@@ -246,7 +246,7 @@ describe('createTrackerMcpServer', () => {
|
|
|
246
246
|
message: 'Tracker MCP server is closed',
|
|
247
247
|
})
|
|
248
248
|
} finally {
|
|
249
|
-
runtime.httpServer.stop(true)
|
|
249
|
+
void runtime.httpServer.stop(true)
|
|
250
250
|
}
|
|
251
251
|
})
|
|
252
252
|
})
|