@andypai/agent-kanban 0.6.0 → 0.6.2
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-jql.test.ts +51 -0
- package/src/__tests__/jira-provider-read.test.ts +50 -0
- package/src/__tests__/mcp-server.test.ts +2 -2
- package/src/__tests__/metrics.test.ts +37 -1
- 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 +91 -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-core.ts +926 -0
- package/src/providers/jira-jql.ts +48 -0
- package/src/providers/jira.ts +111 -759
- package/src/providers/linear-cache.ts +5 -3
- package/src/providers/linear-core.ts +693 -0
- package/src/providers/linear.ts +70 -554
- package/src/providers/postgres-jira-cache.ts +597 -0
- package/src/providers/postgres-jira.ts +15 -1312
- 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 +19 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test, beforeEach } from 'bun:test'
|
|
2
2
|
import { Database } from 'bun:sqlite'
|
|
3
|
-
import { initSchema, seedDefaultColumns, addTask } from '../db'
|
|
3
|
+
import { initSchema, seedDefaultColumns, addTask, addColumn, moveTask } from '../db'
|
|
4
4
|
import { getBoardMetrics } from '../metrics'
|
|
5
5
|
|
|
6
6
|
let db: Database
|
|
@@ -62,3 +62,39 @@ describe('getBoardMetrics', () => {
|
|
|
62
62
|
expect(metrics.tasksByPriority).toHaveLength(0)
|
|
63
63
|
})
|
|
64
64
|
})
|
|
65
|
+
|
|
66
|
+
describe('getBoardMetrics with custom column names', () => {
|
|
67
|
+
function customBoard(columnNames: string[]): Database {
|
|
68
|
+
const db = new Database(':memory:')
|
|
69
|
+
db.run('PRAGMA foreign_keys = ON')
|
|
70
|
+
initSchema(db)
|
|
71
|
+
for (const name of columnNames) addColumn(db, name)
|
|
72
|
+
return db
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test('classifies done/in-progress by role despite custom case and spacing', () => {
|
|
76
|
+
const db = customBoard(['Todo', 'In Progress', 'Human Review', 'Merging', 'Done'])
|
|
77
|
+
addTask(db, 'a', { column: 'Todo' })
|
|
78
|
+
const b = addTask(db, 'b', { column: 'Todo' })
|
|
79
|
+
const c = addTask(db, 'c', { column: 'Todo' })
|
|
80
|
+
moveTask(db, b.id, 'In Progress')
|
|
81
|
+
moveTask(db, c.id, 'Done')
|
|
82
|
+
|
|
83
|
+
const metrics = getBoardMetrics(db)
|
|
84
|
+
// 'In Progress' (space + caps) used to never match the literal 'in-progress'.
|
|
85
|
+
expect(metrics.inProgressCount).toBe(1)
|
|
86
|
+
expect(metrics.completedTasks).toBe(1)
|
|
87
|
+
expect(metrics.completionPercent).toBe(33)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('falls back to the terminal column for completed when no done-named column', () => {
|
|
91
|
+
const db = customBoard(['Todo', 'Doing', 'Shipping'])
|
|
92
|
+
const t = addTask(db, 'a', { column: 'Todo' })
|
|
93
|
+
moveTask(db, t.id, 'Shipping')
|
|
94
|
+
|
|
95
|
+
const metrics = getBoardMetrics(db)
|
|
96
|
+
expect(metrics.completedTasks).toBe(1)
|
|
97
|
+
// 'Doing' is a recognized in-progress synonym.
|
|
98
|
+
expect(metrics.inProgressCount).toBe(0)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import postgres from 'postgres'
|
|
3
3
|
|
|
4
4
|
import { run } from '../index'
|
|
5
|
-
import
|
|
5
|
+
import { openKanbanRuntime } from '../provider-runtime'
|
|
6
|
+
import type { BoardMetrics, Task, TaskComment, TaskWithColumn } from '../types'
|
|
6
7
|
|
|
7
8
|
const databaseUrl = process.env['KANBAN_PG_TEST_URL'] ?? process.env['DATABASE_URL']
|
|
8
9
|
const pgTest = databaseUrl ? test : test.skip
|
|
@@ -131,4 +132,92 @@ describe('postgres local provider', () => {
|
|
|
131
132
|
|
|
132
133
|
expect(created.column_name).toBe('Todo')
|
|
133
134
|
})
|
|
135
|
+
|
|
136
|
+
async function positionsInColumn(
|
|
137
|
+
columnName: string,
|
|
138
|
+
): Promise<Array<{ id: string; position: number }>> {
|
|
139
|
+
if (!sql) throw new Error('sql not initialized')
|
|
140
|
+
return sql<Array<{ id: string; position: number }>>`
|
|
141
|
+
SELECT t.id, t.position FROM tasks t
|
|
142
|
+
JOIN columns c ON t.column_id = c.id
|
|
143
|
+
WHERE c.name = ${columnName}
|
|
144
|
+
ORDER BY t.position
|
|
145
|
+
`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
pgTest('move renumbers the source column and records column-time tracking', async () => {
|
|
149
|
+
const a = expectOk<TaskWithColumn>(await run(['task', 'add', 'A', '-c', 'backlog']))
|
|
150
|
+
const b = expectOk<TaskWithColumn>(await run(['task', 'add', 'B', '-c', 'backlog']))
|
|
151
|
+
const c = expectOk<TaskWithColumn>(await run(['task', 'add', 'C', '-c', 'backlog']))
|
|
152
|
+
expect((await positionsInColumn('backlog')).map((r) => r.id)).toEqual([a.id, b.id, c.id])
|
|
153
|
+
|
|
154
|
+
expectOk<Task>(await run(['task', 'move', b.id, 'in-progress']))
|
|
155
|
+
|
|
156
|
+
// Source column compacted to 0..n-1 with no gap left by B.
|
|
157
|
+
expect(await positionsInColumn('backlog')).toEqual([
|
|
158
|
+
{ id: a.id, position: 0 },
|
|
159
|
+
{ id: c.id, position: 1 },
|
|
160
|
+
])
|
|
161
|
+
|
|
162
|
+
// B has a closed backlog interval and an open in-progress interval.
|
|
163
|
+
const tracking = await sql!<Array<{ column_name: string; exited_at: string | null }>>`
|
|
164
|
+
SELECT c.name AS column_name, ct.exited_at
|
|
165
|
+
FROM column_time_tracking ct JOIN columns c ON ct.column_id = c.id
|
|
166
|
+
WHERE ct.task_id = ${b.id}
|
|
167
|
+
ORDER BY ct.entered_at
|
|
168
|
+
`
|
|
169
|
+
expect(tracking.map((r) => r.column_name)).toEqual(['backlog', 'in-progress'])
|
|
170
|
+
expect(tracking[0]!.exited_at).not.toBeNull()
|
|
171
|
+
expect(tracking[1]!.exited_at).toBeNull()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
pgTest('move to the same column is a no-op (no extra tracking, no reorder)', async () => {
|
|
175
|
+
const a = expectOk<TaskWithColumn>(await run(['task', 'add', 'A', '-c', 'backlog']))
|
|
176
|
+
const b = expectOk<TaskWithColumn>(await run(['task', 'add', 'B', '-c', 'backlog']))
|
|
177
|
+
|
|
178
|
+
expectOk<Task>(await run(['task', 'move', a.id, 'backlog']))
|
|
179
|
+
|
|
180
|
+
// Order preserved (no re-append of A to the end).
|
|
181
|
+
expect((await positionsInColumn('backlog')).map((r) => r.id)).toEqual([a.id, b.id])
|
|
182
|
+
// Only the single creation interval exists for A.
|
|
183
|
+
const trackingRows = await sql!<Array<{ count: string | number }>>`
|
|
184
|
+
SELECT COUNT(*) AS count FROM column_time_tracking WHERE task_id = ${a.id}
|
|
185
|
+
`
|
|
186
|
+
expect(Number(trackingRows[0]?.count ?? 0)).toBe(1)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
pgTest('delete renumbers the remaining tasks in the column', async () => {
|
|
190
|
+
const a = expectOk<TaskWithColumn>(await run(['task', 'add', 'A', '-c', 'backlog']))
|
|
191
|
+
const b = expectOk<TaskWithColumn>(await run(['task', 'add', 'B', '-c', 'backlog']))
|
|
192
|
+
const c = expectOk<TaskWithColumn>(await run(['task', 'add', 'C', '-c', 'backlog']))
|
|
193
|
+
|
|
194
|
+
expectOk<Task>(await run(['task', 'delete', b.id]))
|
|
195
|
+
|
|
196
|
+
expect(await positionsInColumn('backlog')).toEqual([
|
|
197
|
+
{ id: a.id, position: 0 },
|
|
198
|
+
{ id: c.id, position: 1 },
|
|
199
|
+
])
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
pgTest('getMetrics reports completion time for tasks that reached Done', async () => {
|
|
203
|
+
const a = expectOk<TaskWithColumn>(await run(['task', 'add', 'A', '-c', 'backlog']))
|
|
204
|
+
expectOk<TaskWithColumn>(await run(['task', 'add', 'B', '-c', 'backlog']))
|
|
205
|
+
expectOk<Task>(await run(['task', 'move', a.id, 'done']))
|
|
206
|
+
|
|
207
|
+
const runtime = await openKanbanRuntime()
|
|
208
|
+
let metrics: BoardMetrics
|
|
209
|
+
try {
|
|
210
|
+
metrics = await runtime.provider.getMetrics()
|
|
211
|
+
} finally {
|
|
212
|
+
await runtime.close()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
expect(metrics.completedTasks).toBe(1)
|
|
216
|
+
// Bug fix: a task resting in Done now contributes to the average (no longer
|
|
217
|
+
// requires exited_at), and the value is a real number, not a string.
|
|
218
|
+
expect(typeof metrics.avgCompletionHours).toBe('number')
|
|
219
|
+
expect(metrics.avgCompletionHours!).toBeGreaterThanOrEqual(0)
|
|
220
|
+
expect(metrics.tasksCreatedThisWeek).toBe(2)
|
|
221
|
+
expect(metrics.inProgressCount).toBe(0)
|
|
222
|
+
})
|
|
134
223
|
})
|
|
@@ -289,3 +289,129 @@ describe('startServer', () => {
|
|
|
289
289
|
expect(syncCalls).toBeGreaterThanOrEqual(2)
|
|
290
290
|
})
|
|
291
291
|
})
|
|
292
|
+
|
|
293
|
+
describe('startServer auth + CORS', () => {
|
|
294
|
+
const TOKEN = 'secret-token'
|
|
295
|
+
|
|
296
|
+
test('without a token, the API stays open (localhost default)', async () => {
|
|
297
|
+
const runtime = startServer(makeProvider(), 0)
|
|
298
|
+
runtimes.push(runtime)
|
|
299
|
+
const res = await fetch(`http://127.0.0.1:${runtime.port}/api/bootstrap`)
|
|
300
|
+
expect(res.status).toBe(200)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('with a token, protected routes require Bearer auth', async () => {
|
|
304
|
+
const runtime = startServer(makeProvider(), 0, { authToken: TOKEN })
|
|
305
|
+
runtimes.push(runtime)
|
|
306
|
+
|
|
307
|
+
const noAuth = await fetch(`http://127.0.0.1:${runtime.port}/api/bootstrap`)
|
|
308
|
+
expect(noAuth.status).toBe(401)
|
|
309
|
+
const noAuthBody = (await noAuth.json()) as { ok: boolean; error: { code: string } }
|
|
310
|
+
expect(noAuthBody.ok).toBe(false)
|
|
311
|
+
expect(noAuthBody.error.code).toBe('UNAUTHORIZED')
|
|
312
|
+
|
|
313
|
+
const wrong = await fetch(`http://127.0.0.1:${runtime.port}/api/bootstrap`, {
|
|
314
|
+
headers: { Authorization: 'Bearer nope' },
|
|
315
|
+
})
|
|
316
|
+
expect(wrong.status).toBe(401)
|
|
317
|
+
|
|
318
|
+
const ok = await fetch(`http://127.0.0.1:${runtime.port}/api/bootstrap`, {
|
|
319
|
+
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
320
|
+
})
|
|
321
|
+
expect(ok.status).toBe(200)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('?token= does NOT authorize HTTP API routes (header only)', async () => {
|
|
325
|
+
const runtime = startServer(makeProvider(), 0, { authToken: TOKEN })
|
|
326
|
+
runtimes.push(runtime)
|
|
327
|
+
const res = await fetch(`http://127.0.0.1:${runtime.port}/api/bootstrap?token=${TOKEN}`)
|
|
328
|
+
expect(res.status).toBe(401)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('/ws requires the token and accepts it as a query param', async () => {
|
|
332
|
+
const runtime = startServer(makeProvider(), 0, { authToken: TOKEN })
|
|
333
|
+
runtimes.push(runtime)
|
|
334
|
+
|
|
335
|
+
// Plain GET (no upgrade) exercises the auth gate before the upgrade attempt.
|
|
336
|
+
const noToken = await fetch(`http://127.0.0.1:${runtime.port}/ws`)
|
|
337
|
+
expect(noToken.status).toBe(401)
|
|
338
|
+
|
|
339
|
+
// Correct query token passes the gate; the non-WebSocket request then fails
|
|
340
|
+
// the upgrade (400) rather than auth (401).
|
|
341
|
+
const withToken = await fetch(`http://127.0.0.1:${runtime.port}/ws?token=${TOKEN}`)
|
|
342
|
+
expect(withToken.status).not.toBe(401)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test('with a token, /api/health stays public', async () => {
|
|
346
|
+
const runtime = startServer(makeProvider(), 0, { authToken: TOKEN })
|
|
347
|
+
runtimes.push(runtime)
|
|
348
|
+
const res = await fetch(`http://127.0.0.1:${runtime.port}/api/health`)
|
|
349
|
+
expect(res.status).toBe(200)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
test('with a token, webhook routes are exempt (provider secret guards them)', async () => {
|
|
353
|
+
const runtime = startServer(
|
|
354
|
+
makeProvider({
|
|
355
|
+
type: 'local',
|
|
356
|
+
async handleWebhook() {
|
|
357
|
+
return { handled: true }
|
|
358
|
+
},
|
|
359
|
+
}),
|
|
360
|
+
0,
|
|
361
|
+
{ authToken: TOKEN },
|
|
362
|
+
)
|
|
363
|
+
runtimes.push(runtime)
|
|
364
|
+
const res = await fetch(`http://127.0.0.1:${runtime.port}/api/webhooks/local`, {
|
|
365
|
+
method: 'POST',
|
|
366
|
+
headers: { 'Content-Type': 'application/json' },
|
|
367
|
+
body: '{}',
|
|
368
|
+
})
|
|
369
|
+
// Reaches the provider handler instead of being rejected by bearer auth.
|
|
370
|
+
expect(res.status).not.toBe(401)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test('wsClients are tracked per server instance, not shared globally', async () => {
|
|
374
|
+
const a = startServer(makeProvider(), 0)
|
|
375
|
+
runtimes.push(a)
|
|
376
|
+
const b = startServer(makeProvider(), 0)
|
|
377
|
+
runtimes.push(b)
|
|
378
|
+
|
|
379
|
+
const ws = new WebSocket(`ws://127.0.0.1:${a.port}/ws`)
|
|
380
|
+
await new Promise<void>((resolve, reject) => {
|
|
381
|
+
ws.onopen = () => resolve()
|
|
382
|
+
ws.onerror = () => reject(new Error('ws connection failed'))
|
|
383
|
+
})
|
|
384
|
+
// Let server A's open handler register the socket.
|
|
385
|
+
await sleep(25)
|
|
386
|
+
|
|
387
|
+
const healthA = (await (await fetch(`http://127.0.0.1:${a.port}/api/health`)).json()) as {
|
|
388
|
+
data: { wsClients: number }
|
|
389
|
+
}
|
|
390
|
+
const healthB = (await (await fetch(`http://127.0.0.1:${b.port}/api/health`)).json()) as {
|
|
391
|
+
data: { wsClients: number }
|
|
392
|
+
}
|
|
393
|
+
expect(healthA.data.wsClients).toBe(1)
|
|
394
|
+
// Before per-instance scoping, B shared A's global set and also reported 1.
|
|
395
|
+
expect(healthB.data.wsClients).toBe(0)
|
|
396
|
+
ws.close()
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
test('CORS headers are emitted only when an allowed origin is configured', async () => {
|
|
400
|
+
const withOrigin = startServer(makeProvider(), 0, {
|
|
401
|
+
allowedOrigin: 'https://kanban.example',
|
|
402
|
+
})
|
|
403
|
+
runtimes.push(withOrigin)
|
|
404
|
+
const res = await fetch(`http://127.0.0.1:${withOrigin.port}/api/health`)
|
|
405
|
+
expect(res.headers.get('access-control-allow-origin')).toBe('https://kanban.example')
|
|
406
|
+
|
|
407
|
+
const preflight = await fetch(`http://127.0.0.1:${withOrigin.port}/api/bootstrap`, {
|
|
408
|
+
method: 'OPTIONS',
|
|
409
|
+
})
|
|
410
|
+
expect(preflight.headers.get('access-control-allow-origin')).toBe('https://kanban.example')
|
|
411
|
+
|
|
412
|
+
const noOrigin = startServer(makeProvider(), 0)
|
|
413
|
+
runtimes.push(noOrigin)
|
|
414
|
+
const bare = await fetch(`http://127.0.0.1:${noOrigin.port}/api/health`)
|
|
415
|
+
expect(bare.headers.get('access-control-allow-origin')).toBeNull()
|
|
416
|
+
})
|
|
417
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Database } from 'bun:sqlite'
|
|
3
|
+
import { initSchema, seedDefaultColumns } from '../db'
|
|
4
|
+
import { createProvider } from '../providers/index'
|
|
5
|
+
import type { KanbanProvider } from '../providers/types'
|
|
6
|
+
import * as useCases from '../use-cases'
|
|
7
|
+
|
|
8
|
+
let db: Database
|
|
9
|
+
let provider: KanbanProvider
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
db = new Database(':memory:')
|
|
13
|
+
db.run('PRAGMA foreign_keys = ON')
|
|
14
|
+
initSchema(db)
|
|
15
|
+
seedDefaultColumns(db)
|
|
16
|
+
provider = createProvider(db, { provider: 'local' }, ':memory:')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('use-cases label normalization', () => {
|
|
20
|
+
// The use-case layer owns label normalization so every transport feeds raw
|
|
21
|
+
// labels in its own shape and gets the same result.
|
|
22
|
+
test('normalizes CLI-style nested flag arrays', async () => {
|
|
23
|
+
const task = await useCases.createTask(provider, {
|
|
24
|
+
title: 'cli',
|
|
25
|
+
labels: [['bug', 'ui'], undefined],
|
|
26
|
+
})
|
|
27
|
+
expect(task.labels).toEqual(['bug', 'ui'])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('normalizes HTTP-style string arrays', async () => {
|
|
31
|
+
const task = await useCases.createTask(provider, {
|
|
32
|
+
title: 'http',
|
|
33
|
+
labels: ['bug', 'ui'],
|
|
34
|
+
})
|
|
35
|
+
expect(task.labels).toEqual(['bug', 'ui'])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('normalizes a comma-separated string and de-dupes', async () => {
|
|
39
|
+
const task = await useCases.createTask(provider, {
|
|
40
|
+
title: 'csv',
|
|
41
|
+
labels: 'bug, ui, bug',
|
|
42
|
+
})
|
|
43
|
+
expect(task.labels).toEqual(['bug', 'ui'])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('treats omitted labels as none', async () => {
|
|
47
|
+
const task = await useCases.createTask(provider, { title: 'none' })
|
|
48
|
+
expect(task.labels).toEqual([])
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('use-cases provider forwarding', () => {
|
|
53
|
+
test('createTask then getTask/listTasks round-trip', async () => {
|
|
54
|
+
const created = await useCases.createTask(provider, { title: 'roundtrip', column: 'backlog' })
|
|
55
|
+
const fetched = await useCases.getTask(provider, created.id)
|
|
56
|
+
expect(fetched.id).toBe(created.id)
|
|
57
|
+
const all = await useCases.listTasks(provider)
|
|
58
|
+
expect(all.map((t) => t.id)).toContain(created.id)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('moveTask forwards to the target column and returns the task', async () => {
|
|
62
|
+
const created = await useCases.createTask(provider, { title: 'mover', column: 'backlog' })
|
|
63
|
+
const columns = await useCases.listColumns(provider)
|
|
64
|
+
const target = columns.find((c) => c.name.toLowerCase() === 'in-progress')!
|
|
65
|
+
const moved = await useCases.moveTask(provider, created.id, target.name)
|
|
66
|
+
expect(moved.column_id).toBe(target.id)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('addComment / listComments / updateComment forward correctly', async () => {
|
|
70
|
+
const task = await useCases.createTask(provider, { title: 'commented' })
|
|
71
|
+
const comment = await useCases.addComment(provider, task.id, 'first')
|
|
72
|
+
const listed = await useCases.listComments(provider, task.id)
|
|
73
|
+
expect(listed.map((c) => c.body)).toContain('first')
|
|
74
|
+
const updated = await useCases.updateComment(provider, task.id, comment.id, 'edited')
|
|
75
|
+
expect(updated.body).toBe('edited')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -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,60 @@ describe('Linear webhook', () => {
|
|
|
677
689
|
expect(result.unauthorized).toBe(true)
|
|
678
690
|
})
|
|
679
691
|
})
|
|
692
|
+
|
|
693
|
+
describe('webhook open dev mode when secret is unset', () => {
|
|
694
|
+
test('Jira accepts 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({
|
|
707
|
+
webhookEvent: 'jira:issue_created',
|
|
708
|
+
issue: {
|
|
709
|
+
id: '100',
|
|
710
|
+
key: 'ENG-100',
|
|
711
|
+
fields: {
|
|
712
|
+
summary: 'New issue',
|
|
713
|
+
status: { id: '1', name: 'To Do' },
|
|
714
|
+
issuetype: { id: '10000', name: 'Task' },
|
|
715
|
+
assignee: null,
|
|
716
|
+
labels: [],
|
|
717
|
+
comment: { total: 0 },
|
|
718
|
+
created: '2025-02-01T00:00:00.000Z',
|
|
719
|
+
updated: '2025-02-01T00:00:00.000Z',
|
|
720
|
+
project: { id: '1', key: 'ENG' },
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
})
|
|
724
|
+
const result = await provider.handleWebhook({ headers: {}, rawBody: body })
|
|
725
|
+
expect(result.unauthorized).not.toBe(true)
|
|
726
|
+
} finally {
|
|
727
|
+
if (prev === undefined) delete process.env['JIRA_WEBHOOK_SECRET']
|
|
728
|
+
else process.env['JIRA_WEBHOOK_SECRET'] = prev
|
|
729
|
+
}
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
test('Linear accepts webhooks when LINEAR_WEBHOOK_SECRET is unset', async () => {
|
|
733
|
+
const prev = process.env['LINEAR_WEBHOOK_SECRET']
|
|
734
|
+
delete process.env['LINEAR_WEBHOOK_SECRET']
|
|
735
|
+
try {
|
|
736
|
+
const db = new Database(':memory:')
|
|
737
|
+
seedLinear(db)
|
|
738
|
+
const provider = new LinearProvider(db, 'tid', 'key')
|
|
739
|
+
const body = JSON.stringify({ action: 'create', type: 'Issue', data: { id: 'i1' } })
|
|
740
|
+
// Auth passes; downstream may fail for other reasons (e.g. API credentials)
|
|
741
|
+
const result = await provider.handleWebhook({ headers: {}, rawBody: body }).catch(() => null)
|
|
742
|
+
expect(result?.unauthorized).not.toBe(true)
|
|
743
|
+
} finally {
|
|
744
|
+
if (prev === undefined) delete process.env['LINEAR_WEBHOOK_SECRET']
|
|
745
|
+
else process.env['LINEAR_WEBHOOK_SECRET'] = prev
|
|
746
|
+
}
|
|
747
|
+
})
|
|
748
|
+
})
|