@andypai/agent-kanban 0.3.3 → 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 +26 -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-provider-read.test.ts +22 -0
- package/src/__tests__/jira-wiring.test.ts +47 -17
- package/src/__tests__/linear-provider-sync.test.ts +77 -0
- 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__/sync-config.test.ts +32 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +65 -28
- package/src/mcp/errors.ts +1 -0
- package/src/provider-runtime.ts +110 -0
- package/src/providers/index.ts +16 -37
- package/src/providers/jira.ts +5 -2
- package/src/providers/linear.ts +3 -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 -3
- package/src/storage-config.ts +41 -0
- package/src/sync-config.ts +21 -0
- package/src/tracker-config.ts +104 -0
|
@@ -0,0 +1,315 @@
|
|
|
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 } 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 FetchInit = RequestInit | undefined
|
|
11
|
+
type StubCall = { url: string; init?: FetchInit }
|
|
12
|
+
type StubHandler = (url: string, init?: FetchInit) => Response | Promise<Response>
|
|
13
|
+
type StubRoute = { match: (url: string) => boolean; handler: StubHandler }
|
|
14
|
+
|
|
15
|
+
function jiraFetchStub(routes: StubRoute[]): {
|
|
16
|
+
fn: typeof fetch
|
|
17
|
+
calls: StubCall[]
|
|
18
|
+
} {
|
|
19
|
+
const calls: StubCall[] = []
|
|
20
|
+
const fn = (async (input: string | URL | Request, init?: FetchInit) => {
|
|
21
|
+
const url =
|
|
22
|
+
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
|
|
23
|
+
calls.push({ url, init })
|
|
24
|
+
for (const route of routes) {
|
|
25
|
+
if (route.match(url)) return route.handler(url, init)
|
|
26
|
+
}
|
|
27
|
+
return new Response('route not stubbed: ' + url, { status: 500 })
|
|
28
|
+
}) as unknown as typeof fetch
|
|
29
|
+
return { fn, calls }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
33
|
+
return new Response(JSON.stringify(body), {
|
|
34
|
+
status,
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const projectFixture = { id: '10000', key: 'ENG', name: 'Engineering' }
|
|
40
|
+
|
|
41
|
+
function makeIssue(
|
|
42
|
+
overrides: Partial<{
|
|
43
|
+
id: string
|
|
44
|
+
key: string
|
|
45
|
+
summary: string
|
|
46
|
+
updated: string
|
|
47
|
+
}> = {},
|
|
48
|
+
): Record<string, unknown> {
|
|
49
|
+
return {
|
|
50
|
+
id: overrides.id ?? '10001',
|
|
51
|
+
key: overrides.key ?? 'ENG-1',
|
|
52
|
+
fields: {
|
|
53
|
+
summary: overrides.summary ?? 'Postgres cached Jira issue',
|
|
54
|
+
description: null,
|
|
55
|
+
status: { id: '10', name: 'To Do' },
|
|
56
|
+
issuetype: { id: '10000', name: 'Task' },
|
|
57
|
+
priority: { id: '2', name: 'High' },
|
|
58
|
+
assignee: { accountId: 'a1', displayName: 'Alice' },
|
|
59
|
+
labels: ['garage'],
|
|
60
|
+
comment: { total: 0 },
|
|
61
|
+
created: '2026-01-01T00:00:00Z',
|
|
62
|
+
updated: overrides.updated ?? '2026-01-02T00:00:00Z',
|
|
63
|
+
project: { id: '10000', key: 'ENG' },
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function standardRoutes(): StubRoute[] {
|
|
69
|
+
const issues = [makeIssue()]
|
|
70
|
+
const comments: Record<
|
|
71
|
+
string,
|
|
72
|
+
Array<{ id: string; body: unknown; created: string; updated: string }>
|
|
73
|
+
> = {}
|
|
74
|
+
const setIssueStatus = (issueKey: string, statusId: string, name: string): void => {
|
|
75
|
+
const issue = issues.find((candidate) => String(candidate.key) === issueKey) as
|
|
76
|
+
| { fields?: { status?: { id: string; name: string }; updated?: string } }
|
|
77
|
+
| undefined
|
|
78
|
+
if (!issue?.fields?.status) return
|
|
79
|
+
issue.fields.status = { id: statusId, name }
|
|
80
|
+
issue.fields.updated = '2026-01-06T00:00:00Z'
|
|
81
|
+
}
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
match: (url) => url.includes('/rest/api/3/project/ENG/statuses'),
|
|
85
|
+
handler: () =>
|
|
86
|
+
jsonResponse([
|
|
87
|
+
{
|
|
88
|
+
id: 'cat-1',
|
|
89
|
+
name: 'To Do',
|
|
90
|
+
statuses: [
|
|
91
|
+
{ id: '10', name: 'To Do' },
|
|
92
|
+
{ id: '20', name: 'Done' },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
]),
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
match: (url) => url.includes('/rest/api/3/project/ENG'),
|
|
99
|
+
handler: () => jsonResponse(projectFixture),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
match: (url) => url.includes('/rest/api/3/user/assignable/search'),
|
|
103
|
+
handler: () => jsonResponse([{ accountId: 'a1', displayName: 'Alice', active: true }]),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
match: (url) => url.includes('/rest/api/3/priority'),
|
|
107
|
+
handler: () => jsonResponse([{ id: '2', name: 'High' }]),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
match: (url) => url.includes('/rest/api/3/issuetype/project'),
|
|
111
|
+
handler: () => jsonResponse([{ id: '10000', name: 'Task' }]),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
match: (url) => url.endsWith('/rest/api/3/issue'),
|
|
115
|
+
handler: async (_url, init) => {
|
|
116
|
+
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
|
117
|
+
fields?: { summary?: string }
|
|
118
|
+
}
|
|
119
|
+
const issue = makeIssue({
|
|
120
|
+
id: '10002',
|
|
121
|
+
key: 'ENG-2',
|
|
122
|
+
summary: body.fields?.summary ?? 'Created issue',
|
|
123
|
+
updated: '2026-01-03T00:00:00Z',
|
|
124
|
+
})
|
|
125
|
+
issues.push(issue)
|
|
126
|
+
return jsonResponse(
|
|
127
|
+
{ id: '10002', key: 'ENG-2', self: 'https://example/rest/api/3/issue/10002' },
|
|
128
|
+
201,
|
|
129
|
+
)
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
match: (url) => /\/rest\/api\/3\/issue\/ENG-\d+\/comment$/.test(new URL(url).pathname),
|
|
134
|
+
handler: async (url, init) => {
|
|
135
|
+
const issueKey = new URL(url).pathname.match(/\/issue\/(ENG-\d+)\/comment/)![1]!
|
|
136
|
+
if (init?.method === 'POST') {
|
|
137
|
+
const body = JSON.parse(String(init.body ?? '{}')) as { body?: unknown }
|
|
138
|
+
const row = {
|
|
139
|
+
id: `comment-${(comments[issueKey]?.length ?? 0) + 1}`,
|
|
140
|
+
body: body.body,
|
|
141
|
+
created: '2026-01-04T00:00:00Z',
|
|
142
|
+
updated: '2026-01-04T00:00:00Z',
|
|
143
|
+
}
|
|
144
|
+
comments[issueKey] = [...(comments[issueKey] ?? []), row]
|
|
145
|
+
return jsonResponse(row, 201)
|
|
146
|
+
}
|
|
147
|
+
return jsonResponse({
|
|
148
|
+
startAt: 0,
|
|
149
|
+
maxResults: 100,
|
|
150
|
+
total: comments[issueKey]?.length ?? 0,
|
|
151
|
+
comments: comments[issueKey] ?? [],
|
|
152
|
+
})
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
match: (url) =>
|
|
157
|
+
/\/rest\/api\/3\/issue\/ENG-\d+\/comment\/comment-\d+$/.test(new URL(url).pathname),
|
|
158
|
+
handler: async (url, init) => {
|
|
159
|
+
const [, issueKey, commentId] = new URL(url).pathname.match(
|
|
160
|
+
/\/issue\/(ENG-\d+)\/comment\/(comment-\d+)$/,
|
|
161
|
+
)!
|
|
162
|
+
const rows = comments[issueKey!] ?? []
|
|
163
|
+
const existing = rows.find((row) => row.id === commentId)
|
|
164
|
+
if (!existing) return jsonResponse({ errorMessages: ['missing'] }, 404)
|
|
165
|
+
if (init?.method === 'PUT') {
|
|
166
|
+
const body = JSON.parse(String(init.body ?? '{}')) as { body?: unknown }
|
|
167
|
+
existing.body = body.body
|
|
168
|
+
existing.updated = '2026-01-05T00:00:00Z'
|
|
169
|
+
}
|
|
170
|
+
return jsonResponse(existing)
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
match: (url) => /\/rest\/api\/3\/issue\/ENG-\d+\/transitions$/.test(new URL(url).pathname),
|
|
175
|
+
handler: async (url, init) => {
|
|
176
|
+
const issueKey = new URL(url).pathname.match(/\/issue\/(ENG-\d+)\/transitions$/)![1]!
|
|
177
|
+
if (init?.method === 'POST') {
|
|
178
|
+
setIssueStatus(issueKey, '20', 'Done')
|
|
179
|
+
return new Response(null, { status: 204 })
|
|
180
|
+
}
|
|
181
|
+
return jsonResponse({
|
|
182
|
+
transitions: [{ id: 'move-done', name: 'Done', to: { id: '20', name: 'Done' } }],
|
|
183
|
+
})
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
match: (url) => /\/rest\/api\/3\/issue\/[^/]+\/changelog/.test(url),
|
|
188
|
+
handler: () =>
|
|
189
|
+
jsonResponse({
|
|
190
|
+
startAt: 0,
|
|
191
|
+
maxResults: 100,
|
|
192
|
+
total: 0,
|
|
193
|
+
isLast: true,
|
|
194
|
+
values: [],
|
|
195
|
+
}),
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
match: (url) => url.includes('/rest/api/3/search/jql'),
|
|
199
|
+
handler: () =>
|
|
200
|
+
jsonResponse({
|
|
201
|
+
startAt: 0,
|
|
202
|
+
maxResults: 100,
|
|
203
|
+
total: issues.length,
|
|
204
|
+
issues,
|
|
205
|
+
}),
|
|
206
|
+
},
|
|
207
|
+
]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function expectOk<T>(result: Awaited<ReturnType<typeof run>>): T {
|
|
211
|
+
expect(result.exitCode).toBe(0)
|
|
212
|
+
expect(result.output.ok).toBe(true)
|
|
213
|
+
if (!result.output.ok) throw new Error('expected successful CLI output')
|
|
214
|
+
return result.output.data as T
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
describe('postgres jira provider', () => {
|
|
218
|
+
let previousEnv: Record<string, string | undefined>
|
|
219
|
+
let previousFetch: typeof fetch
|
|
220
|
+
let sql: postgres.Sql | null = null
|
|
221
|
+
|
|
222
|
+
beforeEach(async () => {
|
|
223
|
+
previousFetch = globalThis.fetch
|
|
224
|
+
previousEnv = {
|
|
225
|
+
KANBAN_STORAGE: process.env['KANBAN_STORAGE'],
|
|
226
|
+
KANBAN_DATABASE_URL: process.env['KANBAN_DATABASE_URL'],
|
|
227
|
+
KANBAN_PROVIDER: process.env['KANBAN_PROVIDER'],
|
|
228
|
+
JIRA_BASE_URL: process.env['JIRA_BASE_URL'],
|
|
229
|
+
JIRA_EMAIL: process.env['JIRA_EMAIL'],
|
|
230
|
+
JIRA_API_TOKEN: process.env['JIRA_API_TOKEN'],
|
|
231
|
+
JIRA_PROJECT_KEY: process.env['JIRA_PROJECT_KEY'],
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
process.env['KANBAN_STORAGE'] = 'postgres'
|
|
235
|
+
process.env['KANBAN_DATABASE_URL'] = databaseUrl
|
|
236
|
+
process.env['KANBAN_PROVIDER'] = 'jira'
|
|
237
|
+
process.env['JIRA_BASE_URL'] = 'https://example.atlassian.net'
|
|
238
|
+
process.env['JIRA_EMAIL'] = 'user@example.com'
|
|
239
|
+
process.env['JIRA_API_TOKEN'] = 'token'
|
|
240
|
+
process.env['JIRA_PROJECT_KEY'] = 'ENG'
|
|
241
|
+
|
|
242
|
+
if (databaseUrl) {
|
|
243
|
+
sql = postgres(databaseUrl, { max: 1, onnotice: () => {} })
|
|
244
|
+
await sql`DROP TABLE IF EXISTS jira_activity`
|
|
245
|
+
await sql`DROP TABLE IF EXISTS jira_issues`
|
|
246
|
+
await sql`DROP TABLE IF EXISTS jira_issue_types`
|
|
247
|
+
await sql`DROP TABLE IF EXISTS jira_priorities`
|
|
248
|
+
await sql`DROP TABLE IF EXISTS jira_users`
|
|
249
|
+
await sql`DROP TABLE IF EXISTS jira_columns`
|
|
250
|
+
await sql`DROP TABLE IF EXISTS jira_sync_meta`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
globalThis.fetch = jiraFetchStub(standardRoutes()).fn
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
afterEach(async () => {
|
|
257
|
+
globalThis.fetch = previousFetch
|
|
258
|
+
if (sql) {
|
|
259
|
+
await sql.end({ timeout: 1 })
|
|
260
|
+
sql = null
|
|
261
|
+
}
|
|
262
|
+
for (const [key, value] of Object.entries(previousEnv)) {
|
|
263
|
+
if (value === undefined) delete process.env[key]
|
|
264
|
+
else process.env[key] = value
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
pgTest('lists Jira tasks from a shared Postgres cache through the CLI path', async () => {
|
|
269
|
+
const tasks = expectOk<Task[]>(await run(['task', 'list', '-c', 'To Do']))
|
|
270
|
+
|
|
271
|
+
expect(tasks).toHaveLength(1)
|
|
272
|
+
expect(tasks[0]).toMatchObject({
|
|
273
|
+
id: 'jira:10001',
|
|
274
|
+
externalRef: 'ENG-1',
|
|
275
|
+
title: 'Postgres cached Jira issue',
|
|
276
|
+
priority: 'high',
|
|
277
|
+
assignee: 'Alice',
|
|
278
|
+
project: 'ENG',
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
pgTest('creates Jira tasks and writes comments through Postgres storage', async () => {
|
|
283
|
+
const created = expectOk<Task>(
|
|
284
|
+
await run(['task', 'add', 'Created through Postgres Jira', '-p', 'high', '-a', 'Alice']),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
expect(created).toMatchObject({
|
|
288
|
+
id: 'jira:10002',
|
|
289
|
+
externalRef: 'ENG-2',
|
|
290
|
+
title: 'Created through Postgres Jira',
|
|
291
|
+
priority: 'high',
|
|
292
|
+
assignee: 'Alice',
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
const comment = expectOk(await run(['comment', 'add', 'ENG-2', 'Garage projection comment']))
|
|
296
|
+
expect(comment).toMatchObject({
|
|
297
|
+
id: 'comment-1',
|
|
298
|
+
task_id: 'jira:10002',
|
|
299
|
+
body: 'Garage projection comment',
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
const comments = expectOk(await run(['comment', 'list', 'ENG-2']))
|
|
303
|
+
expect(comments).toHaveLength(1)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
pgTest('moves Jira tasks through Postgres storage', async () => {
|
|
307
|
+
const moved = expectOk<Task>(await run(['task', 'move', 'ENG-1', 'Done']))
|
|
308
|
+
|
|
309
|
+
expect(moved).toMatchObject({
|
|
310
|
+
id: 'jira:10001',
|
|
311
|
+
externalRef: 'ENG-1',
|
|
312
|
+
column_id: '20',
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
})
|
|
@@ -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
|
+
})
|