@andypai/agent-kanban 0.5.1 → 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.
Files changed (52) hide show
  1. package/README.md +42 -19
  2. package/package.json +3 -2
  3. package/src/__tests__/activity.test.ts +12 -0
  4. package/src/__tests__/api.test.ts +4 -2
  5. package/src/__tests__/board-utils.test.ts +16 -3
  6. package/src/__tests__/column-roles.test.ts +34 -0
  7. package/src/__tests__/commands/board.test.ts +7 -0
  8. package/src/__tests__/conflict.test.ts +11 -1
  9. package/src/__tests__/db.test.ts +70 -0
  10. package/src/__tests__/index.test.ts +55 -0
  11. package/src/__tests__/jira-cache.test.ts +56 -0
  12. package/src/__tests__/jira-client.test.ts +98 -1
  13. package/src/__tests__/jira-jql.test.ts +51 -0
  14. package/src/__tests__/jira-provider-mutations.test.ts +84 -59
  15. package/src/__tests__/jira-provider-read.test.ts +227 -20
  16. package/src/__tests__/mcp-server.test.ts +2 -2
  17. package/src/__tests__/metrics.test.ts +37 -1
  18. package/src/__tests__/postgres-jira-provider.test.ts +155 -0
  19. package/src/__tests__/postgres-local-provider.test.ts +90 -1
  20. package/src/__tests__/server.test.ts +126 -0
  21. package/src/__tests__/use-cases.test.ts +77 -0
  22. package/src/__tests__/webhooks.test.ts +75 -22
  23. package/src/api.ts +58 -36
  24. package/src/column-roles.ts +52 -0
  25. package/src/commands/board.ts +4 -4
  26. package/src/db.ts +145 -114
  27. package/src/errors.ts +1 -0
  28. package/src/index.ts +84 -33
  29. package/src/mcp/core.ts +8 -7
  30. package/src/metrics.ts +48 -23
  31. package/src/provider-runtime.ts +9 -2
  32. package/src/providers/index.ts +4 -1
  33. package/src/providers/jira-cache.ts +23 -6
  34. package/src/providers/jira-client.ts +70 -4
  35. package/src/providers/jira-core.ts +921 -0
  36. package/src/providers/jira-jql.ts +48 -0
  37. package/src/providers/jira.ts +111 -677
  38. package/src/providers/linear-cache.ts +5 -3
  39. package/src/providers/linear-core.ts +688 -0
  40. package/src/providers/linear.ts +70 -554
  41. package/src/providers/postgres-jira-cache.ts +597 -0
  42. package/src/providers/postgres-jira.ts +15 -1188
  43. package/src/providers/postgres-linear-cache.ts +500 -0
  44. package/src/providers/postgres-linear.ts +22 -1088
  45. package/src/providers/postgres-local.ts +181 -74
  46. package/src/providers/types.ts +71 -2
  47. package/src/server.ts +118 -32
  48. package/src/use-cases.ts +139 -0
  49. package/src/webhooks.ts +26 -0
  50. package/ui/dist/assets/index-65LcV0R7.js +40 -0
  51. package/ui/dist/index.html +1 -1
  52. package/ui/dist/assets/index-CFhtfqCn.js +0 -40
@@ -27,6 +27,18 @@ function hmac(secret: string, body: string): string {
27
27
  return createHmac('sha256', secret).update(body).digest('hex')
28
28
  }
29
29
 
