@andypai/agent-kanban 0.2.0 → 0.3.0
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 +89 -22
- package/package.json +4 -2
- package/src/__tests__/activity.test.ts +15 -9
- package/src/__tests__/api.test.ts +96 -0
- package/src/__tests__/board-utils.test.ts +100 -0
- package/src/__tests__/commands/board.test.ts +6 -13
- package/src/__tests__/conflict.test.ts +64 -0
- package/src/__tests__/index.test.ts +233 -56
- package/src/__tests__/jira-adf.test.ts +168 -0
- package/src/__tests__/jira-cache.test.ts +304 -0
- package/src/__tests__/jira-client.test.ts +169 -0
- package/src/__tests__/jira-provider-comment.test.ts +281 -0
- package/src/__tests__/jira-provider-mutations.test.ts +771 -0
- package/src/__tests__/jira-provider-read.test.ts +594 -0
- package/src/__tests__/jira-wiring.test.ts +187 -0
- package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
- package/src/__tests__/linear-provider-comment.test.ts +243 -0
- package/src/__tests__/linear-provider-sync.test.ts +493 -0
- package/src/__tests__/local-provider-comment.test.ts +60 -0
- package/src/__tests__/mcp-core.test.ts +164 -0
- package/src/__tests__/mcp-server.test.ts +252 -0
- package/src/__tests__/server.test.ts +298 -0
- package/src/__tests__/webhooks.test.ts +604 -0
- package/src/activity.ts +1 -11
- package/src/api.ts +154 -19
- package/src/commands/board.ts +1 -11
- package/src/commands/mcp.ts +87 -0
- package/src/db.ts +115 -3
- package/src/errors.ts +2 -0
- package/src/id.ts +1 -1
- package/src/index.ts +72 -18
- package/src/mcp/core.ts +193 -0
- package/src/mcp/errors.ts +109 -0
- package/src/mcp/index.ts +13 -0
- package/src/mcp/server.ts +512 -0
- package/src/mcp/types.ts +72 -0
- package/src/providers/capabilities.ts +15 -0
- package/src/providers/index.ts +31 -1
- package/src/providers/jira-adf.ts +275 -0
- package/src/providers/jira-cache.ts +625 -0
- package/src/providers/jira-client.ts +390 -0
- package/src/providers/jira.ts +778 -0
- package/src/providers/linear-cache.ts +249 -70
- package/src/providers/linear-client.ts +256 -13
- package/src/providers/linear.ts +337 -14
- package/src/providers/local.ts +68 -17
- package/src/providers/types.ts +18 -2
- package/src/server.ts +139 -11
- package/src/tunnel.ts +79 -0
- package/src/types.ts +18 -2
- package/src/webhooks.ts +36 -0
- package/ui/dist/assets/index-DBnoKL_k.css +1 -0
- package/ui/dist/assets/index-qNVJ6clH.js +40 -0
- package/ui/dist/index.html +2 -2
- package/src/__tests__/commands/task.test.ts +0 -144
- package/src/commands/task.ts +0 -117
- package/src/fixtures.ts +0 -128
- package/ui/dist/assets/index-B8f9NB4z.css +0 -1
- package/ui/dist/assets/index-zWp-rB7b.js +0 -40
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Database } from 'bun:sqlite'
|
|
3
|
+
import { LinearProvider } from '../providers/linear.ts'
|
|
4
|
+
import {
|
|
5
|
+
getCachedTasks,
|
|
6
|
+
initLinearCacheSchema,
|
|
7
|
+
loadSyncMeta,
|
|
8
|
+
replaceStates,
|
|
9
|
+
saveSyncMeta,
|
|
10
|
+
upsertIssues,
|
|
11
|
+
} from '../providers/linear-cache.ts'
|
|
12
|
+
|
|
13
|
+
let db: Database
|
|
14
|
+
let originalFetch: typeof fetch
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
db = new Database(':memory:')
|
|
18
|
+
initLinearCacheSchema(db)
|
|
19
|
+
originalFetch = globalThis.fetch
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
globalThis.fetch = originalFetch
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
function linearIssue(
|
|
27
|
+
overrides: Partial<{
|
|
28
|
+
id: string
|
|
29
|
+
identifier: string
|
|
30
|
+
title: string
|
|
31
|
+
description: string
|
|
32
|
+
priority: number
|
|
33
|
+
url: string
|
|
34
|
+
createdAt: string
|
|
35
|
+
updatedAt: string
|
|
36
|
+
assignee: { id: string; name?: string | null; displayName?: string | null } | null
|
|
37
|
+
project: { id: string; name: string; url?: string | null; state?: string | null } | null
|
|
38
|
+
state: { id: string; name: string; position: number }
|
|
39
|
+
labels: { nodes: Array<{ id: string; name: string }> }
|
|
40
|
+
comments: {
|
|
41
|
+
totalCount?: number
|
|
42
|
+
nodes: Array<{ id: string }>
|
|
43
|
+
pageInfo?: { hasNextPage: boolean; endCursor: string | null }
|
|
44
|
+
}
|
|
45
|
+
}> = {},
|
|
46
|
+
) {
|
|
47
|
+
return {
|
|
48
|
+
id: 'issue-1',
|
|
49
|
+
identifier: 'R2P-1',
|
|
50
|
+
title: 'Linear task',
|
|
51
|
+
description: '',
|
|
52
|
+
priority: 2,
|
|
53
|
+
url: 'https://linear.app/x/issue/R2P-1',
|
|
54
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
55
|
+
updatedAt: '2026-01-02T00:00:00Z',
|
|
56
|
+
assignee: null,
|
|
57
|
+
project: null,
|
|
58
|
+
state: { id: 'state-1', name: 'Todo', position: 0 },
|
|
59
|
+
labels: { nodes: [] },
|
|
60
|
+
comments: { totalCount: 0, nodes: [], pageInfo: { hasNextPage: false, endCursor: null } },
|
|
61
|
+
...overrides,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('LinearProvider sync', () => {
|
|
66
|
+
test('resolves a configured team key before querying issues', async () => {
|
|
67
|
+
const seenIssueTeamIds: string[] = []
|
|
68
|
+
|
|
69
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
70
|
+
const body = JSON.parse(String(init?.body)) as {
|
|
71
|
+
query: string
|
|
72
|
+
variables: Record<string, unknown>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (body.query.includes('query TeamSnapshot')) {
|
|
76
|
+
return new Response(
|
|
77
|
+
JSON.stringify({
|
|
78
|
+
data: {
|
|
79
|
+
team: {
|
|
80
|
+
id: '3ca24047-e954-44e8-b266-c7182410befb',
|
|
81
|
+
key: 'R2P',
|
|
82
|
+
name: 'R2pi',
|
|
83
|
+
states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (body.query.includes('query Users')) {
|
|
92
|
+
return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
|
|
93
|
+
status: 200,
|
|
94
|
+
headers: { 'content-type': 'application/json' },
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (body.query.includes('query Projects')) {
|
|
99
|
+
return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
|
|
100
|
+
status: 200,
|
|
101
|
+
headers: { 'content-type': 'application/json' },
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (body.query.includes('query Issues')) {
|
|
106
|
+
seenIssueTeamIds.push(String(body.variables.teamId))
|
|
107
|
+
return new Response(
|
|
108
|
+
JSON.stringify({
|
|
109
|
+
data: {
|
|
110
|
+
issues: {
|
|
111
|
+
nodes: [],
|
|
112
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return new Response(`Unexpected query: ${body.query}`, { status: 500 })
|
|
121
|
+
}) as unknown as typeof fetch
|
|
122
|
+
|
|
123
|
+
const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
|
|
124
|
+
await provider.getBoard()
|
|
125
|
+
|
|
126
|
+
expect(seenIssueTeamIds).toEqual(['3ca24047-e954-44e8-b266-c7182410befb'])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('createTask uses the resolved team UUID from cached sync meta', async () => {
|
|
130
|
+
replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
|
|
131
|
+
saveSyncMeta(db, {
|
|
132
|
+
team: { id: '3ca24047-e954-44e8-b266-c7182410befb', key: 'R2P', name: 'R2pi' },
|
|
133
|
+
lastSyncAt: new Date().toISOString(),
|
|
134
|
+
lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
let createIssueTeamId: string | null = null
|
|
138
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
139
|
+
const body = JSON.parse(String(init?.body)) as {
|
|
140
|
+
query: string
|
|
141
|
+
variables: { input?: { teamId?: string } }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (body.query.includes('mutation CreateIssue')) {
|
|
145
|
+
createIssueTeamId = body.variables.input?.teamId ?? null
|
|
146
|
+
return new Response(
|
|
147
|
+
JSON.stringify({
|
|
148
|
+
data: {
|
|
149
|
+
issueCreate: {
|
|
150
|
+
success: true,
|
|
151
|
+
issue: {
|
|
152
|
+
id: 'issue-1',
|
|
153
|
+
identifier: 'R2P-1',
|
|
154
|
+
title: 'Hello',
|
|
155
|
+
description: '',
|
|
156
|
+
priority: 3,
|
|
157
|
+
url: 'https://linear.app/x/issue/R2P-1',
|
|
158
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
159
|
+
updatedAt: '2026-01-01T00:00:00Z',
|
|
160
|
+
assignee: null,
|
|
161
|
+
project: null,
|
|
162
|
+
state: { id: 'state-1', name: 'Todo', position: 0 },
|
|
163
|
+
labels: { nodes: [] },
|
|
164
|
+
comments: {
|
|
165
|
+
totalCount: 0,
|
|
166
|
+
nodes: [],
|
|
167
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return new Response(`Unexpected query: ${body.query}`, { status: 500 })
|
|
178
|
+
}) as unknown as typeof fetch
|
|
179
|
+
|
|
180
|
+
const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
|
|
181
|
+
const created = await provider.createTask({ title: 'Hello' })
|
|
182
|
+
|
|
183
|
+
expect(String(createIssueTeamId)).toBe('3ca24047-e954-44e8-b266-c7182410befb')
|
|
184
|
+
expect(created.externalRef).toBe('R2P-1')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('periodic full sync prunes cached issues missing from upstream', async () => {
|
|
188
|
+
replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
|
|
189
|
+
upsertIssues(db, [
|
|
190
|
+
{
|
|
191
|
+
id: 'issue-1',
|
|
192
|
+
identifier: 'R2P-1',
|
|
193
|
+
title: 'Keep me',
|
|
194
|
+
stateId: 'state-1',
|
|
195
|
+
stateName: 'Todo',
|
|
196
|
+
statePosition: 0,
|
|
197
|
+
commentCount: 1,
|
|
198
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
199
|
+
updatedAt: '2026-01-01T00:00:00Z',
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'issue-stale',
|
|
203
|
+
identifier: 'R2P-9',
|
|
204
|
+
title: 'Delete me',
|
|
205
|
+
stateId: 'state-1',
|
|
206
|
+
stateName: 'Todo',
|
|
207
|
+
statePosition: 0,
|
|
208
|
+
commentCount: 3,
|
|
209
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
210
|
+
updatedAt: '2026-01-01T00:00:00Z',
|
|
211
|
+
},
|
|
212
|
+
])
|
|
213
|
+
saveSyncMeta(db, {
|
|
214
|
+
team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
|
|
215
|
+
lastSyncAt: '2026-01-01T00:00:00Z',
|
|
216
|
+
lastFullSyncAt: '2026-01-01T00:00:00Z',
|
|
217
|
+
lastIssueUpdatedAt: '2026-01-01T00:00:00Z',
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
221
|
+
const body = JSON.parse(String(init?.body)) as {
|
|
222
|
+
query: string
|
|
223
|
+
variables: Record<string, unknown>
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (body.query.includes('query TeamSnapshot')) {
|
|
227
|
+
return new Response(
|
|
228
|
+
JSON.stringify({
|
|
229
|
+
data: {
|
|
230
|
+
team: {
|
|
231
|
+
id: 'team-1',
|
|
232
|
+
key: 'R2P',
|
|
233
|
+
name: 'R2pi',
|
|
234
|
+
states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (body.query.includes('query Users')) {
|
|
243
|
+
return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
|
|
244
|
+
status: 200,
|
|
245
|
+
headers: { 'content-type': 'application/json' },
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (body.query.includes('query Projects')) {
|
|
250
|
+
return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
|
|
251
|
+
status: 200,
|
|
252
|
+
headers: { 'content-type': 'application/json' },
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (body.query.includes('query Issues')) {
|
|
257
|
+
return new Response(
|
|
258
|
+
JSON.stringify({
|
|
259
|
+
data: {
|
|
260
|
+
issues: {
|
|
261
|
+
nodes: [
|
|
262
|
+
linearIssue({ comments: { totalCount: 2, nodes: [{ id: 'c1' }, { id: 'c2' }] } }),
|
|
263
|
+
],
|
|
264
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
}),
|
|
268
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (body.query.includes('query IssueHistory')) {
|
|
273
|
+
return new Response(
|
|
274
|
+
JSON.stringify({
|
|
275
|
+
data: {
|
|
276
|
+
issue: {
|
|
277
|
+
history: {
|
|
278
|
+
nodes: [],
|
|
279
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
}),
|
|
284
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return new Response(`Unexpected query: ${body.query}`, { status: 500 })
|
|
289
|
+
}) as unknown as typeof fetch
|
|
290
|
+
|
|
291
|
+
const originalDateNow = Date.now
|
|
292
|
+
Date.now = () => Date.parse('2026-01-01T00:06:00Z')
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
|
|
296
|
+
await provider.getBoard()
|
|
297
|
+
} finally {
|
|
298
|
+
Date.now = originalDateNow
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const tasks = getCachedTasks(db)
|
|
302
|
+
expect(tasks.map((task) => task.externalRef)).toEqual(['R2P-1'])
|
|
303
|
+
expect(tasks[0]?.comment_count).toBe(2)
|
|
304
|
+
expect(loadSyncMeta(db).lastFullSyncAt).not.toBeNull()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('polling keeps upstream comment counts instead of resetting them to zero', async () => {
|
|
308
|
+
upsertIssues(db, [
|
|
309
|
+
{
|
|
310
|
+
id: 'issue-1',
|
|
311
|
+
identifier: 'R2P-1',
|
|
312
|
+
title: 'Linear task',
|
|
313
|
+
stateId: 'state-1',
|
|
314
|
+
stateName: 'Todo',
|
|
315
|
+
statePosition: 0,
|
|
316
|
+
commentCount: 4,
|
|
317
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
318
|
+
updatedAt: '2026-01-01T00:00:00Z',
|
|
319
|
+
},
|
|
320
|
+
])
|
|
321
|
+
saveSyncMeta(db, {
|
|
322
|
+
team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
|
|
323
|
+
lastSyncAt: '2026-01-01T00:00:00Z',
|
|
324
|
+
lastFullSyncAt: '2026-01-01T00:00:00Z',
|
|
325
|
+
lastIssueUpdatedAt: '2026-01-01T00:00:00Z',
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
329
|
+
const body = JSON.parse(String(init?.body)) as {
|
|
330
|
+
query: string
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (body.query.includes('query TeamSnapshot')) {
|
|
334
|
+
return new Response(
|
|
335
|
+
JSON.stringify({
|
|
336
|
+
data: {
|
|
337
|
+
team: {
|
|
338
|
+
id: 'team-1',
|
|
339
|
+
key: 'R2P',
|
|
340
|
+
name: 'R2pi',
|
|
341
|
+
states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
}),
|
|
345
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (body.query.includes('query Users')) {
|
|
350
|
+
return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
|
|
351
|
+
status: 200,
|
|
352
|
+
headers: { 'content-type': 'application/json' },
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (body.query.includes('query Projects')) {
|
|
357
|
+
return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
|
|
358
|
+
status: 200,
|
|
359
|
+
headers: { 'content-type': 'application/json' },
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (body.query.includes('query Issues')) {
|
|
364
|
+
return new Response(
|
|
365
|
+
JSON.stringify({
|
|
366
|
+
data: {
|
|
367
|
+
issues: {
|
|
368
|
+
nodes: [
|
|
369
|
+
linearIssue({
|
|
370
|
+
comments: {
|
|
371
|
+
totalCount: 7,
|
|
372
|
+
nodes: Array.from({ length: 7 }, (_, index) => ({ id: `c${index}` })),
|
|
373
|
+
},
|
|
374
|
+
}),
|
|
375
|
+
],
|
|
376
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
}),
|
|
380
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (body.query.includes('query IssueHistory')) {
|
|
385
|
+
return new Response(
|
|
386
|
+
JSON.stringify({
|
|
387
|
+
data: {
|
|
388
|
+
issue: {
|
|
389
|
+
history: {
|
|
390
|
+
nodes: [],
|
|
391
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
}),
|
|
396
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return new Response(`Unexpected query: ${body.query}`, { status: 500 })
|
|
401
|
+
}) as unknown as typeof fetch
|
|
402
|
+
|
|
403
|
+
const originalDateNow = Date.now
|
|
404
|
+
Date.now = () => Date.parse('2026-01-01T00:06:00Z')
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
|
|
408
|
+
const task = await provider.getTask('R2P-1')
|
|
409
|
+
expect(task.comment_count).toBe(7)
|
|
410
|
+
} finally {
|
|
411
|
+
Date.now = originalDateNow
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test('recent webhook traffic does not stretch polling beyond the normal interval', async () => {
|
|
416
|
+
let issueQueries = 0
|
|
417
|
+
|
|
418
|
+
saveSyncMeta(db, {
|
|
419
|
+
team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
|
|
420
|
+
lastSyncAt: '2026-01-01T00:00:00.000Z',
|
|
421
|
+
lastFullSyncAt: '2026-01-01T00:00:00.000Z',
|
|
422
|
+
lastIssueUpdatedAt: '2026-01-01T00:00:00.000Z',
|
|
423
|
+
lastWebhookAt: '2026-01-01T00:00:30.000Z',
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
427
|
+
const body = JSON.parse(String(init?.body)) as {
|
|
428
|
+
query: string
|
|
429
|
+
variables: Record<string, unknown>
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (body.query.includes('query TeamSnapshot')) {
|
|
433
|
+
return new Response(
|
|
434
|
+
JSON.stringify({
|
|
435
|
+
data: {
|
|
436
|
+
team: {
|
|
437
|
+
id: 'team-1',
|
|
438
|
+
key: 'R2P',
|
|
439
|
+
name: 'R2pi',
|
|
440
|
+
states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
}),
|
|
444
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (body.query.includes('query Users')) {
|
|
449
|
+
return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
|
|
450
|
+
status: 200,
|
|
451
|
+
headers: { 'content-type': 'application/json' },
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (body.query.includes('query Projects')) {
|
|
456
|
+
return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
|
|
457
|
+
status: 200,
|
|
458
|
+
headers: { 'content-type': 'application/json' },
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (body.query.includes('query Issues')) {
|
|
463
|
+
issueQueries += 1
|
|
464
|
+
expect(body.variables.updatedAfter).toBe('2026-01-01T00:00:00.000Z')
|
|
465
|
+
return new Response(
|
|
466
|
+
JSON.stringify({
|
|
467
|
+
data: {
|
|
468
|
+
issues: {
|
|
469
|
+
nodes: [],
|
|
470
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
}),
|
|
474
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return new Response(`Unexpected query: ${body.query}`, { status: 500 })
|
|
479
|
+
}) as unknown as typeof fetch
|
|
480
|
+
|
|
481
|
+
const originalDateNow = Date.now
|
|
482
|
+
Date.now = () => Date.parse('2026-01-01T00:00:31.000Z')
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
|
|
486
|
+
await provider.getBoard()
|
|
487
|
+
} finally {
|
|
488
|
+
Date.now = originalDateNow
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
expect(issueQueries).toBe(1)
|
|
492
|
+
})
|
|
493
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Database } from 'bun:sqlite'
|
|
3
|
+
import { initSchema, seedDefaultColumns, addTask } from '../db.ts'
|
|
4
|
+
import { LocalProvider } from '../providers/local.ts'
|
|
5
|
+
|
|
6
|
+
let db: Database
|
|
7
|
+
let provider: LocalProvider
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
db = new Database(':memory:')
|
|
11
|
+
db.run('PRAGMA foreign_keys = ON')
|
|
12
|
+
initSchema(db)
|
|
13
|
+
seedDefaultColumns(db)
|
|
14
|
+
provider = new LocalProvider(db, ':memory:')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('LocalProvider.comment', () => {
|
|
18
|
+
test('creates a stored comment, updates task counts, and advertises comment capability', async () => {
|
|
19
|
+
const task = addTask(db, 'Comment me')
|
|
20
|
+
|
|
21
|
+
const comment = await provider.comment(task.id, 'hello from local')
|
|
22
|
+
|
|
23
|
+
expect(comment.task_id).toBe(task.id)
|
|
24
|
+
expect(comment.body).toBe('hello from local')
|
|
25
|
+
expect((await provider.getTask(task.id)).comment_count).toBe(1)
|
|
26
|
+
const activity = await provider.getActivity(10, task.id)
|
|
27
|
+
expect(activity[0]?.action).toBe('updated')
|
|
28
|
+
expect(activity[0]?.field_changed).toBe('comment')
|
|
29
|
+
expect(activity[0]?.new_value).toBe('hello from local')
|
|
30
|
+
|
|
31
|
+
const context = await provider.getContext()
|
|
32
|
+
expect(context.capabilities.comment).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('lists stored comments in creation order', async () => {
|
|
36
|
+
const task = addTask(db, 'Comment me')
|
|
37
|
+
|
|
38
|
+
const first = await provider.comment(task.id, 'first comment')
|
|
39
|
+
const second = await provider.comment(task.id, 'second comment')
|
|
40
|
+
const comments = await provider.listComments(task.id)
|
|
41
|
+
|
|
42
|
+
expect(comments.map((comment) => comment.id)).toEqual([first.id, second.id])
|
|
43
|
+
expect(comments.map((comment) => comment.body)).toEqual(['first comment', 'second comment'])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('updates a stored comment body', async () => {
|
|
47
|
+
const task = addTask(db, 'Comment me')
|
|
48
|
+
const comment = await provider.comment(task.id, 'hello from local')
|
|
49
|
+
|
|
50
|
+
const updated = await provider.updateComment(task.id, comment.id, 'edited local comment')
|
|
51
|
+
|
|
52
|
+
expect(updated.id).toBe(comment.id)
|
|
53
|
+
expect(updated.body).toBe('edited local comment')
|
|
54
|
+
const activity = await provider.getActivity(10, task.id)
|
|
55
|
+
expect(activity[0]?.action).toBe('updated')
|
|
56
|
+
expect(activity[0]?.field_changed).toBe('comment')
|
|
57
|
+
expect(activity[0]?.old_value).toBe('hello from local')
|
|
58
|
+
expect(activity[0]?.new_value).toBe('edited local comment')
|
|
59
|
+
})
|
|
60
|
+
})
|