@andypai/agent-kanban 0.3.4 → 0.3.5
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/index.ts +65 -28
- package/src/provider-runtime.ts +110 -0
- package/src/providers/index.ts +16 -39
- package/src/providers/jira.ts +2 -2
- package/src/providers/linear.ts +2 -2
- package/src/providers/postgres-jira.ts +1188 -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
|
@@ -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
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -4,14 +4,17 @@ import { parseArgs } from 'node:util'
|
|
|
4
4
|
import { Database } from 'bun:sqlite'
|
|
5
5
|
import { KanbanError, ErrorCode } from './errors'
|
|
6
6
|
import { formatOutput, error, success } from './output'
|
|
7
|
-
import {
|
|
7
|
+
import { getDbPath, initSchema, seedDefaultColumns } from './db'
|
|
8
8
|
import { boardInit, boardReset } from './commands/board'
|
|
9
9
|
import { columnAdd, columnDelete, columnList, columnRename, columnReorder } from './commands/column'
|
|
10
10
|
import { bulkClearDoneCmd, bulkMoveAllCmd } from './commands/bulk'
|
|
11
11
|
import { getConfigPath, loadConfig, saveConfig } from './config'
|
|
12
12
|
import type { CliOutput, Priority } from './types'
|
|
13
|
-
import { createProvider } from './providers/index'
|
|
14
13
|
import { unsupportedOperation } from './providers/errors'
|
|
14
|
+
import { openKanbanRuntime } from './provider-runtime'
|
|
15
|
+
import { trackerConfigFromEnv } from './tracker-config'
|
|
16
|
+
import type { KanbanProvider } from './providers/types'
|
|
17
|
+
import { resolvePollingSyncIntervalMs } from './sync-config'
|
|
15
18
|
|
|
16
19
|
interface ParsedArgs {
|
|
17
20
|
values: Record<string, unknown>
|
|
@@ -48,7 +51,7 @@ function requireLocalProvider(providerType: string, feature: string): void {
|
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
async function routeTask(
|
|
51
|
-
provider:
|
|
54
|
+
provider: KanbanProvider,
|
|
52
55
|
action: string | undefined,
|
|
53
56
|
positionals: string[],
|
|
54
57
|
values: Record<string, unknown>,
|
|
@@ -140,7 +143,7 @@ async function routeTask(
|
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
async function routeComment(
|
|
143
|
-
provider:
|
|
146
|
+
provider: KanbanProvider,
|
|
144
147
|
action: string | undefined,
|
|
145
148
|
positionals: string[],
|
|
146
149
|
): Promise<CliOutput> {
|
|
@@ -224,7 +227,7 @@ function routeBulk(
|
|
|
224
227
|
}
|
|
225
228
|
|
|
226
229
|
async function routeConfig(
|
|
227
|
-
provider:
|
|
230
|
+
provider: KanbanProvider,
|
|
228
231
|
dbPath: string,
|
|
229
232
|
action: string | undefined,
|
|
230
233
|
positionals: string[],
|
|
@@ -288,7 +291,7 @@ async function routeConfig(
|
|
|
288
291
|
|
|
289
292
|
async function routeBoard(
|
|
290
293
|
db: Database,
|
|
291
|
-
provider:
|
|
294
|
+
provider: KanbanProvider,
|
|
292
295
|
action: string | undefined,
|
|
293
296
|
): Promise<CliOutput> {
|
|
294
297
|
switch (action) {
|
|
@@ -316,23 +319,29 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
|
|
|
316
319
|
return { output: { ok: true, data: { message: HELP_TEXT } }, exitCode: 0 }
|
|
317
320
|
}
|
|
318
321
|
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
+
const runtime = await openKanbanRuntime({
|
|
323
|
+
dbPath: (values.db as string | undefined) ?? getDbPath(),
|
|
324
|
+
})
|
|
322
325
|
|
|
323
326
|
try {
|
|
324
|
-
const provider
|
|
327
|
+
const { provider, sqliteDb, dbPath } = runtime
|
|
325
328
|
const group = positionals[0]
|
|
326
329
|
const action = positionals[1]
|
|
327
330
|
|
|
328
331
|
if (!group) {
|
|
329
|
-
return { output: await routeBoard(
|
|
332
|
+
if (sqliteDb) return { output: await routeBoard(sqliteDb, provider, undefined), exitCode: 0 }
|
|
333
|
+
return { output: success(await provider.getBoard()), exitCode: 0 }
|
|
330
334
|
}
|
|
331
335
|
|
|
332
336
|
let output: CliOutput
|
|
333
337
|
switch (group) {
|
|
334
338
|
case 'board':
|
|
335
|
-
|
|
339
|
+
if (sqliteDb) {
|
|
340
|
+
output = await routeBoard(sqliteDb, provider, action)
|
|
341
|
+
} else {
|
|
342
|
+
if (action === 'view' || action === undefined) output = success(await provider.getBoard())
|
|
343
|
+
else unsupportedOperation(`board ${action} is not available with KANBAN_STORAGE=postgres`)
|
|
344
|
+
}
|
|
336
345
|
break
|
|
337
346
|
case 'task':
|
|
338
347
|
output = await routeTask(provider, action, positionals, values)
|
|
@@ -341,13 +350,24 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
|
|
|
341
350
|
output = await routeComment(provider, action, positionals)
|
|
342
351
|
break
|
|
343
352
|
case 'column':
|
|
344
|
-
|
|
353
|
+
if (!sqliteDb)
|
|
354
|
+
unsupportedOperation('Column commands are not available with KANBAN_STORAGE=postgres')
|
|
355
|
+
output = routeColumn(sqliteDb, provider.type, action, positionals, values)
|
|
345
356
|
break
|
|
346
357
|
case 'bulk':
|
|
347
|
-
|
|
358
|
+
if (!sqliteDb)
|
|
359
|
+
unsupportedOperation('Bulk commands are not available with KANBAN_STORAGE=postgres')
|
|
360
|
+
output = routeBulk(sqliteDb, provider.type, action, positionals)
|
|
348
361
|
break
|
|
349
362
|
case 'config':
|
|
350
|
-
|
|
363
|
+
if (sqliteDb) {
|
|
364
|
+
output = await routeConfig(provider, dbPath, action, positionals, values)
|
|
365
|
+
} else {
|
|
366
|
+
if (action === 'show' || action === undefined)
|
|
367
|
+
output = success(await provider.getConfig())
|
|
368
|
+
else
|
|
369
|
+
unsupportedOperation(`config ${action} is not available with KANBAN_STORAGE=postgres`)
|
|
370
|
+
}
|
|
351
371
|
break
|
|
352
372
|
default:
|
|
353
373
|
throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown command group '${group}'`)
|
|
@@ -355,7 +375,7 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
|
|
|
355
375
|
|
|
356
376
|
return { output, exitCode: 0 }
|
|
357
377
|
} finally {
|
|
358
|
-
|
|
378
|
+
await runtime.close()
|
|
359
379
|
}
|
|
360
380
|
}
|
|
361
381
|
|
|
@@ -397,18 +417,19 @@ Commands:
|
|
|
397
417
|
config add-project <name> Add project
|
|
398
418
|
config remove-project <name> Remove project
|
|
399
419
|
|
|
400
|
-
serve Start web dashboard [--port 3000]
|
|
420
|
+
serve Start web dashboard [--port 3000] [--sync-interval-ms ms]
|
|
401
421
|
mcp Run as an MCP server over stdio (for Claude Desktop, etc.)
|
|
402
422
|
|
|
403
423
|
Options:
|
|
404
424
|
--pretty Human-readable output (default: JSON)
|
|
405
|
-
--db <path>
|
|
425
|
+
--db <path> SQLite database path (default: local ./.kanban if present, else ~/.kanban if present, else create ./.kanban)
|
|
406
426
|
--project <n> Filter/set project
|
|
407
427
|
-h, --help Show this help`
|
|
408
428
|
|
|
409
429
|
export interface ServeOptions {
|
|
410
430
|
db?: string
|
|
411
431
|
port: number
|
|
432
|
+
syncIntervalMs?: number
|
|
412
433
|
tunnel: boolean
|
|
413
434
|
}
|
|
414
435
|
|
|
@@ -418,6 +439,7 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
418
439
|
options: {
|
|
419
440
|
db: { type: 'string' },
|
|
420
441
|
port: { type: 'string' },
|
|
442
|
+
'sync-interval-ms': { type: 'string' },
|
|
421
443
|
tunnel: { type: 'boolean', default: false },
|
|
422
444
|
},
|
|
423
445
|
strict: false,
|
|
@@ -429,10 +451,17 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
429
451
|
return {
|
|
430
452
|
db: values.db as string | undefined,
|
|
431
453
|
port,
|
|
454
|
+
...(values['sync-interval-ms']
|
|
455
|
+
? { syncIntervalMs: parseSyncIntervalMs(values['sync-interval-ms'] as string) }
|
|
456
|
+
: {}),
|
|
432
457
|
tunnel: Boolean(values.tunnel),
|
|
433
458
|
}
|
|
434
459
|
}
|
|
435
460
|
|
|
461
|
+
function parseSyncIntervalMs(raw: string): number {
|
|
462
|
+
return resolvePollingSyncIntervalMs(raw, { label: '--sync-interval-ms' })
|
|
463
|
+
}
|
|
464
|
+
|
|
436
465
|
export interface McpOptions {
|
|
437
466
|
db?: string
|
|
438
467
|
}
|
|
@@ -452,21 +481,29 @@ if (import.meta.main) {
|
|
|
452
481
|
|
|
453
482
|
if (argv[0] === 'mcp') {
|
|
454
483
|
const opts = parseMcpArgs(argv)
|
|
455
|
-
const
|
|
456
|
-
const db = openDb(dbPath)
|
|
457
|
-
migrateSchema(db)
|
|
458
|
-
const provider = createProvider(db, dbPath)
|
|
484
|
+
const runtime = await openKanbanRuntime({ dbPath: opts.db ?? getDbPath() })
|
|
459
485
|
const { startStdioMcpServer } = await import('./commands/mcp')
|
|
460
|
-
|
|
486
|
+
try {
|
|
487
|
+
await startStdioMcpServer(runtime.provider)
|
|
488
|
+
} finally {
|
|
489
|
+
await runtime.close()
|
|
490
|
+
}
|
|
461
491
|
} else if (argv[0] === 'serve') {
|
|
462
492
|
const opts = parseServeArgs(argv)
|
|
463
493
|
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
494
|
+
const runtime = await openKanbanRuntime({
|
|
495
|
+
dbPath: opts.db ?? getDbPath(),
|
|
496
|
+
...(opts.syncIntervalMs !== undefined
|
|
497
|
+
? {
|
|
498
|
+
tracker: {
|
|
499
|
+
...trackerConfigFromEnv(process.env),
|
|
500
|
+
syncIntervalMs: opts.syncIntervalMs,
|
|
501
|
+
},
|
|
502
|
+
}
|
|
503
|
+
: {}),
|
|
504
|
+
})
|
|
468
505
|
const { startServer } = await import('./server')
|
|
469
|
-
startServer(provider, opts.port)
|
|
506
|
+
startServer(runtime.provider, opts.port, { syncIntervalMs: runtime.syncIntervalMs })
|
|
470
507
|
|
|
471
508
|
if (opts.tunnel) {
|
|
472
509
|
const { startCloudflareTunnel } = await import('./tunnel')
|