30
+ const JIRA_TEST_SECRET = 'topsecret'
31
+ const LINEAR_TEST_SECRET = 'hushhush'
32
+
33
+ // Sign for both providers; each handler only reads its own header, so this works
34
+ // regardless of which provider the test exercises.
35
+ function signedHeaders(body: string): Record<string, string> {
36
+ return {
37
+ 'x-hub-signature': `sha256=${hmac(JIRA_TEST_SECRET, body)}`,
38
+ 'linear-signature': hmac(LINEAR_TEST_SECRET, body),
39
+ }
40
+ }
41
+
30
42
  function jsonResponse(body: unknown, status = 200): Response {
31
43
  return new Response(JSON.stringify(body), {
32
44
  status,
@@ -108,7 +120,7 @@ describe('Jira webhook', () => {
108
120
  test('issue_created upserts the task', async () => {
109
121
  const db = new Database(':memory:')
110
122
  seedJira(db)
111
- delete process.env['JIRA_WEBHOOK_SECRET']
123
+ process.env['JIRA_WEBHOOK_SECRET'] = JIRA_TEST_SECRET
112
124
  const client = new JiraClient({
113
125
  baseUrl: jiraConfig.baseUrl,
114
126
  email: jiraConfig.email,
@@ -133,7 +145,7 @@ describe('Jira webhook', () => {
133
145
  },
134
146
  },
135
147
  })
136
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
148
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
137
149
  expect(result.handled).toBe(true)
138
150
  const tasks = getCachedJiraTasks(db)
139
151
  expect(tasks.find((t) => t.externalRef === 'ENG-100')?.title).toBe('New issue')
@@ -142,7 +154,7 @@ describe('Jira webhook', () => {
142
154
  test('issue_created accepts Jira webhook string descriptions', async () => {
143
155
  const db = new Database(':memory:')
144
156
  seedJira(db)
145
- delete process.env['JIRA_WEBHOOK_SECRET']
157
+ process.env['JIRA_WEBHOOK_SECRET'] = JIRA_TEST_SECRET
146
158
  const client = new JiraClient({
147
159
  baseUrl: jiraConfig.baseUrl,
148
160
  email: jiraConfig.email,
@@ -170,7 +182,7 @@ describe('Jira webhook', () => {
170
182
  },
171
183
  })
172
184
 
173
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
185
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
174
186
 
175
187
  expect(result.handled).toBe(true)
176
188
  expect(getCachedJiraTasks(db).find((t) => t.externalRef === 'ENG-101')?.description).toBe(
@@ -193,7 +205,7 @@ describe('Jira webhook', () => {
193
205
  updatedAt: '2025-02-01',
194
206
  },
195
207
  ])
196
- delete process.env['JIRA_WEBHOOK_SECRET']
208
+ process.env['JIRA_WEBHOOK_SECRET'] = JIRA_TEST_SECRET
197
209
  const client = new JiraClient({
198
210
  baseUrl: jiraConfig.baseUrl,
199
211
  email: jiraConfig.email,
@@ -214,7 +226,7 @@ describe('Jira webhook', () => {
214
226
  },
215
227
  },
216
228
  })
217
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
229
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
218
230
  expect(result.handled).toBe(true)
219
231
  expect(getCachedJiraTasks(db).find((t) => t.externalRef === 'ENG-200')).toBeUndefined()
220
232
  })
@@ -298,7 +310,7 @@ describe('Jira webhook', () => {
298
310
  test('ignores issue updates from other projects', async () => {
299
311
  const db = new Database(':memory:')
300
312
  seedJira(db)
301
- delete process.env['JIRA_WEBHOOK_SECRET']
313
+ process.env['JIRA_WEBHOOK_SECRET'] = JIRA_TEST_SECRET
302
314
  const client = new JiraClient({
303
315
  baseUrl: jiraConfig.baseUrl,
304
316
  email: jiraConfig.email,
@@ -323,7 +335,7 @@ describe('Jira webhook', () => {
323
335
  },
324
336
  })
325
337
 
326
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
338
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
327
339
 
328
340
  expect(result.handled).toBe(false)
329
341
  expect(result.message).toContain('Ignoring issue from project')
@@ -333,7 +345,7 @@ describe('Jira webhook', () => {
333
345
  test('issue_updated backfills activity immediately', async () => {
334
346
  const db = new Database(':memory:')
335
347
  seedJira(db)
336
- delete process.env['JIRA_WEBHOOK_SECRET']
348
+ process.env['JIRA_WEBHOOK_SECRET'] = JIRA_TEST_SECRET
337
349
  const originalFetch = globalThis.fetch
338
350
  globalThis.fetch = (async (input) => {
339
351
  const url =
@@ -381,7 +393,7 @@ describe('Jira webhook', () => {
381
393
  },
382
394
  })
383
395
 
384
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
396
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
385
397
 
386
398
  expect(result.handled).toBe(true)
387
399
  expect(getCachedJiraTasks(db).find((task) => task.externalRef === 'ENG-600')?.column_id).toBe(
@@ -434,7 +446,7 @@ describe('Linear webhook', () => {
434
446
  test('Issue.create upserts the task', async () => {
435
447
  const db = new Database(':memory:')
436
448
  seedLinear(db)
437
- delete process.env['LINEAR_WEBHOOK_SECRET']
449
+ process.env['LINEAR_WEBHOOK_SECRET'] = LINEAR_TEST_SECRET
438
450
  const provider = new LinearProvider(db, 'tid', 'key')
439
451
  const body = JSON.stringify({
440
452
  action: 'create',
@@ -454,7 +466,7 @@ describe('Linear webhook', () => {
454
466
  commentCount: 1,
455
467
  },
456
468
  })
457
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
469
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
458
470
  expect(result.handled).toBe(true)
459
471
  const tasks = getCachedLinearTasks(db)
460
472
  const issue = tasks.find((t) => t.externalRef === 'DX-1')
@@ -479,14 +491,14 @@ describe('Linear webhook', () => {
479
491
  updatedAt: '2025-01',
480
492
  },
481
493
  ])
482
- delete process.env['LINEAR_WEBHOOK_SECRET']
494
+ process.env['LINEAR_WEBHOOK_SECRET'] = LINEAR_TEST_SECRET
483
495
  const provider = new LinearProvider(db, 'tid', 'key')
484
496
  const body = JSON.stringify({
485
497
  action: 'remove',
486
498
  type: 'Issue',
487
499
  data: { id: 'ix', identifier: 'DX-9' },
488
500
  })
489
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
501
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
490
502
  expect(result.handled).toBe(true)
491
503
  expect(getCachedLinearTasks(db).find((t) => t.externalRef === 'DX-9')).toBeUndefined()
492
504
  })
@@ -494,7 +506,7 @@ describe('Linear webhook', () => {
494
506
  test('ignores create/update events from another team', async () => {
495
507
  const db = new Database(':memory:')
496
508
  seedLinear(db)
497
- delete process.env['LINEAR_WEBHOOK_SECRET']
509
+ process.env['LINEAR_WEBHOOK_SECRET'] = LINEAR_TEST_SECRET
498
510
  const provider = new LinearProvider(db, 'tid', 'key')
499
511
  const body = JSON.stringify({
500
512
  action: 'update',
@@ -510,7 +522,7 @@ describe('Linear webhook', () => {
510
522
  },
511
523
  })
512
524
 
513
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
525
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
514
526
 
515
527
  expect(result.handled).toBe(false)
516
528
  expect(result.message).toContain("Ignoring issue from team 'other-team'")
@@ -520,7 +532,7 @@ describe('Linear webhook', () => {
520
532
  test('falls back to issue-team lookup when the webhook payload omits team info', async () => {
521
533
  const db = new Database(':memory:')
522
534
  seedLinear(db)
523
- delete process.env['LINEAR_WEBHOOK_SECRET']
535
+ process.env['LINEAR_WEBHOOK_SECRET'] = LINEAR_TEST_SECRET
524
536
  globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
525
537
  const body = JSON.parse(String(init?.body)) as {
526
538
  query: string
@@ -559,7 +571,7 @@ describe('Linear webhook', () => {
559
571
  },
560
572
  })
561
573
 
562
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
574
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
563
575
 
564
576
  expect(result.handled).toBe(false)
565
577
  expect(result.message).toContain("Ignoring issue from team 'OPS'")
@@ -583,7 +595,7 @@ describe('Linear webhook', () => {
583
595
  updatedAt: '2025-02-01T00:00:00.000Z',
584
596
  },
585
597
  ])
586
- delete process.env['LINEAR_WEBHOOK_SECRET']
598
+ process.env['LINEAR_WEBHOOK_SECRET'] = LINEAR_TEST_SECRET
587
599
  const provider = new LinearProvider(db, 'tid', 'key')
588
600
  const body = JSON.stringify({
589
601
  action: 'update',
@@ -599,7 +611,7 @@ describe('Linear webhook', () => {
599
611
  },
600
612
  })
601
613
 
602
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
614
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
603
615
 
604
616
  expect(result.handled).toBe(true)
605
617
  expect(
@@ -610,7 +622,7 @@ describe('Linear webhook', () => {
610
622
  test('create event stamps lastWebhookAt without clobbering team/lastSyncAt', async () => {
611
623
  const db = new Database(':memory:')
612
624
  seedLinear(db)
613
- delete process.env['LINEAR_WEBHOOK_SECRET']
625
+ process.env['LINEAR_WEBHOOK_SECRET'] = LINEAR_TEST_SECRET
614
626
  const provider = new LinearProvider(db, 'tid', 'key')
615
627
  const body = JSON.stringify({
616
628
  action: 'create',
@@ -626,7 +638,7 @@ describe('Linear webhook', () => {
626
638
  },
627
639
  })
628
640
  const before = Date.now()
629
- await provider.handleWebhook({ headers: {}, rawBody: body })
641
+ await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
630
642
  const meta = loadSyncMeta(db)
631
643
  expect(meta.lastWebhookAt).not.toBeNull()
632
644
  expect(new Date(meta.lastWebhookAt!).getTime()).toBeGreaterThanOrEqual(before)
@@ -677,3 +689,44 @@ describe('Linear webhook', () => {
677
689
  expect(result.unauthorized).toBe(true)
678
690
  })
679
691
  })
692
+
693
+ describe('webhook fail-closed when secret is unset', () => {
694
+ test('Jira rejects unauthenticated webhooks when JIRA_WEBHOOK_SECRET is unset', async () => {
695
+ const prev = process.env['JIRA_WEBHOOK_SECRET']
696
+ delete process.env['JIRA_WEBHOOK_SECRET']
697
+ try {
698
+ const db = new Database(':memory:')
699
+ seedJira(db)
700
+ const client = new JiraClient({
701
+ baseUrl: jiraConfig.baseUrl,
702
+ email: jiraConfig.email,
703
+ apiToken: jiraConfig.apiToken,
704
+ })
705
+ const provider = new JiraProvider(db, jiraConfig, client)
706
+ const body = JSON.stringify({ webhookEvent: 'jira:issue_created', issue: { id: '1' } })
707
+ const result = await provider.handleWebhook({ headers: {}, rawBody: body })
708
+ expect(result.handled).toBe(false)
709
+ expect(result.unauthorized).toBe(true)
710
+ } finally {
711
+ if (prev === undefined) delete process.env['JIRA_WEBHOOK_SECRET']
712
+ else process.env['JIRA_WEBHOOK_SECRET'] = prev
713
+ }
714
+ })
715
+
716
+ test('Linear rejects unauthenticated webhooks when LINEAR_WEBHOOK_SECRET is unset', async () => {
717
+ const prev = process.env['LINEAR_WEBHOOK_SECRET']
718
+ delete process.env['LINEAR_WEBHOOK_SECRET']
719
+ try {
720
+ const db = new Database(':memory:')
721
+ seedLinear(db)
722
+ const provider = new LinearProvider(db, 'tid', 'key')
723
+ const body = JSON.stringify({ action: 'create', type: 'Issue', data: { id: 'i1' } })
724
+ const result = await provider.handleWebhook({ headers: {}, rawBody: body })
725
+ expect(result.handled).toBe(false)
726
+ expect(result.unauthorized).toBe(true)
727
+ } finally {
728
+ if (prev === undefined) delete process.env['LINEAR_WEBHOOK_SECRET']
729
+ else process.env['LINEAR_WEBHOOK_SECRET'] = prev
730
+ }
731
+ })
732
+ })
package/src/api.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { KanbanError, ErrorCode } from './errors'
2
2
  import type { BoardConfig, CliOutput, Task } from './types'
3
3
  import type { CreateTaskInput, UpdateTaskInput, KanbanProvider } from './providers/types'
4
- import { normalizeLabels } from './labels'
4
+ import * as useCases from './use-cases'
5
5
 
6
6
  export type WsEvent =
7
- | { type: 'task:upsert'; task: Task; columnName: string }
7
+ | { type: 'task:upsert'; task: Task; columnId: string }
8
8
  | { type: 'task:delete'; id: string }
9
+ // Fallback when a mutation has no precise event; the UI does a full refresh.
10
+ | { type: 'refresh' }
9
11
 
10
12
  interface MoveTaskBody {
11
13
  column?: string
@@ -34,7 +36,8 @@ function statusForCode(code: string): number {
34
36
  if (
35
37
  code === ErrorCode.TASK_NOT_FOUND ||
36
38
  code === ErrorCode.COLUMN_NOT_FOUND ||
37
- code === ErrorCode.COMMENT_NOT_FOUND
39
+ code === ErrorCode.COMMENT_NOT_FOUND ||
40
+ code === ErrorCode.NOT_FOUND
38
41
  )
39
42
  return 404
40
43
  if (code === ErrorCode.PROVIDER_AUTH_FAILED) return 401
@@ -71,17 +74,8 @@ export interface ApiResult {
71
74
  event?: WsEvent
72
75
  }
73
76
 
74
- async function resolveColumnName(
75
- provider: KanbanProvider,
76
- columnId: string,
77
- ): Promise<string | null> {
78
- const columns = await provider.listColumns()
79
- return columns.find((column) => column.id === columnId)?.name ?? null
80
- }
81
-
82
- async function upsertEvent(provider: KanbanProvider, task: Task): Promise<WsEvent | undefined> {
83
- const columnName = await resolveColumnName(provider, task.column_id)
84
- return columnName ? { type: 'task:upsert', task, columnName } : undefined
77
+ function upsertEvent(task: Task): WsEvent {
78
+ return { type: 'task:upsert', task, columnId: task.column_id }
85
79
  }
86
80
 
87
81
  export async function handleRequest(provider: KanbanProvider, req: Request): Promise<ApiResult> {
@@ -91,28 +85,40 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
91
85
 
92
86
  if (path === '/api/bootstrap' && method === 'GET') {
93
87
  return {
94
- response: await wrapHandler(async () => ({ ok: true, data: await provider.getBootstrap() })),
88
+ response: await wrapHandler(async () => ({
89
+ ok: true,
90
+ data: await useCases.getBootstrap(provider),
91
+ })),
95
92
  mutated: false,
96
93
  }
97
94
  }
98
95
 
99
96
  if (path === '/api/provider' && method === 'GET') {
100
97
  return {
101
- response: await wrapHandler(async () => ({ ok: true, data: await provider.getContext() })),
98
+ response: await wrapHandler(async () => ({
99
+ ok: true,
100
+ data: await useCases.getContext(provider),
101
+ })),
102
102
  mutated: false,
103
103
  }
104
104
  }
105
105
 
106
106
  if (path === '/api/board' && method === 'GET') {
107
107
  return {
108
- response: await wrapHandler(async () => ({ ok: true, data: await provider.getBoard() })),
108
+ response: await wrapHandler(async () => ({
109
+ ok: true,
110
+ data: await useCases.getBoard(provider),
111
+ })),
109
112
  mutated: false,
110
113
  }
111
114
  }
112
115
 
113
116
  if (path === '/api/columns' && method === 'GET') {
114
117
  return {
115
- response: await wrapHandler(async () => ({ ok: true, data: await provider.listColumns() })),
118
+ response: await wrapHandler(async () => ({
119
+ ok: true,
120
+ data: await useCases.listColumns(provider),
121
+ })),
116
122
  mutated: false,
117
123
  }
118
124
  }
@@ -128,7 +134,14 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
128
134
  const limit = parseOptionalInt(url.searchParams.get('limit'))
129
135
  return {
130
136
  ok: true,
131
- data: await provider.listTasks({ column, priority, assignee, project, sort, limit }),
137
+ data: await useCases.listTasks(provider, {
138
+ column,
139
+ priority,
140
+ assignee,
141
+ project,
142
+ sort,
143
+ limit,
144
+ }),
132
145
  }
133
146
  }),
134
147
  mutated: false,
@@ -140,19 +153,19 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
140
153
  if (!body.title) return { response: missingArgument('title'), mutated: false }
141
154
  let created: Task | null = null
142
155
  const response = await wrapHandler(async () => {
143
- created = await provider.createTask({
156
+ created = await useCases.createTask(provider, {
144
157
  title: body.title!,
145
158
  description: body.description,
146
159
  column: body.column,
147
160
  priority: body.priority,
148
161
  assignee: body.assignee,
149
162
  project: body.project,
150
- labels: normalizeLabels(body.labels),
163
+ labels: body.labels,
151
164
  metadata: body.metadata,
152
165
  })
153
166
  return { ok: true, data: created }
154
167
  })
155
- const event = response.ok && created ? await upsertEvent(provider, created) : undefined
168
+ const event = response.ok && created ? upsertEvent(created) : undefined
156
169
  return { response, mutated: response.ok, event }
157
170
  }
158
171
 
@@ -162,7 +175,10 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
162
175
 
163
176
  if (method === 'GET') {
164
177
  return {
165
- response: await wrapHandler(async () => ({ ok: true, data: await provider.getTask(id) })),
178
+ response: await wrapHandler(async () => ({
179
+ ok: true,
180
+ data: await useCases.getTask(provider, id),
181
+ })),
166
182
  mutated: false,
167
183
  }
168
184
  }
@@ -171,17 +187,17 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
171
187
  const body = (await req.json()) as UpdateTaskInput
172
188
  let updated: Task | null = null
173
189
  const response = await wrapHandler(async () => {
174
- updated = await provider.updateTask(id, body)
190
+ updated = await useCases.updateTask(provider, id, body)
175
191
  return { ok: true, data: updated }
176
192
  })
177
- const event = response.ok && updated ? await upsertEvent(provider, updated) : undefined
193
+ const event = response.ok && updated ? upsertEvent(updated) : undefined
178
194
  return { response, mutated: response.ok, event }
179
195
  }
180
196
 
181
197
  if (method === 'DELETE') {
182
198
  const response = await wrapHandler(async () => ({
183
199
  ok: true,
184
- data: await provider.deleteTask(id),
200
+ data: await useCases.deleteTask(provider, id),
185
201
  }))
186
202
  const event: WsEvent | undefined = response.ok ? { type: 'task:delete', id } : undefined
187
203
  return { response, mutated: response.ok, event }
@@ -195,10 +211,10 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
195
211
  if (!body.column) return { response: missingArgument('column'), mutated: false }
196
212
  let moved: Task | null = null
197
213
  const response = await wrapHandler(async () => {
198
- moved = await provider.moveTask(id, body.column!)
214
+ moved = await useCases.moveTask(provider, id, body.column!)
199
215
  return { ok: true, data: moved }
200
216
  })
201
- const event = response.ok && moved ? await upsertEvent(provider, moved) : undefined
217
+ const event = response.ok && moved ? upsertEvent(moved) : undefined
202
218
  return { response, mutated: response.ok, event }
203
219
  }
204
220
 
@@ -209,7 +225,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
209
225
  return {
210
226
  response: await wrapHandler(async () => ({
211
227
  ok: true,
212
- data: await provider.listComments(id),
228
+ data: await useCases.listComments(provider, id),
213
229
  })),
214
230
  mutated: false,
215
231
  }
@@ -220,7 +236,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
220
236
  if (!body.body) return { response: missingArgument('body'), mutated: false }
221
237
  const response = await wrapHandler(async () => ({
222
238
  ok: true,
223
- data: await provider.comment(id, body.body!),
239
+ data: await useCases.addComment(provider, id, body.body!),
224
240
  }))
225
241
  return { response, mutated: response.ok }
226
242
  }
@@ -236,7 +252,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
236
252
  if (!body.body) return { response: missingArgument('body'), mutated: false }
237
253
  const response = await wrapHandler(async () => ({
238
254
  ok: true,
239
- data: await provider.updateComment(id, commentId, body.body!),
255
+ data: await useCases.updateComment(provider, id, commentId, body.body!),
240
256
  }))
241
257
  return { response, mutated: response.ok }
242
258
  }
@@ -247,7 +263,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
247
263
  response: await wrapHandler(async () => {
248
264
  const taskId = url.searchParams.get('taskId') ?? undefined
249
265
  const limit = parseOptionalInt(url.searchParams.get('limit'))
250
- return { ok: true, data: await provider.getActivity(limit, taskId) }
266
+ return { ok: true, data: await useCases.getActivity(provider, limit, taskId) }
251
267
  }),
252
268
  mutated: false,
253
269
  }
@@ -255,14 +271,20 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
255
271
 
256
272
  if (path === '/api/metrics' && method === 'GET') {
257
273
  return {
258
- response: await wrapHandler(async () => ({ ok: true, data: await provider.getMetrics() })),
274
+ response: await wrapHandler(async () => ({
275
+ ok: true,
276
+ data: await useCases.getMetrics(provider),
277
+ })),
259
278
  mutated: false,
260
279
  }
261
280
  }
262
281
 
263
282
  if (path === '/api/config' && method === 'GET') {
264
283
  return {
265
- response: await wrapHandler(async () => ({ ok: true, data: await provider.getConfig() })),
284
+ response: await wrapHandler(async () => ({
285
+ ok: true,
286
+ data: await useCases.getConfig(provider),
287
+ })),
266
288
  mutated: false,
267
289
  }
268
290
  }
@@ -271,7 +293,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
271
293
  const body = (await req.json()) as Partial<BoardConfig>
272
294
  const response = await wrapHandler(async () => ({
273
295
  ok: true,
274
- data: await provider.patchConfig(body),
296
+ data: await useCases.patchConfig(provider, body),
275
297
  }))
276
298
  return { response, mutated: response.ok }
277
299
  }
@@ -338,7 +360,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
338
360
 
339
361
  return {
340
362
  response: json(
341
- { ok: false, error: { code: 'NOT_FOUND', message: `No route: ${method} ${path}` } },
363
+ { ok: false, error: { code: ErrorCode.NOT_FOUND, message: `No route: ${method} ${path}` } },
342
364
  404,
343
365
  ),
344
366
  mutated: false,
@@ -0,0 +1,52 @@
1
+ // Board metrics need to know which columns mean "done" and "in progress", but
2
+ // columns are just (name, position) with no role metadata. Names vary across
3
+ // boards (custom local columns, Jira/Linear statuses, KANBAN_DEFAULT_COLUMNS),
4
+ // so we classify by a normalized name plus a small synonym set rather than an
5
+ // exact 'done' / 'in-progress' string match.
6
+
7
+ export interface ClassifiableColumn {
8
+ id: string
9
+ name: string
10
+ position: number
11
+ }
12
+
13
+ const DONE_NAMES = new Set([
14
+ 'done',
15
+ 'complete',
16
+ 'completed',
17
+ 'closed',
18
+ 'resolved',
19
+ 'shipped',
20
+ 'merged',
21
+ ])
22
+
23
+ const IN_PROGRESS_NAMES = new Set([
24
+ 'inprogress',
25
+ 'doing',
26
+ 'wip',
27
+ 'started',
28
+ 'indevelopment',
29
+ 'inreview',
30
+ ])
31
+
32
+ // Lowercase and drop separators so 'In Progress', 'in-progress', and
33
+ // 'in_progress' all normalize to the same token.
34
+ function normalizeColumnName(name: string): string {
35
+ return name.toLowerCase().replace(/[^a-z0-9]/g, '')
36
+ }
37
+
38
+ export function selectDoneColumnIds(columns: ClassifiableColumn[]): string[] {
39
+ const matched = columns.filter((c) => DONE_NAMES.has(normalizeColumnName(c.name)))
40
+ if (matched.length > 0) return matched.map((c) => c.id)
41
+ // No recognizable "done" name: fall back to the terminal column (the kanban
42
+ // convention) so completion metrics aren't silently zero under custom names.
43
+ const terminal = columns.reduce<ClassifiableColumn | null>(
44
+ (best, c) => (best === null || c.position > best.position ? c : best),
45
+ null,
46
+ )
47
+ return terminal ? [terminal.id] : []
48
+ }
49
+
50
+ export function selectInProgressColumnIds(columns: ClassifiableColumn[]): string[] {
51
+ return columns.filter((c) => IN_PROGRESS_NAMES.has(normalizeColumnName(c.name))).map((c) => c.id)
52
+ }
@@ -4,16 +4,16 @@ import { ErrorCode, KanbanError } from '../errors'
4
4
  import { success } from '../output'
5
5
  import type { CliOutput } from '../types'
6
6
 
7
- export function boardInit(db: Database): CliOutput {
7
+ export function boardInit(db: Database, columnNames?: string[]): CliOutput {
8
8
  if (isInitialized(db)) {
9
9
  throw new KanbanError(ErrorCode.BOARD_ALREADY_INITIALIZED, 'Board is already initialized')
10
10
  }
11
11
  initSchema(db)
12
- seedDefaultColumns(db)
12
+ seedDefaultColumns(db, columnNames)
13
13
  return success({ message: 'Board initialized with default columns.' })
14
14
  }
15
15
 
16
- export function boardReset(db: Database): CliOutput {
17
- resetBoard(db)
16
+ export function boardReset(db: Database, columnNames?: string[]): CliOutput {
17
+ resetBoard(db, columnNames)
18
18
  return success({ message: 'Board reset. All data cleared and defaults restored.' })
19
19
  }