@andypai/agent-kanban 0.3.4 → 0.3.6
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 +24 -17
- package/package.json +3 -2
- package/src/__tests__/api.test.ts +3 -3
- package/src/__tests__/index.test.ts +22 -2
- package/src/__tests__/jira-wiring.test.ts +47 -17
- package/src/__tests__/postgres-jira-provider.test.ts +315 -0
- package/src/__tests__/postgres-linear-provider.test.ts +309 -0
- package/src/__tests__/postgres-local-provider.test.ts +129 -0
- package/src/__tests__/storage-config.test.ts +39 -0
- package/src/__tests__/webhooks.test.ts +39 -3
- package/src/index.ts +65 -28
- package/src/provider-runtime.ts +110 -0
- package/src/providers/index.ts +16 -39
- package/src/providers/jira.ts +10 -5
- package/src/providers/linear.ts +2 -2
- package/src/providers/postgres-jira.ts +1193 -0
- package/src/providers/postgres-linear.ts +1088 -0
- package/src/providers/postgres-local.ts +611 -0
- package/src/server.ts +2 -2
- package/src/storage-config.ts +41 -0
- package/src/sync-config.ts +5 -2
- package/src/tracker-config.ts +104 -0
- package/src/webhooks.ts +14 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import postgres from 'postgres'
|
|
3
|
+
|
|
4
|
+
import { run } from '../index'
|
|
5
|
+
import type { Task, TaskComment } from '../types'
|
|
6
|
+
|
|
7
|
+
const databaseUrl = process.env['KANBAN_PG_TEST_URL'] ?? process.env['DATABASE_URL']
|
|
8
|
+
const pgTest = databaseUrl ? test : test.skip
|
|
9
|
+
|
|
10
|
+
type StubIssue = {
|
|
11
|
+
id: string
|
|
12
|
+
identifier: string
|
|
13
|
+
title: string
|
|
14
|
+
description: string
|
|
15
|
+
priority: number
|
|
16
|
+
url: string
|
|
17
|
+
createdAt: string
|
|
18
|
+
updatedAt: string
|
|
19
|
+
assignee: { id: string; name: string; displayName: string } | null
|
|
20
|
+
project: { id: string; name: string; url: string; state: string } | null
|
|
21
|
+
state: { id: string; name: string; position: number }
|
|
22
|
+
labels: { nodes: Array<{ id: string; name: string }> }
|
|
23
|
+
comments: { nodes: Array<{ id: string }>; pageInfo: { hasNextPage: boolean; endCursor: null } }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type StubComment = {
|
|
27
|
+
id: string
|
|
28
|
+
body: string
|
|
29
|
+
createdAt: string
|
|
30
|
+
updatedAt: string
|
|
31
|
+
user: { id: string; name: string; displayName: string }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function expectOk<T>(result: Awaited<ReturnType<typeof run>>): T {
|
|
35
|
+
expect(result.exitCode).toBe(0)
|
|
36
|
+
expect(result.output.ok).toBe(true)
|
|
37
|
+
if (!result.output.ok) throw new Error('expected successful CLI output')
|
|
38
|
+
return result.output.data as T
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeIssue(overrides: Partial<StubIssue> = {}): StubIssue {
|
|
42
|
+
return {
|
|
43
|
+
id: overrides.id ?? 'lin-1',
|
|
44
|
+
identifier: overrides.identifier ?? 'GB-1',
|
|
45
|
+
title: overrides.title ?? 'Postgres cached Linear issue',
|
|
46
|
+
description: overrides.description ?? 'Cached in Postgres',
|
|
47
|
+
priority: overrides.priority ?? 2,
|
|
48
|
+
url: overrides.url ?? 'https://linear.app/issue/GB-1',
|
|
49
|
+
createdAt: overrides.createdAt ?? '2026-01-01T00:00:00.000Z',
|
|
50
|
+
updatedAt: overrides.updatedAt ?? '2026-01-02T00:00:00.000Z',
|
|
51
|
+
assignee: overrides.assignee ?? { id: 'user-1', name: 'Alice', displayName: 'Alice' },
|
|
52
|
+
project: overrides.project ?? {
|
|
53
|
+
id: 'proj-1',
|
|
54
|
+
name: 'Garage',
|
|
55
|
+
url: 'https://linear.app/project/garage',
|
|
56
|
+
state: 'started',
|
|
57
|
+
},
|
|
58
|
+
state: overrides.state ?? { id: 'state-todo', name: 'Todo', position: 0 },
|
|
59
|
+
labels: overrides.labels ?? { nodes: [{ id: 'label-1', name: 'garage' }] },
|
|
60
|
+
comments: overrides.comments ?? {
|
|
61
|
+
nodes: [],
|
|
62
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function linearFetchStub(): typeof fetch {
|
|
68
|
+
const commentsByIssue = new Map<string, StubComment[]>()
|
|
69
|
+
const issues: StubIssue[] = [makeIssue()]
|
|
70
|
+
const states = [
|
|
71
|
+
{ id: 'state-todo', name: 'Todo', position: 0, color: '#888', type: 'unstarted' },
|
|
72
|
+
{ id: 'state-done', name: 'Done', position: 1, color: '#0a0', type: 'completed' },
|
|
73
|
+
]
|
|
74
|
+
const team = { id: 'team-1', key: 'GB', name: 'Garage Band', states: { nodes: states } }
|
|
75
|
+
const users = {
|
|
76
|
+
nodes: [
|
|
77
|
+
{ id: 'user-1', name: 'Alice', displayName: 'Alice', active: true },
|
|
78
|
+
{ id: 'user-2', name: 'Bob', displayName: 'Bob', active: true },
|
|
79
|
+
],
|
|
80
|
+
}
|
|
81
|
+
const projects = {
|
|
82
|
+
nodes: [
|
|
83
|
+
{ id: 'proj-1', name: 'Garage', url: 'https://linear.app/project/garage', state: 'started' },
|
|
84
|
+
],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
88
|
+
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
|
89
|
+
query: string
|
|
90
|
+
variables?: Record<string, unknown>
|
|
91
|
+
}
|
|
92
|
+
const query = body.query
|
|
93
|
+
const variables = body.variables ?? {}
|
|
94
|
+
|
|
95
|
+
if (query.includes('query TeamSnapshot')) {
|
|
96
|
+
return Response.json({ data: { team } })
|
|
97
|
+
}
|
|
98
|
+
if (query.includes('query Users')) {
|
|
99
|
+
return Response.json({ data: { users } })
|
|
100
|
+
}
|
|
101
|
+
if (query.includes('query Projects')) {
|
|
102
|
+
return Response.json({ data: { projects } })
|
|
103
|
+
}
|
|
104
|
+
if (query.includes('query Issues')) {
|
|
105
|
+
return Response.json({
|
|
106
|
+
data: {
|
|
107
|
+
issues: {
|
|
108
|
+
nodes: issues,
|
|
109
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
if (query.includes('mutation CreateIssue')) {
|
|
115
|
+
const input = variables.input as {
|
|
116
|
+
title: string
|
|
117
|
+
description?: string
|
|
118
|
+
priority?: number
|
|
119
|
+
stateId?: string
|
|
120
|
+
assigneeId?: string
|
|
121
|
+
projectId?: string
|
|
122
|
+
}
|
|
123
|
+
const state = states.find((candidate) => candidate.id === input.stateId) ?? states[0]!
|
|
124
|
+
const issue = makeIssue({
|
|
125
|
+
id: 'lin-2',
|
|
126
|
+
identifier: 'GB-2',
|
|
127
|
+
title: input.title,
|
|
128
|
+
description: input.description ?? '',
|
|
129
|
+
priority: input.priority ?? 0,
|
|
130
|
+
state,
|
|
131
|
+
assignee: users.nodes.find((user) => user.id === input.assigneeId) ?? null,
|
|
132
|
+
project: projects.nodes.find((project) => project.id === input.projectId) ?? null,
|
|
133
|
+
updatedAt: '2026-01-03T00:00:00.000Z',
|
|
134
|
+
})
|
|
135
|
+
issues.push(issue)
|
|
136
|
+
return Response.json({ data: { issueCreate: { success: true, issue } } })
|
|
137
|
+
}
|
|
138
|
+
if (query.includes('mutation UpdateIssue')) {
|
|
139
|
+
const issue = issues.find((candidate) => candidate.id === variables.id)
|
|
140
|
+
const input = variables.input as { stateId?: string; title?: string }
|
|
141
|
+
if (issue) {
|
|
142
|
+
if (input.stateId) {
|
|
143
|
+
const state = states.find((candidate) => candidate.id === input.stateId)
|
|
144
|
+
if (state) issue.state = state
|
|
145
|
+
}
|
|
146
|
+
if (input.title) issue.title = input.title
|
|
147
|
+
issue.updatedAt = '2026-01-04T00:00:00.000Z'
|
|
148
|
+
}
|
|
149
|
+
return Response.json({ data: { issueUpdate: { success: true } } })
|
|
150
|
+
}
|
|
151
|
+
if (query.includes('query IssueComments')) {
|
|
152
|
+
const issueId = String(variables.issueId)
|
|
153
|
+
return Response.json({
|
|
154
|
+
data: {
|
|
155
|
+
issue: {
|
|
156
|
+
comments: {
|
|
157
|
+
nodes: commentsByIssue.get(issueId) ?? [],
|
|
158
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
if (query.includes('query Comment')) {
|
|
165
|
+
const comment = [...commentsByIssue.values()]
|
|
166
|
+
.flat()
|
|
167
|
+
.find((candidate) => candidate.id === variables.id)
|
|
168
|
+
return Response.json({ data: { comment: comment ?? null } })
|
|
169
|
+
}
|
|
170
|
+
if (query.includes('mutation CommentCreate')) {
|
|
171
|
+
const input = variables.input as { issueId: string; body: string }
|
|
172
|
+
const row: StubComment = {
|
|
173
|
+
id: `comment-${(commentsByIssue.get(input.issueId)?.length ?? 0) + 1}`,
|
|
174
|
+
body: input.body,
|
|
175
|
+
createdAt: '2026-01-05T00:00:00.000Z',
|
|
176
|
+
updatedAt: '2026-01-05T00:00:00.000Z',
|
|
177
|
+
user: { id: 'user-1', name: 'Alice', displayName: 'Alice' },
|
|
178
|
+
}
|
|
179
|
+
commentsByIssue.set(input.issueId, [...(commentsByIssue.get(input.issueId) ?? []), row])
|
|
180
|
+
return Response.json({ data: { commentCreate: { success: true, comment: row } } })
|
|
181
|
+
}
|
|
182
|
+
if (query.includes('mutation CommentUpdate')) {
|
|
183
|
+
const row = [...commentsByIssue.values()]
|
|
184
|
+
.flat()
|
|
185
|
+
.find((candidate) => candidate.id === variables.id)
|
|
186
|
+
if (row) {
|
|
187
|
+
row.body = (variables.input as { body: string }).body
|
|
188
|
+
row.updatedAt = '2026-01-06T00:00:00.000Z'
|
|
189
|
+
}
|
|
190
|
+
return Response.json({ data: { commentUpdate: { success: true, comment: row ?? null } } })
|
|
191
|
+
}
|
|
192
|
+
if (query.includes('query IssueHistory')) {
|
|
193
|
+
return Response.json({
|
|
194
|
+
data: {
|
|
195
|
+
issue: {
|
|
196
|
+
history: {
|
|
197
|
+
nodes: [],
|
|
198
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
if (query.includes('query IssueTeam')) {
|
|
205
|
+
return Response.json({ data: { issue: { team: { id: 'team-1', key: 'GB' } } } })
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return Response.json({ errors: [{ message: 'unhandled Linear query' }] }, { status: 500 })
|
|
209
|
+
}) as typeof fetch
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
describe('postgres linear provider', () => {
|
|
213
|
+
let previousEnv: Record<string, string | undefined>
|
|
214
|
+
let previousFetch: typeof fetch
|
|
215
|
+
let sql: postgres.Sql | null = null
|
|
216
|
+
|
|
217
|
+
beforeEach(async () => {
|
|
218
|
+
previousFetch = globalThis.fetch
|
|
219
|
+
previousEnv = {
|
|
220
|
+
KANBAN_STORAGE: process.env['KANBAN_STORAGE'],
|
|
221
|
+
KANBAN_DATABASE_URL: process.env['KANBAN_DATABASE_URL'],
|
|
222
|
+
KANBAN_PROVIDER: process.env['KANBAN_PROVIDER'],
|
|
223
|
+
LINEAR_API_KEY: process.env['LINEAR_API_KEY'],
|
|
224
|
+
LINEAR_TEAM_ID: process.env['LINEAR_TEAM_ID'],
|
|
225
|
+
}
|
|
226
|
+
process.env['KANBAN_STORAGE'] = 'postgres'
|
|
227
|
+
process.env['KANBAN_DATABASE_URL'] = databaseUrl
|
|
228
|
+
process.env['KANBAN_PROVIDER'] = 'linear'
|
|
229
|
+
process.env['LINEAR_API_KEY'] = 'linear-key'
|
|
230
|
+
process.env['LINEAR_TEAM_ID'] = 'GB'
|
|
231
|
+
|
|
232
|
+
if (databaseUrl) {
|
|
233
|
+
sql = postgres(databaseUrl, { max: 1, onnotice: () => {} })
|
|
234
|
+
await sql`DROP TABLE IF EXISTS linear_activity`
|
|
235
|
+
await sql`DROP TABLE IF EXISTS linear_issues`
|
|
236
|
+
await sql`DROP TABLE IF EXISTS linear_projects`
|
|
237
|
+
await sql`DROP TABLE IF EXISTS linear_users`
|
|
238
|
+
await sql`DROP TABLE IF EXISTS linear_states`
|
|
239
|
+
await sql`DROP TABLE IF EXISTS linear_sync_meta`
|
|
240
|
+
}
|
|
241
|
+
globalThis.fetch = linearFetchStub()
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
afterEach(async () => {
|
|
245
|
+
globalThis.fetch = previousFetch
|
|
246
|
+
if (sql) {
|
|
247
|
+
await sql.end({ timeout: 1 })
|
|
248
|
+
sql = null
|
|
249
|
+
}
|
|
250
|
+
for (const [key, value] of Object.entries(previousEnv)) {
|
|
251
|
+
if (value === undefined) delete process.env[key]
|
|
252
|
+
else process.env[key] = value
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
pgTest('lists Linear tasks from a shared Postgres cache through the CLI path', async () => {
|
|
257
|
+
const tasks = expectOk<Task[]>(await run(['task', 'list', '-c', 'Todo']))
|
|
258
|
+
|
|
259
|
+
expect(tasks).toHaveLength(1)
|
|
260
|
+
expect(tasks[0]).toMatchObject({
|
|
261
|
+
id: 'linear:lin-1',
|
|
262
|
+
externalRef: 'GB-1',
|
|
263
|
+
title: 'Postgres cached Linear issue',
|
|
264
|
+
priority: 'high',
|
|
265
|
+
assignee: 'Alice',
|
|
266
|
+
project: 'Garage',
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
pgTest('creates, moves, and comments on Linear tasks through Postgres storage', async () => {
|
|
271
|
+
const created = expectOk<Task>(
|
|
272
|
+
await run([
|
|
273
|
+
'task',
|
|
274
|
+
'add',
|
|
275
|
+
'Created through Postgres Linear',
|
|
276
|
+
'-d',
|
|
277
|
+
'from the public CLI path',
|
|
278
|
+
'-p',
|
|
279
|
+
'medium',
|
|
280
|
+
'-a',
|
|
281
|
+
'Alice',
|
|
282
|
+
'--project',
|
|
283
|
+
'Garage',
|
|
284
|
+
]),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
expect(created).toMatchObject({
|
|
288
|
+
id: 'linear:lin-2',
|
|
289
|
+
externalRef: 'GB-2',
|
|
290
|
+
title: 'Created through Postgres Linear',
|
|
291
|
+
priority: 'medium',
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
const moved = expectOk<Task>(await run(['task', 'move', 'GB-2', 'Done']))
|
|
295
|
+
expect(moved.column_id).toBe('state-done')
|
|
296
|
+
|
|
297
|
+
const comment = expectOk<TaskComment>(
|
|
298
|
+
await run(['comment', 'add', 'GB-2', 'Garage projection comment']),
|
|
299
|
+
)
|
|
300
|
+
expect(comment).toMatchObject({
|
|
301
|
+
id: 'comment-1',
|
|
302
|
+
task_id: 'linear:lin-2',
|
|
303
|
+
body: 'Garage projection comment',
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
const comments = expectOk<TaskComment[]>(await run(['comment', 'list', 'GB-2']))
|
|
307
|
+
expect(comments).toHaveLength(1)
|
|
308
|
+
})
|
|
309
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import postgres from 'postgres'
|
|
3
|
+
|
|
4
|
+
import { run } from '../index'
|
|
5
|
+
import type { Task, TaskComment, TaskWithColumn } from '../types'
|
|
6
|
+
|
|
7
|
+
const databaseUrl = process.env['KANBAN_PG_TEST_URL'] ?? process.env['DATABASE_URL']
|
|
8
|
+
const pgTest = databaseUrl ? test : test.skip
|
|
9
|
+
|
|
10
|
+
function expectOk<T>(result: Awaited<ReturnType<typeof run>>): T {
|
|
11
|
+
expect(result.exitCode).toBe(0)
|
|
12
|
+
expect(result.output.ok).toBe(true)
|
|
13
|
+
if (!result.output.ok) throw new Error('expected successful CLI output')
|
|
14
|
+
return result.output.data as T
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('postgres local provider', () => {
|
|
18
|
+
let previousStorage: string | undefined
|
|
19
|
+
let previousDatabaseUrl: string | undefined
|
|
20
|
+
let previousProvider: string | undefined
|
|
21
|
+
let previousDbPath: string | undefined
|
|
22
|
+
let previousDefaultColumns: string | undefined
|
|
23
|
+
let sql: postgres.Sql | null = null
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
previousStorage = process.env['KANBAN_STORAGE']
|
|
27
|
+
previousDatabaseUrl = process.env['KANBAN_DATABASE_URL']
|
|
28
|
+
previousProvider = process.env['KANBAN_PROVIDER']
|
|
29
|
+
previousDbPath = process.env['KANBAN_DB_PATH']
|
|
30
|
+
previousDefaultColumns = process.env['KANBAN_DEFAULT_COLUMNS']
|
|
31
|
+
|
|
32
|
+
process.env['KANBAN_STORAGE'] = 'postgres'
|
|
33
|
+
process.env['KANBAN_DATABASE_URL'] = databaseUrl
|
|
34
|
+
process.env['KANBAN_PROVIDER'] = 'local'
|
|
35
|
+
delete process.env['KANBAN_DB_PATH']
|
|
36
|
+
|
|
37
|
+
if (databaseUrl) {
|
|
38
|
+
sql = postgres(databaseUrl, { max: 1, onnotice: () => {} })
|
|
39
|
+
await sql`DROP TABLE IF EXISTS comments`
|
|
40
|
+
await sql`DROP TABLE IF EXISTS column_time_tracking`
|
|
41
|
+
await sql`DROP TABLE IF EXISTS activity_log`
|
|
42
|
+
await sql`DROP TABLE IF EXISTS tasks`
|
|
43
|
+
await sql`DROP TABLE IF EXISTS columns`
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
if (sql) {
|
|
49
|
+
await sql.end({ timeout: 1 })
|
|
50
|
+
sql = null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (previousStorage === undefined) delete process.env['KANBAN_STORAGE']
|
|
54
|
+
else process.env['KANBAN_STORAGE'] = previousStorage
|
|
55
|
+
if (previousDatabaseUrl === undefined) delete process.env['KANBAN_DATABASE_URL']
|
|
56
|
+
else process.env['KANBAN_DATABASE_URL'] = previousDatabaseUrl
|
|
57
|
+
if (previousProvider === undefined) delete process.env['KANBAN_PROVIDER']
|
|
58
|
+
else process.env['KANBAN_PROVIDER'] = previousProvider
|
|
59
|
+
if (previousDbPath === undefined) delete process.env['KANBAN_DB_PATH']
|
|
60
|
+
else process.env['KANBAN_DB_PATH'] = previousDbPath
|
|
61
|
+
if (previousDefaultColumns === undefined) delete process.env['KANBAN_DEFAULT_COLUMNS']
|
|
62
|
+
else process.env['KANBAN_DEFAULT_COLUMNS'] = previousDefaultColumns
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
pgTest('runs task and comment commands through Postgres storage', async () => {
|
|
66
|
+
const created = expectOk<TaskWithColumn>(
|
|
67
|
+
await run([
|
|
68
|
+
'task',
|
|
69
|
+
'add',
|
|
70
|
+
'Postgres-backed task',
|
|
71
|
+
'-d',
|
|
72
|
+
'Stored in Postgres',
|
|
73
|
+
'-c',
|
|
74
|
+
'recurring',
|
|
75
|
+
'-p',
|
|
76
|
+
'high',
|
|
77
|
+
'-a',
|
|
78
|
+
'garage',
|
|
79
|
+
'--project',
|
|
80
|
+
'Dispatch',
|
|
81
|
+
'-m',
|
|
82
|
+
'{"storage":"postgres"}',
|
|
83
|
+
]),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
expect(created.title).toBe('Postgres-backed task')
|
|
87
|
+
expect(created.column_name).toBe('recurring')
|
|
88
|
+
expect(created.version).toBe('0')
|
|
89
|
+
|
|
90
|
+
const listed = expectOk<Task[]>(await run(['task', 'list', '-c', 'recurring']))
|
|
91
|
+
expect(listed.map((task) => task.id)).toContain(created.id)
|
|
92
|
+
|
|
93
|
+
const updated = expectOk<Task>(
|
|
94
|
+
await run(['task', 'update', created.id, '--title', 'Updated from Postgres', '-p', 'urgent']),
|
|
95
|
+
)
|
|
96
|
+
expect(updated.title).toBe('Updated from Postgres')
|
|
97
|
+
expect(updated.priority).toBe('urgent')
|
|
98
|
+
expect(updated.version).toBe('1')
|
|
99
|
+
|
|
100
|
+
const comment = expectOk<TaskComment>(
|
|
101
|
+
await run(['comment', 'add', created.id, 'Projection comment stored in Postgres']),
|
|
102
|
+
)
|
|
103
|
+
expect(comment.task_id).toBe(created.id)
|
|
104
|
+
|
|
105
|
+
const comments = expectOk<TaskComment[]>(await run(['comment', 'list', created.id]))
|
|
106
|
+
expect(comments).toHaveLength(1)
|
|
107
|
+
expect(comments[0]!.body).toBe('Projection comment stored in Postgres')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
pgTest('seeds custom default columns for Garage local compose', async () => {
|
|
111
|
+
process.env['KANBAN_DEFAULT_COLUMNS'] = 'Todo,In Progress,Human Review,Merging,Done'
|
|
112
|
+
|
|
113
|
+
const created = expectOk<TaskWithColumn>(
|
|
114
|
+
await run(['task', 'add', 'Garage column task', '-c', 'Todo']),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
expect(created.column_name).toBe('Todo')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
pgTest('defaults new tasks to the first configured column when backlog is absent', async () => {
|
|
121
|
+
process.env['KANBAN_DEFAULT_COLUMNS'] = 'Todo,In Progress,Human Review,Merging,Done'
|
|
122
|
+
|
|
123
|
+
const created = expectOk<TaskWithColumn>(
|
|
124
|
+
await run(['task', 'add', 'Garage default column task']),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
expect(created.column_name).toBe('Todo')
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { resolveKanbanStorageConfig } from '../storage-config'
|
|
4
|
+
|
|
5
|
+
describe('resolveKanbanStorageConfig', () => {
|
|
6
|
+
test('defaults to sqlite using the resolved board path', () => {
|
|
7
|
+
const config = resolveKanbanStorageConfig(
|
|
8
|
+
{ HOME: '/tmp/kanban-home' },
|
|
9
|
+
{ defaultSqlitePath: '/tmp/board.db' },
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
expect(config).toEqual({ mode: 'sqlite', sqlitePath: '/tmp/board.db' })
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('accepts uppercase postgres mode and requires a database URL', () => {
|
|
16
|
+
expect(() =>
|
|
17
|
+
resolveKanbanStorageConfig({ KANBAN_STORAGE: 'POSTGRES' }, { defaultSqlitePath: 'ignored' }),
|
|
18
|
+
).toThrow('KANBAN_DATABASE_URL is required when KANBAN_STORAGE=postgres')
|
|
19
|
+
|
|
20
|
+
expect(
|
|
21
|
+
resolveKanbanStorageConfig(
|
|
22
|
+
{
|
|
23
|
+
KANBAN_STORAGE: 'POSTGRES',
|
|
24
|
+
KANBAN_DATABASE_URL: 'postgres://garage:garage@localhost:5432/garage',
|
|
25
|
+
},
|
|
26
|
+
{ defaultSqlitePath: 'ignored' },
|
|
27
|
+
),
|
|
28
|
+
).toEqual({
|
|
29
|
+
mode: 'postgres',
|
|
30
|
+
databaseUrl: 'postgres://garage:garage@localhost:5432/garage',
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('rejects unknown storage modes', () => {
|
|
35
|
+
expect(() =>
|
|
36
|
+
resolveKanbanStorageConfig({ KANBAN_STORAGE: 'mysql' }, { defaultSqlitePath: 'ignored' }),
|
|
37
|
+
).toThrow("Unsupported KANBAN_STORAGE 'mysql'")
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { beforeEach, afterEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
import { Database } from 'bun:sqlite'
|
|
3
3
|
import { createHmac } from 'node:crypto'
|
|
4
|
-
import { verifyHmacSha256 } from '../webhooks'
|
|
4
|
+
import { verifyHmacSha256, verifySha256HmacSignatureHeader } from '../webhooks'
|
|
5
5
|
import { JiraProvider, type JiraProviderConfig } from '../providers/jira'
|
|
6
6
|
import { JiraClient } from '../providers/jira-client'
|
|
7
7
|
import { LinearProvider } from '../providers/linear'
|
|
@@ -57,6 +57,20 @@ describe('verifyHmacSha256', () => {
|
|
|
57
57
|
})
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
+
describe('verifySha256HmacSignatureHeader', () => {
|
|
61
|
+
test('accepts Jira WebSub-style sha256 signatures', () => {
|
|
62
|
+
const body = '{"hello":"world"}'
|
|
63
|
+
const sig = hmac('s3cr3t', body)
|
|
64
|
+
expect(verifySha256HmacSignatureHeader('s3cr3t', body, `sha256=${sig}`)).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('rejects missing method prefix', () => {
|
|
68
|
+
const body = '{"hello":"world"}'
|
|
69
|
+
const sig = hmac('s3cr3t', body)
|
|
70
|
+
expect(verifySha256HmacSignatureHeader('s3cr3t', body, sig)).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
60
74
|
const jiraConfig: JiraProviderConfig = {
|
|
61
75
|
baseUrl: 'https://example.atlassian.net',
|
|
62
76
|
email: 'u@example.com',
|
|
@@ -181,7 +195,7 @@ describe('Jira webhook', () => {
|
|
|
181
195
|
issue: { id: '300', key: 'ENG-300', fields: {} },
|
|
182
196
|
})
|
|
183
197
|
const result = await provider.handleWebhook({
|
|
184
|
-
headers: { 'x-hub-signature
|
|
198
|
+
headers: { 'x-hub-signature': 'sha256=deadbeef' },
|
|
185
199
|
rawBody: body,
|
|
186
200
|
})
|
|
187
201
|
expect(result.unauthorized).toBe(true)
|
|
@@ -214,12 +228,34 @@ describe('Jira webhook', () => {
|
|
|
214
228
|
})
|
|
215
229
|
const sig = hmac('topsecret', body)
|
|
216
230
|
const result = await provider.handleWebhook({
|
|
217
|
-
headers: { 'x-hub-signature
|
|
231
|
+
headers: { 'x-hub-signature': `sha256=${sig}` },
|
|
218
232
|
rawBody: body,
|
|
219
233
|
})
|
|
220
234
|
expect(result.handled).toBe(true)
|
|
221
235
|
})
|
|
222
236
|
|
|
237
|
+
test('rejects the old custom x-hub-signature-256 header when secret is configured', async () => {
|
|
238
|
+
const db = new Database(':memory:')
|
|
239
|
+
seedJira(db)
|
|
240
|
+
process.env['JIRA_WEBHOOK_SECRET'] = 'topsecret'
|
|
241
|
+
const client = new JiraClient({
|
|
242
|
+
baseUrl: jiraConfig.baseUrl,
|
|
243
|
+
email: jiraConfig.email,
|
|
244
|
+
apiToken: jiraConfig.apiToken,
|
|
245
|
+
})
|
|
246
|
+
const provider = new JiraProvider(db, jiraConfig, client)
|
|
247
|
+
const body = JSON.stringify({
|
|
248
|
+
webhookEvent: 'jira:issue_created',
|
|
249
|
+
issue: { id: '401', key: 'ENG-401', fields: {} },
|
|
250
|
+
})
|
|
251
|
+
const sig = hmac('topsecret', body)
|
|
252
|
+
const result = await provider.handleWebhook({
|
|
253
|
+
headers: { 'x-hub-signature-256': `sha256=${sig}` },
|
|
254
|
+
rawBody: body,
|
|
255
|
+
})
|
|
256
|
+
expect(result.unauthorized).toBe(true)
|
|
257
|
+
})
|
|
258
|
+
|
|
223
259
|
test('ignores issue updates from other projects', async () => {
|
|
224
260
|
const db = new Database(':memory:')
|
|
225
261
|
seedJira(db)
|