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