@andypai/agent-kanban 0.7.0 → 0.8.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.
- package/README.md +13 -8
- package/package.json +1 -1
- package/src/__tests__/api.test.ts +220 -0
- package/src/__tests__/cli-task-lifecycle-matrix.test.ts +1262 -0
- package/src/__tests__/index.test.ts +107 -3
- package/src/__tests__/jira-wiring.test.ts +25 -2
- package/src/__tests__/server.test.ts +78 -0
- package/src/__tests__/sync-config.test.ts +14 -1
- package/src/__tests__/tracker-config-webhook-secret.test.ts +89 -0
- package/src/__tests__/transport-input.test.ts +67 -1
- package/src/__tests__/tunnel.test.ts +116 -0
- package/src/__tests__/webhook-events-receipts.test.ts +155 -0
- package/src/__tests__/webhooks.test.ts +47 -1
- package/src/api.ts +53 -15
- package/src/db.ts +18 -1
- package/src/index.ts +76 -17
- package/src/providers/factory.ts +1 -1
- package/src/providers/jira-core.ts +9 -3
- package/src/providers/linear-core.ts +9 -3
- package/src/providers/local.ts +2 -2
- package/src/providers/sqlite-local-store.ts +5 -1
- package/src/sync-config.ts +7 -2
- package/src/tracker-config.ts +72 -4
- package/src/transport-input.ts +46 -14
- package/src/tunnel.ts +22 -5
package/README.md
CHANGED
|
@@ -20,11 +20,11 @@ That buys you a few things that are easy to miss at first:
|
|
|
20
20
|
|
|
21
21
|
## Documentation
|
|
22
22
|
|
|
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
|
|
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
|
|
23
|
+
- [`docs/readme.md`](https://github.com/abpai/agent-kanban/blob/main/docs/readme.md) for the documentation index
|
|
24
|
+
- [`docs/workflow.md`](https://github.com/abpai/agent-kanban/blob/main/docs/workflow.md) for a common day-to-day workflow
|
|
25
|
+
- [`docs/mcp.md`](https://github.com/abpai/agent-kanban/blob/main/docs/mcp.md) for the reusable tracker MCP module
|
|
26
|
+
- [`docs/providers/linear.md`](https://github.com/abpai/agent-kanban/blob/main/docs/providers/linear.md) for Linear provider details
|
|
27
|
+
- [`docs/providers/jira.md`](https://github.com/abpai/agent-kanban/blob/main/docs/providers/jira.md) for Jira provider details
|
|
28
28
|
- [`SKILL.md`](SKILL.md) for agent-specific repo usage instructions
|
|
29
29
|
|
|
30
30
|
## Install
|
|
@@ -176,6 +176,8 @@ Default columns: `recurring`, `backlog`, `in-progress`, `review`, `done`.
|
|
|
176
176
|
| `-p <level>` | Priority: `low`, `medium`, `high`, `urgent` (default: `medium`) |
|
|
177
177
|
| `-a <user>` | Assignee |
|
|
178
178
|
| `--project <name>` | Project tag |
|
|
179
|
+
| `--label <name>` | Label; repeatable and comma-separated values are accepted |
|
|
180
|
+
| `--labels <names>` | Label alias; repeatable and comma-separated values are accepted |
|
|
179
181
|
| `-m <json>` | Arbitrary metadata (must be valid JSON) |
|
|
180
182
|
|
|
181
183
|
**Flags for `task list`:**
|
|
@@ -286,7 +288,8 @@ kanban mcp --db /path/to/board.db
|
|
|
286
288
|
```
|
|
287
289
|
|
|
288
290
|
Runs the bundled MCP server over stdio for local MCP clients such as Claude
|
|
289
|
-
Desktop. See [`docs/mcp.md`](docs/mcp.md)
|
|
291
|
+
Desktop. See [`docs/mcp.md`](https://github.com/abpai/agent-kanban/blob/main/docs/mcp.md)
|
|
292
|
+
for the tool surface and caveats.
|
|
290
293
|
|
|
291
294
|
## Global flags
|
|
292
295
|
|
|
@@ -350,7 +353,8 @@ There are two ways to use it today:
|
|
|
350
353
|
- run `kanban mcp` for a bundled stdio MCP server
|
|
351
354
|
- import the helpers in `src/mcp/` from a sibling workspace or in-repo consumer
|
|
352
355
|
|
|
353
|
-
See [`docs/mcp.md`](docs/mcp.md)
|
|
356
|
+
See [`docs/mcp.md`](https://github.com/abpai/agent-kanban/blob/main/docs/mcp.md)
|
|
357
|
+
for the current default tool set, the auth and
|
|
354
358
|
policy model, and the caveats around source-level imports and `kanban serve`.
|
|
355
359
|
|
|
356
360
|
## Scripts
|
|
@@ -435,7 +439,8 @@ If you want to contribute or report an issue, start with these guides:
|
|
|
435
439
|
- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
|
|
436
440
|
- [SECURITY.md](SECURITY.md)
|
|
437
441
|
|
|
438
|
-
Longer product and workflow docs live under
|
|
442
|
+
Longer product and workflow docs live under
|
|
443
|
+
[`docs/`](https://github.com/abpai/agent-kanban/blob/main/docs/readme.md).
|
|
439
444
|
|
|
440
445
|
## License
|
|
441
446
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andypai/agent-kanban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structured JSON output.",
|
|
5
5
|
"homepage": "https://github.com/abpai/agent-kanban#readme",
|
|
6
6
|
"repository": {
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
import { Database } from 'bun:sqlite'
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
3
6
|
import { initSchema, seedDefaultColumns, addTask } from '../db'
|
|
7
|
+
import { KanbanError, ErrorCode } from '../errors'
|
|
4
8
|
import { handleRequest } from '../api'
|
|
5
9
|
import { createProvider } from '../providers/index'
|
|
6
10
|
import type { KanbanProvider } from '../providers/types'
|
|
@@ -223,4 +227,220 @@ describe('handleRequest', () => {
|
|
|
223
227
|
expect(body.data.provider).toBe('local')
|
|
224
228
|
expect(body.data.capabilities.taskDelete).toBe(true)
|
|
225
229
|
})
|
|
230
|
+
|
|
231
|
+
test('F22: GET /api/activity returns an ok envelope wrapping an array', async () => {
|
|
232
|
+
addTask(db, 'Generates activity')
|
|
233
|
+
const req = new Request('http://localhost/api/activity?limit=5', { method: 'GET' })
|
|
234
|
+
const result = await handleRequest(provider, req)
|
|
235
|
+
const body = (await result.response.json()) as { ok: boolean; data: unknown[] }
|
|
236
|
+
expect(result.response.status).toBe(200)
|
|
237
|
+
expect(result.mutated).toBe(false)
|
|
238
|
+
expect(body.ok).toBe(true)
|
|
239
|
+
expect(Array.isArray(body.data)).toBe(true)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('F22: GET /api/activity rejects an invalid limit through the envelope', async () => {
|
|
243
|
+
const req = new Request('http://localhost/api/activity?limit=0', { method: 'GET' })
|
|
244
|
+
const result = await handleRequest(provider, req)
|
|
245
|
+
const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
|
|
246
|
+
expect(result.response.status).toBe(400)
|
|
247
|
+
expect(body.error.code).toBe('INVALID_ARGUMENT')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('F23: GET /api/metrics returns the metrics envelope', async () => {
|
|
251
|
+
const req = new Request('http://localhost/api/metrics', { method: 'GET' })
|
|
252
|
+
const result = await handleRequest(provider, req)
|
|
253
|
+
const body = (await result.response.json()) as {
|
|
254
|
+
ok: boolean
|
|
255
|
+
data: { totalTasks: number; tasksByColumn: unknown[] }
|
|
256
|
+
}
|
|
257
|
+
expect(result.response.status).toBe(200)
|
|
258
|
+
expect(result.mutated).toBe(false)
|
|
259
|
+
expect(body.ok).toBe(true)
|
|
260
|
+
expect(typeof body.data.totalTasks).toBe('number')
|
|
261
|
+
expect(Array.isArray(body.data.tasksByColumn)).toBe(true)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('F24: GET /api/config returns config', async () => {
|
|
265
|
+
const getReq = new Request('http://localhost/api/config', { method: 'GET' })
|
|
266
|
+
const getRes = await handleRequest(provider, getReq)
|
|
267
|
+
const getBody = (await getRes.response.json()) as { ok: boolean; data: { provider: string } }
|
|
268
|
+
expect(getRes.response.status).toBe(200)
|
|
269
|
+
expect(getRes.mutated).toBe(false)
|
|
270
|
+
expect(getBody.data.provider).toBe('local')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('F24: PATCH /api/config mutates without a precise WsEvent', async () => {
|
|
274
|
+
// PATCH persists the config sidecar (config.json) next to the db path, so use
|
|
275
|
+
// a hermetic temp dir instead of the shared ':memory:' provider, which would
|
|
276
|
+
// write ./config.json into the checkout.
|
|
277
|
+
const dir = mkdtempSync(join(tmpdir(), 'kanban-api-config-'))
|
|
278
|
+
const cfgDb = new Database(':memory:')
|
|
279
|
+
cfgDb.run('PRAGMA foreign_keys = ON')
|
|
280
|
+
initSchema(cfgDb)
|
|
281
|
+
seedDefaultColumns(cfgDb)
|
|
282
|
+
const cfgProvider = createProvider(cfgDb, { provider: 'local' }, join(dir, 'board.db'))
|
|
283
|
+
try {
|
|
284
|
+
const patchReq = new Request('http://localhost/api/config', {
|
|
285
|
+
method: 'PATCH',
|
|
286
|
+
headers: { 'Content-Type': 'application/json' },
|
|
287
|
+
body: JSON.stringify({ members: [{ name: 'alice', role: 'human' }] }),
|
|
288
|
+
})
|
|
289
|
+
const patchRes = await handleRequest(cfgProvider, patchReq)
|
|
290
|
+
const patchBody = (await patchRes.response.json()) as {
|
|
291
|
+
ok: boolean
|
|
292
|
+
data: { members: { name: string }[] }
|
|
293
|
+
}
|
|
294
|
+
expect(patchRes.response.status).toBe(200)
|
|
295
|
+
expect(patchRes.mutated).toBe(true)
|
|
296
|
+
// No precise WsEvent → the server falls back to a 'refresh' broadcast.
|
|
297
|
+
expect(patchRes.event).toBeUndefined()
|
|
298
|
+
expect(patchBody.data.members.map((m) => m.name)).toContain('alice')
|
|
299
|
+
} finally {
|
|
300
|
+
cfgDb.close()
|
|
301
|
+
rmSync(dir, { recursive: true, force: true })
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
// Minimal provider whose only relevant field is `type` plus an overridable
|
|
307
|
+
// handleWebhook — the webhook branch of handleRequest is the surface under test.
|
|
308
|
+
function webhookProvider(
|
|
309
|
+
type: string,
|
|
310
|
+
handleWebhook?: KanbanProvider['handleWebhook'],
|
|
311
|
+
): KanbanProvider {
|
|
312
|
+
const p: Partial<KanbanProvider> = { type: type as KanbanProvider['type'] }
|
|
313
|
+
if (handleWebhook) p.handleWebhook = handleWebhook
|
|
314
|
+
return p as KanbanProvider
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function webhookRequest(target: string): Request {
|
|
318
|
+
return new Request(`http://localhost/api/webhooks/${target}`, {
|
|
319
|
+
method: 'POST',
|
|
320
|
+
headers: { 'Content-Type': 'application/json' },
|
|
321
|
+
body: '{}',
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
describe('handleRequest webhook route (F25)', () => {
|
|
326
|
+
test('target that does not match the active provider → 400 UNSUPPORTED_OPERATION, not mutated', async () => {
|
|
327
|
+
const result = await handleRequest(
|
|
328
|
+
webhookProvider('local', async () => ({ handled: true })),
|
|
329
|
+
webhookRequest('jira'),
|
|
330
|
+
)
|
|
331
|
+
const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
|
|
332
|
+
expect(result.response.status).toBe(400)
|
|
333
|
+
expect(result.mutated).toBe(false)
|
|
334
|
+
expect(body.error.code).toBe('UNSUPPORTED_OPERATION')
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
test('provider without handleWebhook → 400 UNSUPPORTED_OPERATION', async () => {
|
|
338
|
+
const result = await handleRequest(webhookProvider('local'), webhookRequest('local'))
|
|
339
|
+
const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
|
|
340
|
+
expect(result.response.status).toBe(400)
|
|
341
|
+
expect(result.mutated).toBe(false)
|
|
342
|
+
expect(body.error.code).toBe('UNSUPPORTED_OPERATION')
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test('unauthorized result → 401 PROVIDER_AUTH_FAILED, not mutated', async () => {
|
|
346
|
+
const result = await handleRequest(
|
|
347
|
+
webhookProvider('local', async () => ({ handled: false, unauthorized: true })),
|
|
348
|
+
webhookRequest('local'),
|
|
349
|
+
)
|
|
350
|
+
const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
|
|
351
|
+
expect(result.response.status).toBe(401)
|
|
352
|
+
expect(result.mutated).toBe(false)
|
|
353
|
+
expect(body.error.code).toBe('PROVIDER_AUTH_FAILED')
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
test('handled result → 200, mutated true', async () => {
|
|
357
|
+
const result = await handleRequest(
|
|
358
|
+
webhookProvider('local', async () => ({ handled: true, message: 'ok' })),
|
|
359
|
+
webhookRequest('local'),
|
|
360
|
+
)
|
|
361
|
+
const body = (await result.response.json()) as { ok: boolean; data: { handled: boolean } }
|
|
362
|
+
expect(result.response.status).toBe(200)
|
|
363
|
+
expect(result.mutated).toBe(true)
|
|
364
|
+
expect(body.data.handled).toBe(true)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('skipped (handled:false) result → 200, NOT mutated (no broadcast)', async () => {
|
|
368
|
+
const result = await handleRequest(
|
|
369
|
+
webhookProvider('local', async () => ({ handled: false, message: 'ignored' })),
|
|
370
|
+
webhookRequest('local'),
|
|
371
|
+
)
|
|
372
|
+
expect(result.response.status).toBe(200)
|
|
373
|
+
expect(result.mutated).toBe(false)
|
|
374
|
+
})
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
describe('handleRequest webhook route error containment (F55 regression)', () => {
|
|
378
|
+
test('a throwing handleWebhook is enveloped as 500 INTERNAL_ERROR, never escapes as a rejection', async () => {
|
|
379
|
+
const provider = webhookProvider('local', async () => {
|
|
380
|
+
throw new Error('boom from provider.handleWebhook')
|
|
381
|
+
})
|
|
382
|
+
// Must NOT reject — before the fix this threw out of handleRequest.
|
|
383
|
+
const result = await handleRequest(provider, webhookRequest('local'))
|
|
384
|
+
const body = (await result.response.json()) as {
|
|
385
|
+
ok: boolean
|
|
386
|
+
error: { code: string; message: string }
|
|
387
|
+
}
|
|
388
|
+
expect(result.response.status).toBe(500)
|
|
389
|
+
expect(result.mutated).toBe(false)
|
|
390
|
+
expect(body.ok).toBe(false)
|
|
391
|
+
expect(body.error.code).toBe('INTERNAL_ERROR')
|
|
392
|
+
expect(body.error.message).toContain('boom')
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
test('a thrown KanbanError keeps its mapped status + code through the envelope', async () => {
|
|
396
|
+
const provider = webhookProvider('local', async () => {
|
|
397
|
+
throw new KanbanError(ErrorCode.CONFLICT, 'version conflict during webhook apply')
|
|
398
|
+
})
|
|
399
|
+
const result = await handleRequest(provider, webhookRequest('local'))
|
|
400
|
+
const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
|
|
401
|
+
expect(result.response.status).toBe(409)
|
|
402
|
+
expect(result.mutated).toBe(false)
|
|
403
|
+
expect(body.error.code).toBe('CONFLICT')
|
|
404
|
+
})
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
describe('handleRequest malformed path encoding (D2 regression)', () => {
|
|
408
|
+
test('malformed %-encoding in a task id → 400 INVALID_ARGUMENT, never thrown', async () => {
|
|
409
|
+
const req = new Request('http://localhost/api/tasks/%E0%A4%A', { method: 'GET' })
|
|
410
|
+
// Must not reject — before the fix decodeURIComponent threw a URIError that
|
|
411
|
+
// escaped handleRequest.
|
|
412
|
+
const result = await handleRequest(provider, req)
|
|
413
|
+
const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
|
|
414
|
+
expect(result.response.status).toBe(400)
|
|
415
|
+
expect(result.mutated).toBe(false)
|
|
416
|
+
expect(body.error.code).toBe('INVALID_ARGUMENT')
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
test('malformed %-encoding in a webhook target → 400 INVALID_ARGUMENT', async () => {
|
|
420
|
+
const result = await handleRequest(webhookProvider('local'), webhookRequest('%E0%A4%A'))
|
|
421
|
+
const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
|
|
422
|
+
expect(result.response.status).toBe(400)
|
|
423
|
+
expect(result.mutated).toBe(false)
|
|
424
|
+
expect(body.error.code).toBe('INVALID_ARGUMENT')
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
describe('statusForCode server-side mapping (D3 regression)', () => {
|
|
429
|
+
const cases: { code: keyof typeof ErrorCode; status: number }[] = [
|
|
430
|
+
{ code: 'PROVIDER_UPSTREAM_ERROR', status: 502 },
|
|
431
|
+
{ code: 'PROVIDER_SYNC_REQUIRED', status: 503 },
|
|
432
|
+
{ code: 'INTERNAL_ERROR', status: 500 },
|
|
433
|
+
]
|
|
434
|
+
for (const { code, status } of cases) {
|
|
435
|
+
test(`${code} → ${status} (not the default 400)`, async () => {
|
|
436
|
+
const provider = webhookProvider('local', async () => {
|
|
437
|
+
throw new KanbanError(ErrorCode[code], `${code} from provider`)
|
|
438
|
+
})
|
|
439
|
+
const result = await handleRequest(provider, webhookRequest('local'))
|
|
440
|
+
const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
|
|
441
|
+
expect(result.response.status).toBe(status)
|
|
442
|
+
expect(body.error.code).toBe(code)
|
|
443
|
+
expect(result.mutated).toBe(false)
|
|
444
|
+
})
|
|
445
|
+
}
|
|
226
446
|
})
|