@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 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) for the tool surface and caveats.
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) for the current default tool set, the auth and
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 [`docs/`](docs/readme.md).
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.7.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
  })