@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.
- 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-client.test.ts +98 -1
- package/src/__tests__/jira-jql.test.ts +51 -0
- package/src/__tests__/jira-provider-mutations.test.ts +84 -59
- package/src/__tests__/jira-provider-read.test.ts +227 -20
- package/src/__tests__/mcp-server.test.ts +2 -2
- package/src/__tests__/metrics.test.ts +37 -1
- package/src/__tests__/postgres-jira-provider.test.ts +155 -0
- 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-client.ts +70 -4
- package/src/providers/jira-core.ts +921 -0
- package/src/providers/jira-jql.ts +48 -0
- package/src/providers/jira.ts +111 -677
- 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 -1188
- 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
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
4
|
+
import * as useCases from './use-cases'
|
|
5
5
|
|
|
6
6
|
export type WsEvent =
|
|
7
|
-
| { type: 'task:upsert'; task: Task;
|
|
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
|
-
|
|
75
|
-
|
|
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 () => ({
|
|
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 () => ({
|
|
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 () => ({
|
|
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 () => ({
|
|
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
|
|
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
|
|
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:
|
|
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 ?
|
|
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 () => ({
|
|
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
|
|
190
|
+
updated = await useCases.updateTask(provider, id, body)
|
|
175
191
|
return { ok: true, data: updated }
|
|
176
192
|
})
|
|
177
|
-
const event = response.ok && updated ?
|
|
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
|
|
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
|
|
214
|
+
moved = await useCases.moveTask(provider, id, body.column!)
|
|
199
215
|
return { ok: true, data: moved }
|
|
200
216
|
})
|
|
201
|
-
const event = response.ok && moved ?
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 () => ({
|
|
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 () => ({
|
|
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
|
|
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:
|
|
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
|
+
}
|
package/src/commands/board.ts
CHANGED
|
@@ -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
|
}
|