@andypai/agent-kanban 0.7.0 → 0.8.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 +22 -12
- package/package.json +8 -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 +54 -16
- package/src/db.ts +18 -1
- package/src/index.ts +76 -17
- package/src/mcp/index.ts +2 -10
- package/src/mcp/types.ts +1 -1
- package/src/metrics-spec.ts +1 -1
- package/src/providers/factory.ts +1 -1
- package/src/providers/jira-adf.ts +8 -11
- package/src/providers/jira-client.ts +2 -2
- package/src/providers/jira-core.ts +12 -6
- package/src/providers/linear-cache.ts +4 -4
- package/src/providers/linear-client.ts +1 -1
- package/src/providers/linear-core.ts +9 -3
- package/src/providers/local.ts +2 -2
- package/src/providers/postgres-linear-cache.ts +1 -1
- package/src/providers/sqlite-local-store.ts +5 -1
- package/src/storage-config.ts +2 -4
- package/src/sync-config.ts +7 -2
- package/src/tracker-config.ts +74 -6
- package/src/transport-input.ts +46 -14
- package/src/tunnel.ts +22 -5
package/README.md
CHANGED
|
@@ -20,11 +20,12 @@ That buys you a few things that are easy to miss at first:
|
|
|
20
20
|
|
|
21
21
|
## Documentation
|
|
22
22
|
|
|
23
|
-
- [`docs/
|
|
24
|
-
- [`docs/
|
|
25
|
-
- [`docs/
|
|
26
|
-
- [`docs/
|
|
27
|
-
- [`docs/providers/
|
|
23
|
+
- [`docs/INDEX.md`](https://github.com/abpai/agent-kanban/blob/main/docs/INDEX.md) for the documentation index
|
|
24
|
+
- [`docs/SPEC_CONTRACT.md`](https://github.com/abpai/agent-kanban/blob/main/docs/SPEC_CONTRACT.md) for the repo proof menu and spec contract
|
|
25
|
+
- [`docs/workflow.md`](https://github.com/abpai/agent-kanban/blob/main/docs/workflow.md) for a common day-to-day workflow
|
|
26
|
+
- [`docs/mcp.md`](https://github.com/abpai/agent-kanban/blob/main/docs/mcp.md) for the reusable tracker MCP module
|
|
27
|
+
- [`docs/providers/linear.md`](https://github.com/abpai/agent-kanban/blob/main/docs/providers/linear.md) for Linear provider details
|
|
28
|
+
- [`docs/providers/jira.md`](https://github.com/abpai/agent-kanban/blob/main/docs/providers/jira.md) for Jira provider details
|
|
28
29
|
- [`SKILL.md`](SKILL.md) for agent-specific repo usage instructions
|
|
29
30
|
|
|
30
31
|
## Install
|
|
@@ -176,6 +177,8 @@ Default columns: `recurring`, `backlog`, `in-progress`, `review`, `done`.
|
|
|
176
177
|
| `-p <level>` | Priority: `low`, `medium`, `high`, `urgent` (default: `medium`) |
|
|
177
178
|
| `-a <user>` | Assignee |
|
|
178
179
|
| `--project <name>` | Project tag |
|
|
180
|
+
| `--label <name>` | Label; repeatable and comma-separated values are accepted |
|
|
181
|
+
| `--labels <names>` | Label alias; repeatable and comma-separated values are accepted |
|
|
179
182
|
| `-m <json>` | Arbitrary metadata (must be valid JSON) |
|
|
180
183
|
|
|
181
184
|
**Flags for `task list`:**
|
|
@@ -269,14 +272,18 @@ compatibility. To require authentication, set a token via `--token` or the
|
|
|
269
272
|
accepts `?token=<token>` instead.
|
|
270
273
|
- `/api/health` stays public (liveness probe).
|
|
271
274
|
- `/api/webhooks/*` are **not** covered by this token — they authenticate with
|
|
272
|
-
the provider webhook secret
|
|
275
|
+
the provider webhook secret when one is configured. Without that secret,
|
|
276
|
+
Linear/Jira webhooks run in local open dev mode and accept unsigned payloads.
|
|
273
277
|
- The bundled UI picks the token up once from `?token=` / `#token=` and stores
|
|
274
278
|
it in `localStorage`, so you can open `https://<host>/?token=<token>`.
|
|
275
279
|
|
|
276
280
|
`--tunnel` exposes the dashboard publicly, so it **refuses to start without a
|
|
277
|
-
token**.
|
|
278
|
-
|
|
279
|
-
|
|
281
|
+
token**. In Linear or Jira mode it also refuses to start unless the matching
|
|
282
|
+
webhook secret is set, so auth-exempt webhook routes cannot accept unsigned
|
|
283
|
+
public writes. CORS is off by default (same-origin); set
|
|
284
|
+
`KANBAN_ALLOWED_ORIGIN` (or `--allowed-origin`) to allow a specific cross-origin
|
|
285
|
+
browser client. CORS is origin hygiene, not an auth control — the token is the
|
|
286
|
+
security boundary.
|
|
280
287
|
|
|
281
288
|
### mcp
|
|
282
289
|
|
|
@@ -286,7 +293,8 @@ kanban mcp --db /path/to/board.db
|
|
|
286
293
|
```
|
|
287
294
|
|
|
288
295
|
Runs the bundled MCP server over stdio for local MCP clients such as Claude
|
|
289
|
-
Desktop. See [`docs/mcp.md`](docs/mcp.md)
|
|
296
|
+
Desktop. See [`docs/mcp.md`](https://github.com/abpai/agent-kanban/blob/main/docs/mcp.md)
|
|
297
|
+
for the tool surface and caveats.
|
|
290
298
|
|
|
291
299
|
## Global flags
|
|
292
300
|
|
|
@@ -350,7 +358,8 @@ There are two ways to use it today:
|
|
|
350
358
|
- run `kanban mcp` for a bundled stdio MCP server
|
|
351
359
|
- import the helpers in `src/mcp/` from a sibling workspace or in-repo consumer
|
|
352
360
|
|
|
353
|
-
See [`docs/mcp.md`](docs/mcp.md)
|
|
361
|
+
See [`docs/mcp.md`](https://github.com/abpai/agent-kanban/blob/main/docs/mcp.md)
|
|
362
|
+
for the current default tool set, the auth and
|
|
354
363
|
policy model, and the caveats around source-level imports and `kanban serve`.
|
|
355
364
|
|
|
356
365
|
## Scripts
|
|
@@ -435,7 +444,8 @@ If you want to contribute or report an issue, start with these guides:
|
|
|
435
444
|
- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
|
|
436
445
|
- [SECURITY.md](SECURITY.md)
|
|
437
446
|
|
|
438
|
-
Longer product and workflow docs live under
|
|
447
|
+
Longer product and workflow docs live under
|
|
448
|
+
[`docs/`](https://github.com/abpai/agent-kanban/blob/main/docs/readme.md).
|
|
439
449
|
|
|
440
450
|
## License
|
|
441
451
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andypai/agent-kanban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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": {
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
"access": "public"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
|
+
"bootstrap": "bun install && cd ui && bun install",
|
|
49
|
+
"smoke": "bun src/index.ts --help",
|
|
48
50
|
"dev": "bun --watch src/index.ts",
|
|
49
51
|
"start": "bun src/index.ts",
|
|
50
52
|
"build": "bun build ./src/index.ts --target bun --outdir ./dist",
|
|
@@ -56,6 +58,10 @@
|
|
|
56
58
|
"check": "bun run lint && bun run typecheck && bun run ui:typecheck",
|
|
57
59
|
"test": "bun test",
|
|
58
60
|
"test:watch": "bun test --watch",
|
|
61
|
+
"knip": "knip",
|
|
62
|
+
"test:pg": "DATABASE_URL=${DATABASE_URL:-postgres://postgres:postgres@localhost:${KANBAN_PG_PORT:-5432}/kanban_test} bun test",
|
|
63
|
+
"pg:up": "docker compose -f docker-compose.postgres.yml up -d --wait",
|
|
64
|
+
"pg:down": "docker compose -f docker-compose.postgres.yml down -v",
|
|
59
65
|
"seed:test": "bun --env-file=.env.test scripts/seed-test-db.ts",
|
|
60
66
|
"serve": "bun src/index.ts serve",
|
|
61
67
|
"ui:dev": "cd ui && bun run dev",
|
|
@@ -83,6 +89,7 @@
|
|
|
83
89
|
"eslint-config-prettier": "^10.1.8",
|
|
84
90
|
"eslint-plugin-prettier": "^5.5.4",
|
|
85
91
|
"husky": "^8.0.3",
|
|
92
|
+
"knip": "^6.24.0",
|
|
86
93
|
"lint-staged": "^15.2.0",
|
|
87
94
|
"prettier": "^3.7.4",
|
|
88
95
|
"typescript": "^5.7.2"
|
|
@@ -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
|
})
|