@andypai/agent-kanban 0.6.2 → 0.6.3
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/package.json +1 -1
- package/src/__tests__/api.test.ts +48 -0
- package/src/__tests__/conflict.test.ts +25 -1
- package/src/__tests__/linear-provider-sync.test.ts +517 -40
- package/src/__tests__/mcp-core.test.ts +54 -0
- package/src/__tests__/mcp-server.test.ts +17 -0
- package/src/__tests__/metrics-spec.test.ts +121 -0
- package/src/__tests__/postgres-linear-provider.test.ts +10 -2
- package/src/__tests__/postgres-local-provider.test.ts +140 -0
- package/src/__tests__/tracker-config-columns.test.ts +23 -0
- package/src/__tests__/transport-input.test.ts +35 -0
- package/src/api.ts +35 -31
- package/src/commands/mcp.ts +2 -1
- package/src/db.ts +1 -1
- package/src/errors.ts +2 -0
- package/src/index.ts +6 -1
- package/src/mcp/core.ts +24 -2
- package/src/mcp/server.ts +2 -1
- package/src/mcp/types.ts +12 -1
- package/src/metrics-spec.ts +87 -0
- package/src/metrics.ts +29 -60
- package/src/providers/capabilities.ts +12 -0
- package/src/providers/linear-client.ts +172 -95
- package/src/providers/linear-core.ts +89 -59
- package/src/providers/postgres-local.ts +136 -89
- package/src/tracker-config.ts +17 -1
- package/src/transport-input.ts +30 -0
- package/src/version.ts +8 -0
- package/ui/dist/assets/index-Cigv8a9S.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-65LcV0R7.js +0 -40
|
@@ -41,6 +41,7 @@ function linearIssue(
|
|
|
41
41
|
nodes: Array<{ id: string }>
|
|
42
42
|
pageInfo?: { hasNextPage: boolean; endCursor: string | null }
|
|
43
43
|
}
|
|
44
|
+
team: { id: string } | null
|
|
44
45
|
}> = {},
|
|
45
46
|
) {
|
|
46
47
|
return {
|
|
@@ -88,17 +89,27 @@ describe('LinearProvider sync', () => {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
if (body.query.includes('query Users')) {
|
|
91
|
-
return new Response(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
return new Response(
|
|
93
|
+
JSON.stringify({
|
|
94
|
+
data: { users: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
95
|
+
}),
|
|
96
|
+
{
|
|
97
|
+
status: 200,
|
|
98
|
+
headers: { 'content-type': 'application/json' },
|
|
99
|
+
},
|
|
100
|
+
)
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
if (body.query.includes('query Projects')) {
|
|
98
|
-
return new Response(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
return new Response(
|
|
105
|
+
JSON.stringify({
|
|
106
|
+
data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
107
|
+
}),
|
|
108
|
+
{
|
|
109
|
+
status: 200,
|
|
110
|
+
headers: { 'content-type': 'application/json' },
|
|
111
|
+
},
|
|
112
|
+
)
|
|
102
113
|
}
|
|
103
114
|
|
|
104
115
|
if (body.query.includes('query Issues')) {
|
|
@@ -209,6 +220,324 @@ describe('LinearProvider sync', () => {
|
|
|
209
220
|
expect(created.labels).toEqual(['garage-smoke', 'garage-owner-local'])
|
|
210
221
|
})
|
|
211
222
|
|
|
223
|
+
test('paginates users and projects across multiple pages', async () => {
|
|
224
|
+
let userPages = 0
|
|
225
|
+
let projectPages = 0
|
|
226
|
+
const seenUserAfter: Array<string | null> = []
|
|
227
|
+
const seenProjectAfter: Array<string | null> = []
|
|
228
|
+
|
|
229
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
230
|
+
const body = JSON.parse(String(init?.body)) as {
|
|
231
|
+
query: string
|
|
232
|
+
variables: Record<string, unknown>
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (body.query.includes('query TeamSnapshot')) {
|
|
236
|
+
return new Response(
|
|
237
|
+
JSON.stringify({
|
|
238
|
+
data: {
|
|
239
|
+
team: {
|
|
240
|
+
id: 'team-1',
|
|
241
|
+
key: 'R2P',
|
|
242
|
+
name: 'R2pi',
|
|
243
|
+
states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (body.query.includes('query Users')) {
|
|
252
|
+
userPages += 1
|
|
253
|
+
const after = (body.variables.after as string | null) ?? null
|
|
254
|
+
seenUserAfter.push(after)
|
|
255
|
+
const page = after
|
|
256
|
+
? {
|
|
257
|
+
nodes: [{ id: 'u2', displayName: 'User Two', active: true }],
|
|
258
|
+
hasNextPage: false,
|
|
259
|
+
endCursor: null,
|
|
260
|
+
}
|
|
261
|
+
: {
|
|
262
|
+
nodes: [{ id: 'u1', displayName: 'User One', active: true }],
|
|
263
|
+
hasNextPage: true,
|
|
264
|
+
endCursor: 'cursor-u1',
|
|
265
|
+
}
|
|
266
|
+
return new Response(
|
|
267
|
+
JSON.stringify({
|
|
268
|
+
data: {
|
|
269
|
+
users: {
|
|
270
|
+
nodes: page.nodes,
|
|
271
|
+
pageInfo: { hasNextPage: page.hasNextPage, endCursor: page.endCursor },
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
}),
|
|
275
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (body.query.includes('query Projects')) {
|
|
280
|
+
projectPages += 1
|
|
281
|
+
const after = (body.variables.after as string | null) ?? null
|
|
282
|
+
seenProjectAfter.push(after)
|
|
283
|
+
const page = after
|
|
284
|
+
? { nodes: [{ id: 'p2', name: 'Project Two' }], hasNextPage: false, endCursor: null }
|
|
285
|
+
: {
|
|
286
|
+
nodes: [{ id: 'p1', name: 'Project One' }],
|
|
287
|
+
hasNextPage: true,
|
|
288
|
+
endCursor: 'cursor-p1',
|
|
289
|
+
}
|
|
290
|
+
return new Response(
|
|
291
|
+
JSON.stringify({
|
|
292
|
+
data: {
|
|
293
|
+
projects: {
|
|
294
|
+
nodes: page.nodes,
|
|
295
|
+
pageInfo: { hasNextPage: page.hasNextPage, endCursor: page.endCursor },
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
}),
|
|
299
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (body.query.includes('query Issues')) {
|
|
304
|
+
return new Response(
|
|
305
|
+
JSON.stringify({
|
|
306
|
+
data: { issues: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
307
|
+
}),
|
|
308
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return new Response(`Unexpected query: ${body.query}`, { status: 500 })
|
|
313
|
+
}) as unknown as typeof fetch
|
|
314
|
+
|
|
315
|
+
const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
|
|
316
|
+
await provider.getBoard()
|
|
317
|
+
|
|
318
|
+
expect(userPages).toBe(2)
|
|
319
|
+
expect(seenUserAfter).toEqual([null, 'cursor-u1'])
|
|
320
|
+
expect(projectPages).toBe(2)
|
|
321
|
+
expect(seenProjectAfter).toEqual([null, 'cursor-p1'])
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('createTask rejects an unknown assignee instead of silently dropping it', async () => {
|
|
325
|
+
replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
|
|
326
|
+
saveSyncMeta(db, {
|
|
327
|
+
team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
|
|
328
|
+
lastSyncAt: new Date().toISOString(),
|
|
329
|
+
lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
333
|
+
const body = JSON.parse(String(init?.body)) as { query: string }
|
|
334
|
+
// The assignee resolver should throw before any CreateIssue mutation runs.
|
|
335
|
+
return new Response(`Unexpected query: ${body.query}`, { status: 500 })
|
|
336
|
+
}) as unknown as typeof fetch
|
|
337
|
+
|
|
338
|
+
const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
|
|
339
|
+
await expect(
|
|
340
|
+
provider.createTask({ title: 'Hello', assignee: 'Ghost User' }),
|
|
341
|
+
).rejects.toMatchObject({ code: 'PROVIDER_UPSTREAM_ERROR' })
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('updateTask rejects an unknown assignee instead of clearing the field', async () => {
|
|
345
|
+
replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
|
|
346
|
+
upsertIssues(db, [
|
|
347
|
+
{
|
|
348
|
+
id: 'issue-1',
|
|
349
|
+
identifier: 'R2P-1',
|
|
350
|
+
title: 'Linear task',
|
|
351
|
+
assigneeId: 'user-1',
|
|
352
|
+
assigneeName: 'Real Person',
|
|
353
|
+
stateId: 'state-1',
|
|
354
|
+
stateName: 'Todo',
|
|
355
|
+
statePosition: 0,
|
|
356
|
+
commentCount: 0,
|
|
357
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
358
|
+
updatedAt: '2026-01-01T00:00:00Z',
|
|
359
|
+
},
|
|
360
|
+
])
|
|
361
|
+
saveSyncMeta(db, {
|
|
362
|
+
team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
|
|
363
|
+
lastSyncAt: new Date().toISOString(),
|
|
364
|
+
lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
368
|
+
const body = JSON.parse(String(init?.body)) as { query: string }
|
|
369
|
+
// The resolver should throw before any UpdateIssue mutation runs.
|
|
370
|
+
return new Response(`Unexpected query: ${body.query}`, { status: 500 })
|
|
371
|
+
}) as unknown as typeof fetch
|
|
372
|
+
|
|
373
|
+
const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
|
|
374
|
+
await expect(provider.updateTask('R2P-1', { assignee: 'Ghost User' })).rejects.toMatchObject({
|
|
375
|
+
code: 'PROVIDER_UPSTREAM_ERROR',
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
test('updateTask clears the assignee when given an empty string', async () => {
|
|
380
|
+
replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
|
|
381
|
+
upsertIssues(db, [
|
|
382
|
+
{
|
|
383
|
+
id: 'issue-1',
|
|
384
|
+
identifier: 'R2P-1',
|
|
385
|
+
title: 'Linear task',
|
|
386
|
+
assigneeId: 'user-1',
|
|
387
|
+
assigneeName: 'Real Person',
|
|
388
|
+
stateId: 'state-1',
|
|
389
|
+
stateName: 'Todo',
|
|
390
|
+
statePosition: 0,
|
|
391
|
+
commentCount: 0,
|
|
392
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
393
|
+
updatedAt: '2026-01-01T00:00:00Z',
|
|
394
|
+
},
|
|
395
|
+
])
|
|
396
|
+
saveSyncMeta(db, {
|
|
397
|
+
team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
|
|
398
|
+
lastSyncAt: new Date().toISOString(),
|
|
399
|
+
lastFullSyncAt: new Date().toISOString(),
|
|
400
|
+
lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
const updateInputs: Array<Record<string, unknown>> = []
|
|
404
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
405
|
+
const body = JSON.parse(String(init?.body)) as {
|
|
406
|
+
query: string
|
|
407
|
+
variables: { input?: Record<string, unknown> }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (body.query.includes('mutation UpdateIssue')) {
|
|
411
|
+
updateInputs.push(body.variables.input ?? {})
|
|
412
|
+
return new Response(JSON.stringify({ data: { issueUpdate: { success: true } } }), {
|
|
413
|
+
status: 200,
|
|
414
|
+
headers: { 'content-type': 'application/json' },
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (body.query.includes('query TeamSnapshot')) {
|
|
419
|
+
return new Response(
|
|
420
|
+
JSON.stringify({
|
|
421
|
+
data: {
|
|
422
|
+
team: {
|
|
423
|
+
id: 'team-1',
|
|
424
|
+
key: 'R2P',
|
|
425
|
+
name: 'R2pi',
|
|
426
|
+
states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
}),
|
|
430
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
if (body.query.includes('query Users')) {
|
|
434
|
+
return new Response(
|
|
435
|
+
JSON.stringify({
|
|
436
|
+
data: { users: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
437
|
+
}),
|
|
438
|
+
{
|
|
439
|
+
status: 200,
|
|
440
|
+
headers: { 'content-type': 'application/json' },
|
|
441
|
+
},
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
if (body.query.includes('query Projects')) {
|
|
445
|
+
return new Response(
|
|
446
|
+
JSON.stringify({
|
|
447
|
+
data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
448
|
+
}),
|
|
449
|
+
{
|
|
450
|
+
status: 200,
|
|
451
|
+
headers: { 'content-type': 'application/json' },
|
|
452
|
+
},
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
if (body.query.includes('query Issues')) {
|
|
456
|
+
return new Response(
|
|
457
|
+
JSON.stringify({
|
|
458
|
+
data: {
|
|
459
|
+
issues: {
|
|
460
|
+
nodes: [linearIssue()],
|
|
461
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
}),
|
|
465
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
if (body.query.includes('query IssueById')) {
|
|
469
|
+
return new Response(JSON.stringify({ data: { issue: linearIssue({ assignee: null }) } }), {
|
|
470
|
+
status: 200,
|
|
471
|
+
headers: { 'content-type': 'application/json' },
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
if (body.query.includes('query IssueHistory')) {
|
|
475
|
+
return new Response(
|
|
476
|
+
JSON.stringify({
|
|
477
|
+
data: {
|
|
478
|
+
issue: { history: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
479
|
+
},
|
|
480
|
+
}),
|
|
481
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
return new Response(`Unexpected query: ${body.query}`, { status: 500 })
|
|
485
|
+
}) as unknown as typeof fetch
|
|
486
|
+
|
|
487
|
+
const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
|
|
488
|
+
await provider.updateTask('R2P-1', { assignee: '' })
|
|
489
|
+
|
|
490
|
+
expect(updateInputs).toHaveLength(1)
|
|
491
|
+
expect(updateInputs[0]).toHaveProperty('assigneeId', null)
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
test('updateTask drops the cached row when the hydrated issue left the team', async () => {
|
|
495
|
+
replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
|
|
496
|
+
upsertIssues(db, [
|
|
497
|
+
{
|
|
498
|
+
id: 'issue-1',
|
|
499
|
+
identifier: 'R2P-1',
|
|
500
|
+
title: 'Linear task',
|
|
501
|
+
stateId: 'state-1',
|
|
502
|
+
stateName: 'Todo',
|
|
503
|
+
statePosition: 0,
|
|
504
|
+
commentCount: 0,
|
|
505
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
506
|
+
updatedAt: '2026-01-01T00:00:00Z',
|
|
507
|
+
},
|
|
508
|
+
])
|
|
509
|
+
saveSyncMeta(db, {
|
|
510
|
+
team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
|
|
511
|
+
lastSyncAt: new Date().toISOString(),
|
|
512
|
+
lastFullSyncAt: new Date().toISOString(),
|
|
513
|
+
lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
517
|
+
const body = JSON.parse(String(init?.body)) as { query: string }
|
|
518
|
+
if (body.query.includes('mutation UpdateIssue')) {
|
|
519
|
+
return new Response(JSON.stringify({ data: { issueUpdate: { success: true } } }), {
|
|
520
|
+
status: 200,
|
|
521
|
+
headers: { 'content-type': 'application/json' },
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
if (body.query.includes('query IssueById')) {
|
|
525
|
+
// Upstream reports the issue now belongs to a different team.
|
|
526
|
+
return new Response(
|
|
527
|
+
JSON.stringify({ data: { issue: linearIssue({ team: { id: 'team-2' } }) } }),
|
|
528
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
return new Response(`Unexpected query: ${body.query}`, { status: 500 })
|
|
532
|
+
}) as unknown as typeof fetch
|
|
533
|
+
|
|
534
|
+
const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
|
|
535
|
+
await expect(provider.updateTask('R2P-1', { title: 'Renamed' })).rejects.toMatchObject({
|
|
536
|
+
code: 'TASK_NOT_FOUND',
|
|
537
|
+
})
|
|
538
|
+
expect(getCachedTasks(db)).toHaveLength(0)
|
|
539
|
+
})
|
|
540
|
+
|
|
212
541
|
test('periodic full sync prunes cached issues missing from upstream', async () => {
|
|
213
542
|
replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
|
|
214
543
|
upsertIssues(db, [
|
|
@@ -265,17 +594,27 @@ describe('LinearProvider sync', () => {
|
|
|
265
594
|
}
|
|
266
595
|
|
|
267
596
|
if (body.query.includes('query Users')) {
|
|
268
|
-
return new Response(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
597
|
+
return new Response(
|
|
598
|
+
JSON.stringify({
|
|
599
|
+
data: { users: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
600
|
+
}),
|
|
601
|
+
{
|
|
602
|
+
status: 200,
|
|
603
|
+
headers: { 'content-type': 'application/json' },
|
|
604
|
+
},
|
|
605
|
+
)
|
|
272
606
|
}
|
|
273
607
|
|
|
274
608
|
if (body.query.includes('query Projects')) {
|
|
275
|
-
return new Response(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
609
|
+
return new Response(
|
|
610
|
+
JSON.stringify({
|
|
611
|
+
data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
612
|
+
}),
|
|
613
|
+
{
|
|
614
|
+
status: 200,
|
|
615
|
+
headers: { 'content-type': 'application/json' },
|
|
616
|
+
},
|
|
617
|
+
)
|
|
279
618
|
}
|
|
280
619
|
|
|
281
620
|
if (body.query.includes('query Issues')) {
|
|
@@ -327,6 +666,114 @@ describe('LinearProvider sync', () => {
|
|
|
327
666
|
expect(loadSyncMeta(db).lastFullSyncAt).not.toBeNull()
|
|
328
667
|
})
|
|
329
668
|
|
|
669
|
+
test('counts comments beyond the inline first page instead of capping at the page length', async () => {
|
|
670
|
+
saveSyncMeta(db, {
|
|
671
|
+
team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
|
|
672
|
+
lastSyncAt: '2026-01-01T00:00:00Z',
|
|
673
|
+
lastFullSyncAt: '2026-01-01T00:00:00Z',
|
|
674
|
+
lastIssueUpdatedAt: '2026-01-01T00:00:00Z',
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
let commentCountQueries = 0
|
|
678
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
679
|
+
const body = JSON.parse(String(init?.body)) as {
|
|
680
|
+
query: string
|
|
681
|
+
variables: Record<string, unknown>
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (body.query.includes('query TeamSnapshot')) {
|
|
685
|
+
return new Response(
|
|
686
|
+
JSON.stringify({
|
|
687
|
+
data: {
|
|
688
|
+
team: {
|
|
689
|
+
id: 'team-1',
|
|
690
|
+
key: 'R2P',
|
|
691
|
+
name: 'R2pi',
|
|
692
|
+
states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
}),
|
|
696
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
697
|
+
)
|
|
698
|
+
}
|
|
699
|
+
if (body.query.includes('query Users')) {
|
|
700
|
+
return new Response(
|
|
701
|
+
JSON.stringify({
|
|
702
|
+
data: { users: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
703
|
+
}),
|
|
704
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
705
|
+
)
|
|
706
|
+
}
|
|
707
|
+
if (body.query.includes('query Projects')) {
|
|
708
|
+
return new Response(
|
|
709
|
+
JSON.stringify({
|
|
710
|
+
data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
711
|
+
}),
|
|
712
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
713
|
+
)
|
|
714
|
+
}
|
|
715
|
+
if (body.query.includes('query IssueCommentCount')) {
|
|
716
|
+
commentCountQueries += 1
|
|
717
|
+
// Second comment page: three more comments, no further pages.
|
|
718
|
+
return new Response(
|
|
719
|
+
JSON.stringify({
|
|
720
|
+
data: {
|
|
721
|
+
issue: {
|
|
722
|
+
comments: {
|
|
723
|
+
nodes: [{ id: 'c3' }, { id: 'c4' }, { id: 'c5' }],
|
|
724
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
}),
|
|
729
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
730
|
+
)
|
|
731
|
+
}
|
|
732
|
+
if (body.query.includes('query Issues')) {
|
|
733
|
+
return new Response(
|
|
734
|
+
JSON.stringify({
|
|
735
|
+
data: {
|
|
736
|
+
issues: {
|
|
737
|
+
nodes: [
|
|
738
|
+
linearIssue({
|
|
739
|
+
comments: {
|
|
740
|
+
nodes: [{ id: 'c1' }, { id: 'c2' }],
|
|
741
|
+
pageInfo: { hasNextPage: true, endCursor: 'comment-cursor-1' },
|
|
742
|
+
},
|
|
743
|
+
}),
|
|
744
|
+
],
|
|
745
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
}),
|
|
749
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
750
|
+
)
|
|
751
|
+
}
|
|
752
|
+
if (body.query.includes('query IssueHistory')) {
|
|
753
|
+
return new Response(
|
|
754
|
+
JSON.stringify({
|
|
755
|
+
data: {
|
|
756
|
+
issue: { history: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
757
|
+
},
|
|
758
|
+
}),
|
|
759
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
return new Response(`Unexpected query: ${body.query}`, { status: 500 })
|
|
763
|
+
}) as unknown as typeof fetch
|
|
764
|
+
|
|
765
|
+
const originalDateNow = Date.now
|
|
766
|
+
Date.now = () => Date.parse('2026-01-01T00:06:00Z')
|
|
767
|
+
try {
|
|
768
|
+
const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
|
|
769
|
+
const task = await provider.getTask('R2P-1')
|
|
770
|
+
expect(task.comment_count).toBe(5)
|
|
771
|
+
expect(commentCountQueries).toBe(1)
|
|
772
|
+
} finally {
|
|
773
|
+
Date.now = originalDateNow
|
|
774
|
+
}
|
|
775
|
+
})
|
|
776
|
+
|
|
330
777
|
test('polling keeps upstream comment counts instead of resetting them to zero', async () => {
|
|
331
778
|
upsertIssues(db, [
|
|
332
779
|
{
|
|
@@ -370,17 +817,27 @@ describe('LinearProvider sync', () => {
|
|
|
370
817
|
}
|
|
371
818
|
|
|
372
819
|
if (body.query.includes('query Users')) {
|
|
373
|
-
return new Response(
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
820
|
+
return new Response(
|
|
821
|
+
JSON.stringify({
|
|
822
|
+
data: { users: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
823
|
+
}),
|
|
824
|
+
{
|
|
825
|
+
status: 200,
|
|
826
|
+
headers: { 'content-type': 'application/json' },
|
|
827
|
+
},
|
|
828
|
+
)
|
|
377
829
|
}
|
|
378
830
|
|
|
379
831
|
if (body.query.includes('query Projects')) {
|
|
380
|
-
return new Response(
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
832
|
+
return new Response(
|
|
833
|
+
JSON.stringify({
|
|
834
|
+
data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
835
|
+
}),
|
|
836
|
+
{
|
|
837
|
+
status: 200,
|
|
838
|
+
headers: { 'content-type': 'application/json' },
|
|
839
|
+
},
|
|
840
|
+
)
|
|
384
841
|
}
|
|
385
842
|
|
|
386
843
|
if (body.query.includes('query Issues')) {
|
|
@@ -468,17 +925,27 @@ describe('LinearProvider sync', () => {
|
|
|
468
925
|
}
|
|
469
926
|
|
|
470
927
|
if (body.query.includes('query Users')) {
|
|
471
|
-
return new Response(
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
928
|
+
return new Response(
|
|
929
|
+
JSON.stringify({
|
|
930
|
+
data: { users: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
931
|
+
}),
|
|
932
|
+
{
|
|
933
|
+
status: 200,
|
|
934
|
+
headers: { 'content-type': 'application/json' },
|
|
935
|
+
},
|
|
936
|
+
)
|
|
475
937
|
}
|
|
476
938
|
|
|
477
939
|
if (body.query.includes('query Projects')) {
|
|
478
|
-
return new Response(
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
940
|
+
return new Response(
|
|
941
|
+
JSON.stringify({
|
|
942
|
+
data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
943
|
+
}),
|
|
944
|
+
{
|
|
945
|
+
status: 200,
|
|
946
|
+
headers: { 'content-type': 'application/json' },
|
|
947
|
+
},
|
|
948
|
+
)
|
|
482
949
|
}
|
|
483
950
|
|
|
484
951
|
if (body.query.includes('query Issues')) {
|
|
@@ -546,17 +1013,27 @@ describe('LinearProvider sync', () => {
|
|
|
546
1013
|
}
|
|
547
1014
|
|
|
548
1015
|
if (body.query.includes('query Users')) {
|
|
549
|
-
return new Response(
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
1016
|
+
return new Response(
|
|
1017
|
+
JSON.stringify({
|
|
1018
|
+
data: { users: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
1019
|
+
}),
|
|
1020
|
+
{
|
|
1021
|
+
status: 200,
|
|
1022
|
+
headers: { 'content-type': 'application/json' },
|
|
1023
|
+
},
|
|
1024
|
+
)
|
|
553
1025
|
}
|
|
554
1026
|
|
|
555
1027
|
if (body.query.includes('query Projects')) {
|
|
556
|
-
return new Response(
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
1028
|
+
return new Response(
|
|
1029
|
+
JSON.stringify({
|
|
1030
|
+
data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
1031
|
+
}),
|
|
1032
|
+
{
|
|
1033
|
+
status: 200,
|
|
1034
|
+
headers: { 'content-type': 'application/json' },
|
|
1035
|
+
},
|
|
1036
|
+
)
|
|
560
1037
|
}
|
|
561
1038
|
|
|
562
1039
|
if (body.query.includes('query Issues')) {
|
|
@@ -115,6 +115,60 @@ describe('createTrackerCore', () => {
|
|
|
115
115
|
expect(seenCommentBody === 'visible comment').toBe(true)
|
|
116
116
|
})
|
|
117
117
|
|
|
118
|
+
test('filters board tasks via policy and reports the visible task count', async () => {
|
|
119
|
+
const visible = addTask(db, 'visible task')
|
|
120
|
+
addTask(db, 'hidden task')
|
|
121
|
+
const hookResults: Array<{ tool: string; result?: Record<string, unknown> }> = []
|
|
122
|
+
|
|
123
|
+
const core = createTrackerCore<TestScope>({
|
|
124
|
+
provider,
|
|
125
|
+
policy: {
|
|
126
|
+
canReadTicket() {},
|
|
127
|
+
canPostComment() {},
|
|
128
|
+
canUpdateComment() {},
|
|
129
|
+
canMoveTicket() {},
|
|
130
|
+
filterTask(_scope, task) {
|
|
131
|
+
return task.title.startsWith('visible')
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
hooks: {
|
|
135
|
+
onToolResult(event) {
|
|
136
|
+
hookResults.push({ tool: event.tool, result: event.result })
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const board = await core.handlers.getBoard({ scope: { actor: 'agent' } })
|
|
142
|
+
const tasks = board.columns.flatMap((column) => column.tasks)
|
|
143
|
+
|
|
144
|
+
expect(tasks.map((task) => task.id)).toEqual([visible.id])
|
|
145
|
+
expect(hookResults).toEqual([{ tool: 'getBoard', result: { taskCount: 1 } }])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('denies board reads when canReadBoard throws', async () => {
|
|
149
|
+
addTask(db, 'Core task')
|
|
150
|
+
const core = createTrackerCore<TestScope>({
|
|
151
|
+
provider,
|
|
152
|
+
policy: {
|
|
153
|
+
canReadTicket() {},
|
|
154
|
+
canPostComment() {},
|
|
155
|
+
canUpdateComment() {},
|
|
156
|
+
canMoveTicket() {},
|
|
157
|
+
canReadBoard() {
|
|
158
|
+
throw new TrackerMcpError({
|
|
159
|
+
code: 'policy_denied',
|
|
160
|
+
publicMessage: 'forbidden_board',
|
|
161
|
+
})
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
await expect(core.handlers.getBoard({ scope: { actor: 'agent' } })).rejects.toMatchObject({
|
|
167
|
+
code: 'policy_denied',
|
|
168
|
+
publicMessage: 'forbidden_board',
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
118
172
|
test('normalizes policy denials into TrackerMcpError and reports them through hooks', async () => {
|
|
119
173
|
const task = addTask(db, 'Core task')
|
|
120
174
|
const toolErrors: Array<{ tool: string; code: string; message?: string }> = []
|