@taiga-ai/mcp-server 0.18.0-dev.450 → 0.19.0-dev.451
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/dist/index.js +1036 -628
- package/package.json +1 -1
- package/src/client.ts +11 -0
- package/src/modern.integration.test.ts +1 -1
- package/src/server.integration.test.ts +767 -232
- package/src/tools/autonomy.ts +2 -2
- package/src/tools/backlogs.ts +164 -51
- package/src/tools/builds.ts +134 -47
- package/src/tools/catalogue.test.ts +23 -17
- package/src/tools/context.ts +98 -30
- package/src/tools/documents.ts +4 -1
- package/src/tools/errors.ts +40 -2
- package/src/tools/hints.ts +34 -9
- package/src/tools/inbox.ts +151 -28
- package/src/tools/index.ts +15 -1
- package/src/tools/instructions.ts +32 -154
- package/src/tools/maintenance.ts +44 -18
- package/src/tools/product-documents.ts +1 -1
- package/src/tools/products.ts +7 -5
- package/src/tools/prompts.ts +7 -7
- package/src/tools/queue.ts +12 -12
- package/src/tools/readiness.ts +172 -0
- package/src/tools/repositories.ts +4 -1
- package/src/tools/resources.ts +6 -6
- package/src/tools/sections.ts +13 -1
- package/src/tools/setup.ts +30 -30
- package/src/tools/wait.ts +13 -7
- package/taiga-ai-mcp-server-0.19.0-dev.451.tgz +0 -0
- package/taiga-ai-mcp-server-0.18.0-dev.450.tgz +0 -0
|
@@ -4,6 +4,10 @@ import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
|
4
4
|
import { TaigaClient } from './client.js';
|
|
5
5
|
import { createServer } from './server.js';
|
|
6
6
|
import type { WaitOptions } from './tools/wait.js';
|
|
7
|
+
import { inProductWords } from './tools/errors.js';
|
|
8
|
+
|
|
9
|
+
/** What a tool returns for an API value: its keys in the product's words (PRO-2800). */
|
|
10
|
+
const asSeen = <T>(value: T): T => inProductWords(value) as T;
|
|
7
11
|
|
|
8
12
|
// ---------------------------------------------------------------------------
|
|
9
13
|
// Helpers -- thin wrappers, not abstraction layers
|
|
@@ -42,15 +46,15 @@ const ALL_TOOL_NAMES = [
|
|
|
42
46
|
'taiga_fix_finding',
|
|
43
47
|
'taiga_fix_policy_violation',
|
|
44
48
|
'taiga_generate_product_documents',
|
|
45
|
-
'taiga_get_backlog_item',
|
|
46
|
-
'taiga_get_build',
|
|
47
49
|
'taiga_get_delivery_metrics',
|
|
48
50
|
'taiga_get_deployments',
|
|
49
51
|
'taiga_get_document',
|
|
50
52
|
'taiga_get_factory_document',
|
|
51
53
|
'taiga_get_finding',
|
|
54
|
+
'taiga_get_initiative',
|
|
52
55
|
'taiga_get_job',
|
|
53
56
|
'taiga_get_knowledge',
|
|
57
|
+
'taiga_get_line',
|
|
54
58
|
'taiga_get_maintenance',
|
|
55
59
|
'taiga_get_monitoring',
|
|
56
60
|
'taiga_get_plan',
|
|
@@ -58,26 +62,26 @@ const ALL_TOOL_NAMES = [
|
|
|
58
62
|
'taiga_get_product',
|
|
59
63
|
'taiga_get_product_context',
|
|
60
64
|
'taiga_get_product_document',
|
|
61
|
-
'
|
|
65
|
+
'taiga_get_run',
|
|
62
66
|
'taiga_ignore_finding',
|
|
63
67
|
'taiga_link_repository',
|
|
64
68
|
'taiga_list_action_required',
|
|
65
|
-
'taiga_list_backlog_items',
|
|
66
|
-
'taiga_list_builds',
|
|
67
69
|
'taiga_list_documents',
|
|
68
70
|
'taiga_list_environments',
|
|
69
71
|
'taiga_list_factories',
|
|
70
72
|
'taiga_list_factory_documents',
|
|
71
73
|
'taiga_list_findings',
|
|
74
|
+
'taiga_list_initiatives',
|
|
72
75
|
'taiga_list_knowledge',
|
|
73
76
|
'taiga_list_linkable_repositories',
|
|
74
77
|
'taiga_list_product_documents',
|
|
75
78
|
'taiga_list_product_repositories',
|
|
76
79
|
'taiga_list_products',
|
|
77
80
|
'taiga_list_repository_branches',
|
|
81
|
+
'taiga_list_runs',
|
|
78
82
|
'taiga_list_setup_tasks',
|
|
79
83
|
'taiga_mark_product_document_reviewed',
|
|
80
|
-
'
|
|
84
|
+
'taiga_move_initiative',
|
|
81
85
|
'taiga_move_product',
|
|
82
86
|
'taiga_pause_line',
|
|
83
87
|
'taiga_plan_again',
|
|
@@ -85,7 +89,7 @@ const ALL_TOOL_NAMES = [
|
|
|
85
89
|
'taiga_publish_document',
|
|
86
90
|
'taiga_publish_factory_document',
|
|
87
91
|
'taiga_publish_product_document',
|
|
88
|
-
'
|
|
92
|
+
'taiga_queue_initiative',
|
|
89
93
|
'taiga_refresh_reality_documents',
|
|
90
94
|
'taiga_regenerate_product_document',
|
|
91
95
|
'taiga_reject_plan',
|
|
@@ -94,24 +98,24 @@ const ALL_TOOL_NAMES = [
|
|
|
94
98
|
'taiga_request_initiatives',
|
|
95
99
|
'taiga_resolve_action',
|
|
96
100
|
'taiga_resolve_product',
|
|
97
|
-
'
|
|
101
|
+
'taiga_resume_initiative',
|
|
98
102
|
'taiga_retry_knowledge',
|
|
99
103
|
'taiga_set_auto_build',
|
|
100
104
|
'taiga_set_auto_merge',
|
|
101
105
|
'taiga_set_product_autonomy',
|
|
102
106
|
'taiga_set_repository_branch',
|
|
103
107
|
'taiga_start_import',
|
|
104
|
-
'taiga_stop_build',
|
|
105
108
|
'taiga_stop_generating_documents',
|
|
109
|
+
'taiga_stop_run',
|
|
106
110
|
'taiga_unlink_repository',
|
|
107
111
|
'taiga_update_document',
|
|
108
112
|
'taiga_update_environment',
|
|
109
113
|
'taiga_update_factory_document',
|
|
110
114
|
'taiga_update_product',
|
|
111
115
|
'taiga_update_product_document',
|
|
112
|
-
'
|
|
113
|
-
'taiga_wait_for_build',
|
|
116
|
+
'taiga_wait_for_initiative',
|
|
114
117
|
'taiga_wait_for_job',
|
|
118
|
+
'taiga_wait_for_run',
|
|
115
119
|
];
|
|
116
120
|
|
|
117
121
|
const PRODUCT_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5c';
|
|
@@ -434,10 +438,10 @@ describe('MCP server integration', () => {
|
|
|
434
438
|
'taiga_resolve_product',
|
|
435
439
|
'taiga_list_product_documents',
|
|
436
440
|
'taiga_get_product_document',
|
|
437
|
-
'
|
|
438
|
-
'
|
|
439
|
-
'
|
|
440
|
-
'
|
|
441
|
+
'taiga_list_runs',
|
|
442
|
+
'taiga_get_run',
|
|
443
|
+
'taiga_get_line',
|
|
444
|
+
'taiga_wait_for_run',
|
|
441
445
|
'taiga_get_job',
|
|
442
446
|
'taiga_wait_for_job',
|
|
443
447
|
'taiga_get_maintenance',
|
|
@@ -461,12 +465,7 @@ describe('MCP server integration', () => {
|
|
|
461
465
|
'taiga_move_product',
|
|
462
466
|
],
|
|
463
467
|
'vcs:manage': ['taiga_link_repository', 'taiga_set_repository_branch', 'taiga_unlink_repository'],
|
|
464
|
-
'backlog:read': [
|
|
465
|
-
'taiga_list_backlog_items',
|
|
466
|
-
'taiga_get_backlog_item',
|
|
467
|
-
'taiga_get_plan',
|
|
468
|
-
'taiga_wait_for_backlog_item',
|
|
469
|
-
],
|
|
468
|
+
'backlog:read': ['taiga_list_initiatives', 'taiga_get_initiative', 'taiga_get_plan', 'taiga_wait_for_initiative'],
|
|
470
469
|
'factories:read': ['taiga_list_factories', 'taiga_list_factory_documents', 'taiga_get_factory_document'],
|
|
471
470
|
'tenant:documents:read': ['taiga_list_documents', 'taiga_get_document'],
|
|
472
471
|
'customDocuments:read': ['taiga_list_knowledge', 'taiga_get_knowledge'],
|
|
@@ -474,10 +473,10 @@ describe('MCP server integration', () => {
|
|
|
474
473
|
const PAIRED_CONTRACT: Record<string, string[]> = {
|
|
475
474
|
'tenant:documents:read tenant:documents:write': ['taiga_update_document', 'taiga_publish_document'],
|
|
476
475
|
'factories:read factories:documents:write': ['taiga_update_factory_document', 'taiga_publish_factory_document'],
|
|
477
|
-
'backlog:write': ['
|
|
478
|
-
'backlog:write products:write': ['
|
|
476
|
+
'backlog:write': ['taiga_move_initiative', 'taiga_set_auto_merge'],
|
|
477
|
+
'backlog:write products:write': ['taiga_queue_initiative'],
|
|
479
478
|
'products:write': [
|
|
480
|
-
'
|
|
479
|
+
'taiga_resume_initiative',
|
|
481
480
|
'taiga_set_auto_build',
|
|
482
481
|
'taiga_request_initiatives',
|
|
483
482
|
'taiga_set_product_autonomy',
|
|
@@ -495,7 +494,7 @@ describe('MCP server integration', () => {
|
|
|
495
494
|
'backlog:read backlog:write products:write': ['taiga_plan_again'],
|
|
496
495
|
'products:read products:write': [
|
|
497
496
|
'taiga_start_import',
|
|
498
|
-
'
|
|
497
|
+
'taiga_stop_run',
|
|
499
498
|
'taiga_pause_line',
|
|
500
499
|
'taiga_ignore_finding',
|
|
501
500
|
'taiga_fix_finding',
|
|
@@ -1136,7 +1135,7 @@ describe('MCP server integration', () => {
|
|
|
1136
1135
|
});
|
|
1137
1136
|
|
|
1138
1137
|
const result = await client.callTool({
|
|
1139
|
-
name: '
|
|
1138
|
+
name: 'taiga_list_initiatives',
|
|
1140
1139
|
arguments: { productId: PRODUCT_ID, status: 'in_progress', category: 'feature', includeArchived: true },
|
|
1141
1140
|
});
|
|
1142
1141
|
|
|
@@ -1156,7 +1155,7 @@ describe('MCP server integration', () => {
|
|
|
1156
1155
|
routes: { [`/v1/backlog/${ITEM_ID}`]: { data: item } },
|
|
1157
1156
|
});
|
|
1158
1157
|
|
|
1159
|
-
const result = await client.callTool({ name: '
|
|
1158
|
+
const result = await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } });
|
|
1160
1159
|
|
|
1161
1160
|
expect(parseToolResult(result)).toEqual(item);
|
|
1162
1161
|
});
|
|
@@ -1168,8 +1167,8 @@ describe('MCP server integration', () => {
|
|
|
1168
1167
|
});
|
|
1169
1168
|
|
|
1170
1169
|
const result = await client.callTool({
|
|
1171
|
-
name: '
|
|
1172
|
-
arguments: { productId: PRODUCT_ID,
|
|
1170
|
+
name: 'taiga_list_runs',
|
|
1171
|
+
arguments: { productId: PRODUCT_ID, initiativeId: ITEM_ID },
|
|
1173
1172
|
});
|
|
1174
1173
|
|
|
1175
1174
|
const [row] = parseToolResult(result).data;
|
|
@@ -1195,10 +1194,18 @@ describe('MCP server integration', () => {
|
|
|
1195
1194
|
},
|
|
1196
1195
|
});
|
|
1197
1196
|
|
|
1198
|
-
const result = await client.callTool({ name: '
|
|
1197
|
+
const result = await client.callTool({ name: 'taiga_get_run', arguments: { runId: BUILD_ID, full: true } });
|
|
1199
1198
|
|
|
1200
1199
|
expect(result.isError).toBeFalsy();
|
|
1201
|
-
|
|
1200
|
+
const { backlogItemId: _item, ...rest } = run;
|
|
1201
|
+
expect(parseToolResult(result)).toEqual({
|
|
1202
|
+
...rest,
|
|
1203
|
+
initiativeId: ITEM_ID,
|
|
1204
|
+
// Completed with no merge recorded: an older run that settled on an open pull request.
|
|
1205
|
+
disposition: 'unmerged',
|
|
1206
|
+
outcome: 'Settled without merging: its pull request was left unmerged.',
|
|
1207
|
+
steps: [step],
|
|
1208
|
+
});
|
|
1202
1209
|
});
|
|
1203
1210
|
|
|
1204
1211
|
it('should drop tenantId from every response at the client, so a run, its steps, an item and a list row never carry it', async () => {
|
|
@@ -1214,14 +1221,22 @@ describe('MCP server integration', () => {
|
|
|
1214
1221
|
});
|
|
1215
1222
|
|
|
1216
1223
|
const build = parseToolResult(
|
|
1217
|
-
await client.callTool({ name: '
|
|
1224
|
+
await client.callTool({ name: 'taiga_get_run', arguments: { runId: BUILD_ID, full: true } })
|
|
1218
1225
|
);
|
|
1219
|
-
|
|
1226
|
+
const { backlogItemId: _item, ...rest } = run;
|
|
1227
|
+
expect(build).toEqual({
|
|
1228
|
+
...rest,
|
|
1229
|
+
initiativeId: ITEM_ID,
|
|
1230
|
+
disposition: 'unmerged',
|
|
1231
|
+
outcome: 'Settled without merging: its pull request was left unmerged.',
|
|
1232
|
+
steps: [{ ...step, verification: { ok: true } }],
|
|
1233
|
+
});
|
|
1220
1234
|
expect(JSON.stringify(build)).not.toContain('tenantId');
|
|
1221
1235
|
|
|
1222
1236
|
const got = parseToolResult(
|
|
1223
|
-
await client.callTool({ name: '
|
|
1237
|
+
await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
|
|
1224
1238
|
);
|
|
1239
|
+
// No lifecycle on this item, so no situation.
|
|
1225
1240
|
expect(got).toEqual(item);
|
|
1226
1241
|
});
|
|
1227
1242
|
|
|
@@ -1230,8 +1245,8 @@ describe('MCP server integration', () => {
|
|
|
1230
1245
|
const before = fetchSpy.mock.calls.length;
|
|
1231
1246
|
|
|
1232
1247
|
for (const [name, args] of [
|
|
1233
|
-
['
|
|
1234
|
-
['
|
|
1248
|
+
['taiga_list_initiatives', { productId: PRODUCT_ID, limit: 2.5 }],
|
|
1249
|
+
['taiga_list_runs', { productId: PRODUCT_ID, limit: 2.5 }],
|
|
1235
1250
|
['taiga_list_products', { limit: 2.5 }],
|
|
1236
1251
|
] as const) {
|
|
1237
1252
|
const result = await client.callTool({ name, arguments: { ...args } });
|
|
@@ -1245,8 +1260,8 @@ describe('MCP server integration', () => {
|
|
|
1245
1260
|
|
|
1246
1261
|
const names = toolNames((await client.listTools()).tools);
|
|
1247
1262
|
|
|
1248
|
-
expect(names).toContain('
|
|
1249
|
-
expect(names).not.toContain('
|
|
1263
|
+
expect(names).toContain('taiga_list_runs');
|
|
1264
|
+
expect(names).not.toContain('taiga_list_initiatives');
|
|
1250
1265
|
});
|
|
1251
1266
|
});
|
|
1252
1267
|
|
|
@@ -1949,6 +1964,34 @@ describe('MCP server integration', () => {
|
|
|
1949
1964
|
},
|
|
1950
1965
|
[`GET /v1/products/${PRODUCT_ID}/repositories`]: { data: [repo], nextCursor: null },
|
|
1951
1966
|
[`GET /v1/products/${PRODUCT_ID}/builds?limit=10`]: { data: [run], nextCursor: null },
|
|
1967
|
+
[`GET /v1/products/${PRODUCT_ID}/inbox`]: {
|
|
1968
|
+
data: [
|
|
1969
|
+
{ id: 'i1', kind: 'review_pr', status: 'open' },
|
|
1970
|
+
{ id: 'i2', kind: 'review_pr', status: 'open' },
|
|
1971
|
+
{ id: 'i3', kind: 'setup_secret', status: 'snoozed', snoozedUntil: '2999-01-01T00:00:00.000Z' },
|
|
1972
|
+
],
|
|
1973
|
+
nextCursor: null,
|
|
1974
|
+
totalCount: 3,
|
|
1975
|
+
},
|
|
1976
|
+
[`GET /v1/products/${PRODUCT_ID}/maintenance/summary`]: {
|
|
1977
|
+
data: {
|
|
1978
|
+
openFindings: 1,
|
|
1979
|
+
severityCounts: { critical: 0, high: 1 },
|
|
1980
|
+
lastRun: { id: 'sweep-1', status: 'completed', completedAt: '2026-10-01T00:00:00.000Z', findingsCount: 1 },
|
|
1981
|
+
},
|
|
1982
|
+
},
|
|
1983
|
+
[`GET /v1/products/${PRODUCT_ID}/maintenance/policies`]: {
|
|
1984
|
+
// The API's own shape: the lists, no summary.
|
|
1985
|
+
data: {
|
|
1986
|
+
controls: [
|
|
1987
|
+
{ controlId: 'SEC-1', divergenceCount: 2, findingCount: 0 },
|
|
1988
|
+
{ controlId: 'SEC-2', divergenceCount: 0, findingCount: 1 },
|
|
1989
|
+
{ controlId: 'SEC-3', divergenceCount: 0, findingCount: 0 },
|
|
1990
|
+
],
|
|
1991
|
+
divergences: [{ severity: 'critical' }, { severity: 'high' }, { severity: 'high' }],
|
|
1992
|
+
resolvedDivergences: [],
|
|
1993
|
+
},
|
|
1994
|
+
},
|
|
1952
1995
|
};
|
|
1953
1996
|
|
|
1954
1997
|
it('should assemble every part in one call and name the one document that is not published', async () => {
|
|
@@ -2003,7 +2046,24 @@ describe('MCP server integration', () => {
|
|
|
2003
2046
|
],
|
|
2004
2047
|
truncated: false,
|
|
2005
2048
|
});
|
|
2006
|
-
expect(bundle.builds).toEqual([
|
|
2049
|
+
expect(bundle.builds).toEqual([
|
|
2050
|
+
{
|
|
2051
|
+
id: BUILD_ID,
|
|
2052
|
+
runNumber: 1,
|
|
2053
|
+
status: 'completed',
|
|
2054
|
+
targetTitle: 'Add login',
|
|
2055
|
+
disposition: 'unmerged',
|
|
2056
|
+
outcome: 'Settled without a merged pull request.',
|
|
2057
|
+
},
|
|
2058
|
+
]);
|
|
2059
|
+
// PRO-2800: "how does it stand" in the same call, as counts.
|
|
2060
|
+
expect(bundle.attention).toEqual({ open: 3, byKind: { review_pr: 2, setup_secret: 1 }, snoozed: 1 });
|
|
2061
|
+
expect(bundle.maintenance).toEqual({
|
|
2062
|
+
openFindings: 1,
|
|
2063
|
+
bySeverity: { critical: 0, high: 1 },
|
|
2064
|
+
lastSweep: { status: 'completed', completedAt: '2026-10-01T00:00:00.000Z' },
|
|
2065
|
+
});
|
|
2066
|
+
expect(bundle.standing).toEqual({ openViolations: 3, bySeverity: { critical: 1, high: 2 }, controlsViolated: 2 });
|
|
2007
2067
|
expect(bundle.notGranted).toEqual([]);
|
|
2008
2068
|
expect(bundle.unavailable).toEqual({});
|
|
2009
2069
|
});
|
|
@@ -2328,8 +2388,8 @@ describe('MCP server integration', () => {
|
|
|
2328
2388
|
`/v1/factories/${FACTORY_ID}/documents`,
|
|
2329
2389
|
['factories:read'],
|
|
2330
2390
|
],
|
|
2331
|
-
['
|
|
2332
|
-
['
|
|
2391
|
+
['taiga_list_initiatives', { productId: PRODUCT_ID }, `/v1/products/${PRODUCT_ID}/backlog`, ['backlog:read']],
|
|
2392
|
+
['taiga_list_runs', { productId: PRODUCT_ID }, `/v1/products/${PRODUCT_ID}/builds`, ['products:read']],
|
|
2333
2393
|
] as const)('%s passes the cursor on and returns where the next page starts', async (name, args, path, caps) => {
|
|
2334
2394
|
const { client, fetchSpy } = await bootMcpPair({
|
|
2335
2395
|
capabilities: [...caps],
|
|
@@ -2385,9 +2445,9 @@ describe('MCP server integration', () => {
|
|
|
2385
2445
|
mimeType: 'text/markdown',
|
|
2386
2446
|
});
|
|
2387
2447
|
expect(resourceTemplates.map((t) => t.uriTemplate).sort()).toEqual([
|
|
2388
|
-
'taiga://backlog/{itemId}',
|
|
2389
2448
|
'taiga://documents/{type}',
|
|
2390
2449
|
'taiga://factories/{factoryId}/instructions',
|
|
2450
|
+
'taiga://initiatives/{initiativeId}',
|
|
2391
2451
|
'taiga://products/{productId}/documents/{type}',
|
|
2392
2452
|
'taiga://products/{productId}/documents/{type}{?origin}',
|
|
2393
2453
|
]);
|
|
@@ -2398,7 +2458,7 @@ describe('MCP server integration', () => {
|
|
|
2398
2458
|
|
|
2399
2459
|
const { resourceTemplates } = await client.listResourceTemplates();
|
|
2400
2460
|
|
|
2401
|
-
expect(resourceTemplates.map((t) => t.uriTemplate)).toEqual(['taiga://
|
|
2461
|
+
expect(resourceTemplates.map((t) => t.uriTemplate)).toEqual(['taiga://initiatives/{initiativeId}']);
|
|
2402
2462
|
});
|
|
2403
2463
|
|
|
2404
2464
|
it('should read a policy as Markdown with its sections as headings and no provenance', async () => {
|
|
@@ -2463,7 +2523,7 @@ describe('MCP server integration', () => {
|
|
|
2463
2523
|
});
|
|
2464
2524
|
|
|
2465
2525
|
const instructions = await client.readResource({ uri: `taiga://factories/${FACTORY_ID}/instructions` });
|
|
2466
|
-
const backlog = await client.readResource({ uri: `taiga://
|
|
2526
|
+
const backlog = await client.readResource({ uri: `taiga://initiatives/${ITEM_ID}` });
|
|
2467
2527
|
|
|
2468
2528
|
expect(textOf(instructions.contents)).toContain('## instructions\n\nUse pnpm.');
|
|
2469
2529
|
expect(backlog.contents[0]!.mimeType).toBe('application/json');
|
|
@@ -2475,7 +2535,7 @@ describe('MCP server integration', () => {
|
|
|
2475
2535
|
const before = fetchSpy.mock.calls.length;
|
|
2476
2536
|
|
|
2477
2537
|
await expect(client.readResource({ uri: 'taiga://documents/policy_ip' })).rejects.toThrow(/not one of/);
|
|
2478
|
-
await expect(client.readResource({ uri: 'taiga://
|
|
2538
|
+
await expect(client.readResource({ uri: 'taiga://initiatives/not-an-id' })).rejects.toThrow(/not an ID/);
|
|
2479
2539
|
await expect(client.readResource({ uri: 'taiga://nothing/here' })).rejects.toThrow(/not found/);
|
|
2480
2540
|
await expect(
|
|
2481
2541
|
client.readResource({ uri: `taiga://products/${PRODUCT_ID}/documents/architecture?origin=` })
|
|
@@ -2601,7 +2661,7 @@ describe('MCP server integration', () => {
|
|
|
2601
2661
|
const docsOnly = await bootMcpPair({ capabilities: ['tenant:documents:read'] });
|
|
2602
2662
|
|
|
2603
2663
|
expect((await full.client.listPrompts()).prompts.map((p) => p.name).sort()).toEqual([
|
|
2604
|
-
'
|
|
2664
|
+
'taiga_implement_initiative',
|
|
2605
2665
|
'taiga_review_against_policies',
|
|
2606
2666
|
'taiga_start_session',
|
|
2607
2667
|
]);
|
|
@@ -2643,7 +2703,10 @@ describe('MCP server integration', () => {
|
|
|
2643
2703
|
routes: { ...routes, [`GET /v1/products/${PRODUCT_ID}`]: { data: named } },
|
|
2644
2704
|
});
|
|
2645
2705
|
|
|
2646
|
-
const result = await client.getPrompt({
|
|
2706
|
+
const result = await client.getPrompt({
|
|
2707
|
+
name: 'taiga_implement_initiative',
|
|
2708
|
+
arguments: { initiativeId: ITEM_ID },
|
|
2709
|
+
});
|
|
2647
2710
|
|
|
2648
2711
|
const text = (result.messages[0]!.content as { text: string }).text;
|
|
2649
2712
|
expect(text.split('## Initiative')[0]).not.toContain('(none apply)');
|
|
@@ -2720,25 +2783,28 @@ describe('MCP server integration', () => {
|
|
|
2720
2783
|
const before = fetchSpy.mock.calls.length;
|
|
2721
2784
|
|
|
2722
2785
|
const notAnId = await client
|
|
2723
|
-
.getPrompt({ name: '
|
|
2786
|
+
.getPrompt({ name: 'taiga_implement_initiative', arguments: { initiativeId: 'PRO-123' } })
|
|
2724
2787
|
.catch((e: unknown) => e);
|
|
2725
2788
|
expect(notAnId).toMatchObject({ code: -32602 });
|
|
2726
2789
|
expect(fetchSpy.mock.calls.length).toBe(before);
|
|
2727
2790
|
|
|
2728
2791
|
const unseen = await client
|
|
2729
2792
|
.getPrompt({
|
|
2730
|
-
name: '
|
|
2731
|
-
arguments: {
|
|
2793
|
+
name: 'taiga_implement_initiative',
|
|
2794
|
+
arguments: { initiativeId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a99' },
|
|
2732
2795
|
})
|
|
2733
2796
|
.catch((e: unknown) => e);
|
|
2734
2797
|
expect(unseen).toMatchObject({ code: -32602 });
|
|
2735
|
-
expect(String((unseen as Error).message)).toContain('
|
|
2798
|
+
expect(String((unseen as Error).message)).toContain('taiga_list_initiatives shows what exists');
|
|
2736
2799
|
});
|
|
2737
2800
|
|
|
2738
2801
|
it('should assemble an initiative with its criteria, the architecture, the instructions and the policy sections', async () => {
|
|
2739
2802
|
const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes });
|
|
2740
2803
|
|
|
2741
|
-
const result = await client.getPrompt({
|
|
2804
|
+
const result = await client.getPrompt({
|
|
2805
|
+
name: 'taiga_implement_initiative',
|
|
2806
|
+
arguments: { initiativeId: ITEM_ID },
|
|
2807
|
+
});
|
|
2742
2808
|
|
|
2743
2809
|
const text = (result.messages[0]!.content as { text: string }).text;
|
|
2744
2810
|
expect(text).toContain('Users can log in');
|
|
@@ -2750,7 +2816,10 @@ describe('MCP server integration', () => {
|
|
|
2750
2816
|
it('should say which parts a narrower key could not read rather than drop them', async () => {
|
|
2751
2817
|
const { client } = await bootMcpPair({ capabilities: ['products:read', 'backlog:read'], routes });
|
|
2752
2818
|
|
|
2753
|
-
const result = await client.getPrompt({
|
|
2819
|
+
const result = await client.getPrompt({
|
|
2820
|
+
name: 'taiga_implement_initiative',
|
|
2821
|
+
arguments: { initiativeId: ITEM_ID },
|
|
2822
|
+
});
|
|
2754
2823
|
|
|
2755
2824
|
const text = (result.messages[0]!.content as { text: string }).text;
|
|
2756
2825
|
expect(text).toContain('(The factory instructions need factories:read, which this key lacks.)');
|
|
@@ -2766,7 +2835,10 @@ describe('MCP server integration', () => {
|
|
|
2766
2835
|
return { ok: false, status: 403, json: async () => body, text: async () => JSON.stringify(body) };
|
|
2767
2836
|
});
|
|
2768
2837
|
|
|
2769
|
-
const result = await client.getPrompt({
|
|
2838
|
+
const result = await client.getPrompt({
|
|
2839
|
+
name: 'taiga_implement_initiative',
|
|
2840
|
+
arguments: { initiativeId: ITEM_ID },
|
|
2841
|
+
});
|
|
2770
2842
|
|
|
2771
2843
|
const text = (result.messages[0]!.content as { text: string }).text;
|
|
2772
2844
|
expect(text).toContain(
|
|
@@ -2878,12 +2950,12 @@ describe('MCP server integration', () => {
|
|
|
2878
2950
|
const byName = Object.fromEntries(tools.map((t) => [t.name, t.annotations]));
|
|
2879
2951
|
|
|
2880
2952
|
for (const name of [
|
|
2881
|
-
'
|
|
2882
|
-
'
|
|
2883
|
-
'
|
|
2953
|
+
'taiga_queue_initiative',
|
|
2954
|
+
'taiga_move_initiative',
|
|
2955
|
+
'taiga_resume_initiative',
|
|
2884
2956
|
'taiga_approve_plan',
|
|
2885
2957
|
'taiga_reject_plan',
|
|
2886
|
-
'
|
|
2958
|
+
'taiga_stop_run',
|
|
2887
2959
|
'taiga_request_initiatives',
|
|
2888
2960
|
'taiga_plan_again',
|
|
2889
2961
|
'taiga_define_environment',
|
|
@@ -3080,7 +3152,7 @@ describe('MCP server integration', () => {
|
|
|
3080
3152
|
routes: { [`PUT /v1/backlog/${ITEM_ID}/state`]: { data: queued } },
|
|
3081
3153
|
});
|
|
3082
3154
|
|
|
3083
|
-
const result = await client.callTool({ name: '
|
|
3155
|
+
const result = await client.callTool({ name: 'taiga_queue_initiative', arguments: { initiativeId: ITEM_ID } });
|
|
3084
3156
|
|
|
3085
3157
|
expect(result.isError).toBeFalsy();
|
|
3086
3158
|
// One write to the item, no item read around it: the route answers with the item (the reads
|
|
@@ -3097,12 +3169,15 @@ describe('MCP server integration', () => {
|
|
|
3097
3169
|
routes: { [`PUT /v1/backlog/${ITEM_ID}/state`]: { data: queued } },
|
|
3098
3170
|
});
|
|
3099
3171
|
|
|
3100
|
-
await client.callTool({
|
|
3172
|
+
await client.callTool({
|
|
3173
|
+
name: 'taiga_queue_initiative',
|
|
3174
|
+
arguments: { initiativeId: ITEM_ID, afterId: OTHER_ITEM },
|
|
3175
|
+
});
|
|
3101
3176
|
expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'queue', afterId: OTHER_ITEM });
|
|
3102
3177
|
|
|
3103
3178
|
fetchSpy.mockClear();
|
|
3104
3179
|
vi.stubGlobal('fetch', fetchSpy);
|
|
3105
|
-
await client.callTool({ name: '
|
|
3180
|
+
await client.callTool({ name: 'taiga_queue_initiative', arguments: { initiativeId: ITEM_ID, afterId: null } });
|
|
3106
3181
|
expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'queue', afterId: null });
|
|
3107
3182
|
});
|
|
3108
3183
|
|
|
@@ -3114,7 +3189,7 @@ describe('MCP server integration', () => {
|
|
|
3114
3189
|
},
|
|
3115
3190
|
});
|
|
3116
3191
|
|
|
3117
|
-
const result = await client.callTool({ name: '
|
|
3192
|
+
const result = await client.callTool({ name: 'taiga_queue_initiative', arguments: { initiativeId: ITEM_ID } });
|
|
3118
3193
|
|
|
3119
3194
|
expect(result.isError).toBe(true);
|
|
3120
3195
|
expect(outcomeOf(result)).toEqual({ error: 'CONFLICT', message: 'Stop the running agent before re-planning' });
|
|
@@ -3123,7 +3198,7 @@ describe('MCP server integration', () => {
|
|
|
3123
3198
|
it('should report an item the key cannot see as a miss', async () => {
|
|
3124
3199
|
const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
3125
3200
|
|
|
3126
|
-
const result = await client.callTool({ name: '
|
|
3201
|
+
const result = await client.callTool({ name: 'taiga_queue_initiative', arguments: { initiativeId: ITEM_ID } });
|
|
3127
3202
|
|
|
3128
3203
|
expect(result.isError).toBe(true);
|
|
3129
3204
|
expect(outcomeOf(result).error).toBe('NOT_FOUND');
|
|
@@ -3134,7 +3209,7 @@ describe('MCP server integration', () => {
|
|
|
3134
3209
|
const operatorOnly = await bootMcpPair({ capabilities: ['products:write'] });
|
|
3135
3210
|
|
|
3136
3211
|
expect(toolNames((await backlogOnly.client.listTools()).tools)).toEqual([
|
|
3137
|
-
'
|
|
3212
|
+
'taiga_move_initiative',
|
|
3138
3213
|
'taiga_set_auto_merge',
|
|
3139
3214
|
]);
|
|
3140
3215
|
// A request, the resume and the review gate take a product or an item ID a request or a queue hands
|
|
@@ -3148,7 +3223,7 @@ describe('MCP server integration', () => {
|
|
|
3148
3223
|
'taiga_refresh_reality_documents',
|
|
3149
3224
|
'taiga_reopen_discovery',
|
|
3150
3225
|
'taiga_request_initiatives',
|
|
3151
|
-
'
|
|
3226
|
+
'taiga_resume_initiative',
|
|
3152
3227
|
'taiga_set_auto_build',
|
|
3153
3228
|
'taiga_set_product_autonomy',
|
|
3154
3229
|
'taiga_stop_generating_documents',
|
|
@@ -3161,8 +3236,8 @@ describe('MCP server integration', () => {
|
|
|
3161
3236
|
const before = narrow.fetchSpy.mock.calls.length;
|
|
3162
3237
|
|
|
3163
3238
|
const result = await narrow.client.callTool({
|
|
3164
|
-
name: '
|
|
3165
|
-
arguments: {
|
|
3239
|
+
name: 'taiga_move_initiative',
|
|
3240
|
+
arguments: { initiativeId: ITEM_ID, state: 'queue' },
|
|
3166
3241
|
});
|
|
3167
3242
|
|
|
3168
3243
|
expect(result.isError).toBe(true);
|
|
@@ -3182,7 +3257,10 @@ describe('MCP server integration', () => {
|
|
|
3182
3257
|
[`PUT /v1/backlog/${ITEM_ID}/state`]: { data: { ...atStation, planningStatus: 'planning' } },
|
|
3183
3258
|
},
|
|
3184
3259
|
});
|
|
3185
|
-
const replanned = await station.client.callTool({
|
|
3260
|
+
const replanned = await station.client.callTool({
|
|
3261
|
+
name: 'taiga_plan_again',
|
|
3262
|
+
arguments: { initiativeId: ITEM_ID },
|
|
3263
|
+
});
|
|
3186
3264
|
expect(replanned.isError).toBeFalsy();
|
|
3187
3265
|
expect(sentBody(station.fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'plan' });
|
|
3188
3266
|
expect(outcomeOf(replanned)).toMatchObject({ state: 'plan', planningStatus: 'planning' });
|
|
@@ -3191,7 +3269,7 @@ describe('MCP server integration', () => {
|
|
|
3191
3269
|
capabilities: ALL_CAPABILITIES,
|
|
3192
3270
|
routes: { [`GET /v1/backlog/${ITEM_ID}`]: { data: item } },
|
|
3193
3271
|
});
|
|
3194
|
-
const refused = await idle.client.callTool({ name: 'taiga_plan_again', arguments: {
|
|
3272
|
+
const refused = await idle.client.callTool({ name: 'taiga_plan_again', arguments: { initiativeId: ITEM_ID } });
|
|
3195
3273
|
expect(refused.isError).toBe(true);
|
|
3196
3274
|
expect(outcomeOf(refused).message).toMatch(/not at the Build station\. The queue is the only way to start work/);
|
|
3197
3275
|
// Refused on the read: no move was sent.
|
|
@@ -3208,7 +3286,7 @@ describe('MCP server integration', () => {
|
|
|
3208
3286
|
},
|
|
3209
3287
|
});
|
|
3210
3288
|
|
|
3211
|
-
await client.callTool({ name: 'taiga_plan_again', arguments: {
|
|
3289
|
+
await client.callTool({ name: 'taiga_plan_again', arguments: { initiativeId: ITEM_ID, force: true } });
|
|
3212
3290
|
|
|
3213
3291
|
expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'plan', force: true });
|
|
3214
3292
|
});
|
|
@@ -3235,8 +3313,8 @@ describe('MCP server integration', () => {
|
|
|
3235
3313
|
'Initiatives are already being generated for this product',
|
|
3236
3314
|
],
|
|
3237
3315
|
[
|
|
3238
|
-
'
|
|
3239
|
-
{
|
|
3316
|
+
'taiga_resume_initiative',
|
|
3317
|
+
{ initiativeId: ITEM_ID },
|
|
3240
3318
|
'CONFLICT',
|
|
3241
3319
|
'This initiative is not stopped, so there is nothing to resume.',
|
|
3242
3320
|
],
|
|
@@ -3256,8 +3334,8 @@ describe('MCP server integration', () => {
|
|
|
3256
3334
|
});
|
|
3257
3335
|
|
|
3258
3336
|
const result = await client.callTool({
|
|
3259
|
-
name: '
|
|
3260
|
-
arguments: {
|
|
3337
|
+
name: 'taiga_move_initiative',
|
|
3338
|
+
arguments: { initiativeId: ITEM_ID, state: 'todo', afterId: null },
|
|
3261
3339
|
});
|
|
3262
3340
|
|
|
3263
3341
|
expect(result.isError).toBeFalsy();
|
|
@@ -3276,8 +3354,8 @@ describe('MCP server integration', () => {
|
|
|
3276
3354
|
});
|
|
3277
3355
|
|
|
3278
3356
|
const filed = await client.callTool({
|
|
3279
|
-
name: '
|
|
3280
|
-
arguments: {
|
|
3357
|
+
name: 'taiga_move_initiative',
|
|
3358
|
+
arguments: { initiativeId: ITEM_ID, state: 'canceled', archived: true },
|
|
3281
3359
|
});
|
|
3282
3360
|
|
|
3283
3361
|
expect(filed.isError).toBeFalsy();
|
|
@@ -3287,8 +3365,8 @@ describe('MCP server integration', () => {
|
|
|
3287
3365
|
fetchSpy.mockClear();
|
|
3288
3366
|
vi.stubGlobal('fetch', fetchSpy);
|
|
3289
3367
|
await client.callTool({
|
|
3290
|
-
name: '
|
|
3291
|
-
arguments: {
|
|
3368
|
+
name: 'taiga_move_initiative',
|
|
3369
|
+
arguments: { initiativeId: ITEM_ID, state: 'todo', archived: false },
|
|
3292
3370
|
});
|
|
3293
3371
|
expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'todo', archived: false });
|
|
3294
3372
|
});
|
|
@@ -3306,8 +3384,8 @@ describe('MCP server integration', () => {
|
|
|
3306
3384
|
});
|
|
3307
3385
|
|
|
3308
3386
|
const result = await client.callTool({
|
|
3309
|
-
name: '
|
|
3310
|
-
arguments: {
|
|
3387
|
+
name: 'taiga_move_initiative',
|
|
3388
|
+
arguments: { initiativeId: ITEM_ID, state: 'done', archived: true },
|
|
3311
3389
|
});
|
|
3312
3390
|
|
|
3313
3391
|
expect(result.isError).toBe(true);
|
|
@@ -3321,7 +3399,7 @@ describe('MCP server integration', () => {
|
|
|
3321
3399
|
const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
3322
3400
|
|
|
3323
3401
|
const { tools } = await client.listTools();
|
|
3324
|
-
const move = tools.find((t) => t.name === '
|
|
3402
|
+
const move = tools.find((t) => t.name === 'taiga_move_initiative')!;
|
|
3325
3403
|
const props = move.inputSchema.properties as Record<string, { enum?: string[] }>;
|
|
3326
3404
|
// The queue is the only way to start work: no "plan this now", nothing dropped into Build.
|
|
3327
3405
|
expect(props['state']!.enum).toEqual(['backlog', 'todo', 'queue', 'done', 'canceled']);
|
|
@@ -3329,8 +3407,8 @@ describe('MCP server integration', () => {
|
|
|
3329
3407
|
const before = fetchSpy.mock.calls.length;
|
|
3330
3408
|
for (const state of ['build', 'plan']) {
|
|
3331
3409
|
const result = await client.callTool({
|
|
3332
|
-
name: '
|
|
3333
|
-
arguments: {
|
|
3410
|
+
name: 'taiga_move_initiative',
|
|
3411
|
+
arguments: { initiativeId: ITEM_ID, state },
|
|
3334
3412
|
});
|
|
3335
3413
|
expect(result.isError, state).toBe(true);
|
|
3336
3414
|
}
|
|
@@ -3346,8 +3424,8 @@ describe('MCP server integration', () => {
|
|
|
3346
3424
|
});
|
|
3347
3425
|
|
|
3348
3426
|
const result = await client.callTool({
|
|
3349
|
-
name: '
|
|
3350
|
-
arguments: {
|
|
3427
|
+
name: 'taiga_move_initiative',
|
|
3428
|
+
arguments: { initiativeId: ITEM_ID, state: 'todo' },
|
|
3351
3429
|
});
|
|
3352
3430
|
|
|
3353
3431
|
expect(result.isError).toBe(true);
|
|
@@ -3361,7 +3439,7 @@ describe('MCP server integration', () => {
|
|
|
3361
3439
|
routes: { [`POST /v1/backlog/${ITEM_ID}/resume`]: { data: resumed } },
|
|
3362
3440
|
});
|
|
3363
3441
|
|
|
3364
|
-
const result = await client.callTool({ name: '
|
|
3442
|
+
const result = await client.callTool({ name: 'taiga_resume_initiative', arguments: { initiativeId: ITEM_ID } });
|
|
3365
3443
|
|
|
3366
3444
|
expect(result.isError).toBeFalsy();
|
|
3367
3445
|
const call = fetchSpy.mock.calls.find((c: unknown[]) => (c[0] as string).endsWith('/resume'))!;
|
|
@@ -3379,11 +3457,11 @@ describe('MCP server integration', () => {
|
|
|
3379
3457
|
},
|
|
3380
3458
|
});
|
|
3381
3459
|
|
|
3382
|
-
const found = await client.callTool({ name: 'taiga_get_plan', arguments: {
|
|
3460
|
+
const found = await client.callTool({ name: 'taiga_get_plan', arguments: { initiativeId: ITEM_ID } });
|
|
3383
3461
|
// A summary by default: the step's prose, timestamps and planId stay behind.
|
|
3384
3462
|
expect(outcomeOf(found)).toEqual({
|
|
3385
3463
|
id: PLAN_ID,
|
|
3386
|
-
|
|
3464
|
+
initiativeId: ITEM_ID,
|
|
3387
3465
|
version: 1,
|
|
3388
3466
|
status: 'active',
|
|
3389
3467
|
steps: [
|
|
@@ -3400,19 +3478,21 @@ describe('MCP server integration', () => {
|
|
|
3400
3478
|
],
|
|
3401
3479
|
});
|
|
3402
3480
|
|
|
3403
|
-
const full = await client.callTool({ name: 'taiga_get_plan', arguments: {
|
|
3481
|
+
const full = await client.callTool({ name: 'taiga_get_plan', arguments: { initiativeId: ITEM_ID, full: true } });
|
|
3404
3482
|
const { tenantId: _t, ...planWithoutTenant } = plan;
|
|
3405
|
-
expect(outcomeOf(full)).toEqual(
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3483
|
+
expect(outcomeOf(full)).toEqual(
|
|
3484
|
+
asSeen({
|
|
3485
|
+
...planWithoutTenant,
|
|
3486
|
+
steps: plan.steps.map(({ tenantId: _s, ...step }) => step),
|
|
3487
|
+
})
|
|
3488
|
+
);
|
|
3409
3489
|
|
|
3410
|
-
const none = await client.callTool({ name: 'taiga_get_plan', arguments: {
|
|
3490
|
+
const none = await client.callTool({ name: 'taiga_get_plan', arguments: { initiativeId: OTHER_ITEM } });
|
|
3411
3491
|
expect(none.isError).toBe(true);
|
|
3412
3492
|
expect(outcomeOf(none)).toEqual({
|
|
3413
3493
|
error: 'NOT_FOUND',
|
|
3414
3494
|
message:
|
|
3415
|
-
'No active plan found.
|
|
3495
|
+
'No active plan found. taiga_get_initiative says whether one is being generated (planningStatus planning) or failed, and taiga_plan_again asks for a new one.',
|
|
3416
3496
|
});
|
|
3417
3497
|
});
|
|
3418
3498
|
|
|
@@ -3473,18 +3553,20 @@ describe('MCP server integration', () => {
|
|
|
3473
3553
|
},
|
|
3474
3554
|
});
|
|
3475
3555
|
|
|
3476
|
-
const stop = await client.callTool({ name: '
|
|
3556
|
+
const stop = await client.callTool({ name: 'taiga_stop_run', arguments: { runId: BUILD_ID } });
|
|
3477
3557
|
expect(stop.isError).toBeFalsy();
|
|
3478
3558
|
expect(outcomeOf(stop)).toEqual({
|
|
3479
3559
|
id: BUILD_ID,
|
|
3480
3560
|
runNumber: 1,
|
|
3481
|
-
|
|
3561
|
+
initiativeId: ITEM_ID,
|
|
3482
3562
|
status: 'stopped',
|
|
3483
3563
|
failedReason: 'user_stopped',
|
|
3484
3564
|
completedSteps: 1,
|
|
3485
3565
|
totalSteps: 3,
|
|
3486
3566
|
prUrl: null,
|
|
3487
3567
|
actions: ['resume', 'start_over'],
|
|
3568
|
+
disposition: 'stopped',
|
|
3569
|
+
outcome: 'Stopped before it finished (1 of 3 steps done).',
|
|
3488
3570
|
});
|
|
3489
3571
|
const call = fetchSpy.mock.calls.find((c: unknown[]) => (c[0] as string).endsWith('/stop'))!;
|
|
3490
3572
|
expect((call[1] as RequestInit).body).toBeNull();
|
|
@@ -3498,7 +3580,7 @@ describe('MCP server integration', () => {
|
|
|
3498
3580
|
},
|
|
3499
3581
|
});
|
|
3500
3582
|
|
|
3501
|
-
const result = await client.callTool({ name: '
|
|
3583
|
+
const result = await client.callTool({ name: 'taiga_stop_run', arguments: { runId: BUILD_ID } });
|
|
3502
3584
|
|
|
3503
3585
|
expect(result.isError).toBe(true);
|
|
3504
3586
|
expect(outcomeOf(result)).toEqual({ error: 'CONFLICT', message: 'Cannot stop a run that is completed' });
|
|
@@ -3520,7 +3602,7 @@ describe('MCP server integration', () => {
|
|
|
3520
3602
|
},
|
|
3521
3603
|
});
|
|
3522
3604
|
|
|
3523
|
-
const result = await client.callTool({ name: '
|
|
3605
|
+
const result = await client.callTool({ name: 'taiga_list_runs', arguments: { active: true, limit: 10 } });
|
|
3524
3606
|
|
|
3525
3607
|
expect(result.isError).toBeFalsy();
|
|
3526
3608
|
const { data, nextCursor } = outcomeOf(result);
|
|
@@ -3541,11 +3623,11 @@ describe('MCP server integration', () => {
|
|
|
3541
3623
|
expect(sentUrl(fetchSpy, '/products').searchParams.get('limit')).toBe('100');
|
|
3542
3624
|
});
|
|
3543
3625
|
|
|
3544
|
-
it('should refuse
|
|
3626
|
+
it('should refuse initiativeId without productId before any request rather than answer with every run', async () => {
|
|
3545
3627
|
const { client, fetchSpy } = await bootMcpPair({ capabilities: ['products:read'] });
|
|
3546
3628
|
const before = fetchSpy.mock.calls.length;
|
|
3547
3629
|
|
|
3548
|
-
const result = await client.callTool({ name: '
|
|
3630
|
+
const result = await client.callTool({ name: 'taiga_list_runs', arguments: { initiativeId: ITEM_ID } });
|
|
3549
3631
|
|
|
3550
3632
|
expect(result.isError).toBe(true);
|
|
3551
3633
|
expect(outcomeOf(result)).toMatchObject({ error: 'VALIDATION_ERROR' });
|
|
@@ -3561,7 +3643,7 @@ describe('MCP server integration', () => {
|
|
|
3561
3643
|
},
|
|
3562
3644
|
});
|
|
3563
3645
|
|
|
3564
|
-
const result = await client.callTool({ name: '
|
|
3646
|
+
const result = await client.callTool({ name: 'taiga_list_runs', arguments: {} });
|
|
3565
3647
|
|
|
3566
3648
|
const { data, note } = outcomeOf(result);
|
|
3567
3649
|
expect(data[0]).not.toHaveProperty('product');
|
|
@@ -3577,7 +3659,7 @@ describe('MCP server integration', () => {
|
|
|
3577
3659
|
},
|
|
3578
3660
|
});
|
|
3579
3661
|
|
|
3580
|
-
const { data, note } = outcomeOf(await client.callTool({ name: '
|
|
3662
|
+
const { data, note } = outcomeOf(await client.callTool({ name: 'taiga_list_runs', arguments: {} }));
|
|
3581
3663
|
|
|
3582
3664
|
expect(data[0]).not.toHaveProperty('product');
|
|
3583
3665
|
expect(note).toMatch(/cannot list \(deleted since, or not visible/);
|
|
@@ -3593,7 +3675,7 @@ describe('MCP server integration', () => {
|
|
|
3593
3675
|
},
|
|
3594
3676
|
});
|
|
3595
3677
|
|
|
3596
|
-
const result = outcomeOf(await client.callTool({ name: '
|
|
3678
|
+
const result = outcomeOf(await client.callTool({ name: 'taiga_list_runs', arguments: {} }));
|
|
3597
3679
|
|
|
3598
3680
|
expect(result.data[0].product).toEqual({ name: 'Web', slug: 'web' });
|
|
3599
3681
|
expect(result).not.toHaveProperty('note');
|
|
@@ -3609,7 +3691,7 @@ describe('MCP server integration', () => {
|
|
|
3609
3691
|
},
|
|
3610
3692
|
},
|
|
3611
3693
|
});
|
|
3612
|
-
const result = outcomeOf(await offline.client.callTool({ name: '
|
|
3694
|
+
const result = outcomeOf(await offline.client.callTool({ name: 'taiga_list_runs', arguments: {} }));
|
|
3613
3695
|
expect(result.data[0]).not.toHaveProperty('product');
|
|
3614
3696
|
expect(result.note).toMatch(/products could not be read/);
|
|
3615
3697
|
|
|
@@ -3622,7 +3704,7 @@ describe('MCP server integration', () => {
|
|
|
3622
3704
|
},
|
|
3623
3705
|
},
|
|
3624
3706
|
});
|
|
3625
|
-
const failure = await buggy.client.callTool({ name: '
|
|
3707
|
+
const failure = await buggy.client.callTool({ name: 'taiga_list_runs', arguments: {} });
|
|
3626
3708
|
expect(failure.isError).toBe(true);
|
|
3627
3709
|
expect(JSON.stringify(failure.content)).toContain('a bug in the products path');
|
|
3628
3710
|
expect(JSON.stringify(failure.content)).not.toContain('could not be read');
|
|
@@ -3637,7 +3719,7 @@ describe('MCP server integration', () => {
|
|
|
3637
3719
|
},
|
|
3638
3720
|
});
|
|
3639
3721
|
|
|
3640
|
-
const result = await client.callTool({ name: '
|
|
3722
|
+
const result = await client.callTool({ name: 'taiga_list_runs', arguments: {} });
|
|
3641
3723
|
|
|
3642
3724
|
expect(result.isError).toBeFalsy();
|
|
3643
3725
|
const { data, note } = outcomeOf(result);
|
|
@@ -3656,7 +3738,7 @@ describe('MCP server integration', () => {
|
|
|
3656
3738
|
},
|
|
3657
3739
|
});
|
|
3658
3740
|
|
|
3659
|
-
expect(outcomeOf(await client.callTool({ name: '
|
|
3741
|
+
expect(outcomeOf(await client.callTool({ name: 'taiga_list_runs', arguments: {} }))).not.toHaveProperty('note');
|
|
3660
3742
|
});
|
|
3661
3743
|
|
|
3662
3744
|
it('should refuse active with productId before any request rather than drop the filter', async () => {
|
|
@@ -3664,7 +3746,7 @@ describe('MCP server integration', () => {
|
|
|
3664
3746
|
const before = fetchSpy.mock.calls.length;
|
|
3665
3747
|
|
|
3666
3748
|
const result = await client.callTool({
|
|
3667
|
-
name: '
|
|
3749
|
+
name: 'taiga_list_runs',
|
|
3668
3750
|
arguments: { productId: PRODUCT_ID, active: true },
|
|
3669
3751
|
});
|
|
3670
3752
|
|
|
@@ -3679,7 +3761,7 @@ describe('MCP server integration', () => {
|
|
|
3679
3761
|
routes: { [`/v1/products/${PRODUCT_ID}/builds`]: { data: [run], nextCursor: null } },
|
|
3680
3762
|
});
|
|
3681
3763
|
|
|
3682
|
-
const result = await client.callTool({ name: '
|
|
3764
|
+
const result = await client.callTool({ name: 'taiga_list_runs', arguments: { productId: PRODUCT_ID } });
|
|
3683
3765
|
|
|
3684
3766
|
expect(result.isError).toBeFalsy();
|
|
3685
3767
|
expect(fetchSpy.mock.calls.some((c: unknown[]) => (c[0] as string).endsWith('/v1/products?limit=100'))).toBe(
|
|
@@ -3758,7 +3840,7 @@ describe('MCP server integration', () => {
|
|
|
3758
3840
|
});
|
|
3759
3841
|
|
|
3760
3842
|
const rows = parseToolResult(
|
|
3761
|
-
await client.callTool({ name: '
|
|
3843
|
+
await client.callTool({ name: 'taiga_list_initiatives', arguments: { productId: PRODUCT_ID } })
|
|
3762
3844
|
).data;
|
|
3763
3845
|
|
|
3764
3846
|
expect(rows[0].criteria).toEqual({ met: 1, total: 2 });
|
|
@@ -3791,9 +3873,7 @@ describe('MCP server integration', () => {
|
|
|
3791
3873
|
routes: { [`/v1/builds/${BUILD_ID}`]: { data: run }, [`/v1/builds/${BUILD_ID}/steps`]: { data: steps } },
|
|
3792
3874
|
});
|
|
3793
3875
|
|
|
3794
|
-
const result = parseToolResult(
|
|
3795
|
-
await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } })
|
|
3796
|
-
);
|
|
3876
|
+
const result = parseToolResult(await client.callTool({ name: 'taiga_get_run', arguments: { runId: BUILD_ID } }));
|
|
3797
3877
|
|
|
3798
3878
|
expect(result).toMatchObject(run);
|
|
3799
3879
|
expect(result.steps[0]).toEqual({
|
|
@@ -3828,7 +3908,7 @@ describe('MCP server integration', () => {
|
|
|
3828
3908
|
});
|
|
3829
3909
|
|
|
3830
3910
|
const queue = parseToolResult(
|
|
3831
|
-
await client.callTool({ name: '
|
|
3911
|
+
await client.callTool({ name: 'taiga_get_line', arguments: { productId: PRODUCT_ID } })
|
|
3832
3912
|
);
|
|
3833
3913
|
|
|
3834
3914
|
expect(queue.entries.map((row: { title: string }) => row.title)).toEqual(['ARC-3.3 OpenAPI', 'README']);
|
|
@@ -3860,10 +3940,10 @@ describe('MCP server integration', () => {
|
|
|
3860
3940
|
});
|
|
3861
3941
|
|
|
3862
3942
|
const queue = parseToolResult(
|
|
3863
|
-
await client.callTool({ name: '
|
|
3943
|
+
await client.callTool({ name: 'taiga_get_line', arguments: { productId: PRODUCT_ID } })
|
|
3864
3944
|
);
|
|
3865
3945
|
|
|
3866
|
-
expect(queue.entries[0]).toMatchObject({
|
|
3946
|
+
expect(queue.entries[0]).toMatchObject({ runId: BUILD_ID, prUrl: 'https://github.com/acme/web/pull/22' });
|
|
3867
3947
|
expect(queue.waitingOn).toBe(
|
|
3868
3948
|
'"ARC-3.3 OpenAPI" is waiting on a person (review_pr) (https://github.com/acme/web/pull/22); every other queued initiative waits until its pull request merges.'
|
|
3869
3949
|
);
|
|
@@ -3887,12 +3967,12 @@ describe('MCP server integration', () => {
|
|
|
3887
3967
|
});
|
|
3888
3968
|
|
|
3889
3969
|
const queue = parseToolResult(
|
|
3890
|
-
await client.callTool({ name: '
|
|
3970
|
+
await client.callTool({ name: 'taiga_get_line', arguments: { productId: PRODUCT_ID } })
|
|
3891
3971
|
);
|
|
3892
3972
|
expect(queue.waitingOn).toMatch(/^"ARC-3.3 OpenAPI" is waiting on a person/);
|
|
3893
3973
|
|
|
3894
3974
|
fetchSpy.mockClear();
|
|
3895
|
-
const queued = await client.callTool({ name: '
|
|
3975
|
+
const queued = await client.callTool({ name: 'taiga_queue_initiative', arguments: { initiativeId: ITEM_ID } });
|
|
3896
3976
|
expect(queued.isError).toBeFalsy();
|
|
3897
3977
|
// Placing an item names only the entry the line waits on: the active one.
|
|
3898
3978
|
const itemReads = fetchSpy.mock.calls
|
|
@@ -3915,7 +3995,7 @@ describe('MCP server integration', () => {
|
|
|
3915
3995
|
});
|
|
3916
3996
|
|
|
3917
3997
|
const queue = parseToolResult(
|
|
3918
|
-
await client.callTool({ name: '
|
|
3998
|
+
await client.callTool({ name: 'taiga_get_line', arguments: { productId: PRODUCT_ID } })
|
|
3919
3999
|
);
|
|
3920
4000
|
|
|
3921
4001
|
expect(queue.waitingOn).toContain('holding the entries behind it');
|
|
@@ -3937,7 +4017,7 @@ describe('MCP server integration', () => {
|
|
|
3937
4017
|
});
|
|
3938
4018
|
|
|
3939
4019
|
const queue = parseToolResult(
|
|
3940
|
-
await client.callTool({ name: '
|
|
4020
|
+
await client.callTool({ name: 'taiga_get_line', arguments: { productId: PRODUCT_ID } })
|
|
3941
4021
|
);
|
|
3942
4022
|
|
|
3943
4023
|
expect(queue.waitingOn).toBe(
|
|
@@ -3964,7 +4044,7 @@ describe('MCP server integration', () => {
|
|
|
3964
4044
|
expect(pause).toEqual({ productId: PRODUCT_ID, linePaused: true, lineStoppedAt: '2026-10-08T20:00:00.000Z' });
|
|
3965
4045
|
|
|
3966
4046
|
const queue = parseToolResult(
|
|
3967
|
-
await client.callTool({ name: '
|
|
4047
|
+
await client.callTool({ name: 'taiga_get_line', arguments: { productId: PRODUCT_ID } })
|
|
3968
4048
|
);
|
|
3969
4049
|
expect(queue.linePaused).toBe(true);
|
|
3970
4050
|
|
|
@@ -3989,7 +4069,7 @@ describe('MCP server integration', () => {
|
|
|
3989
4069
|
// One pair at a time: each boot stubs the global fetch the previous client reads through.
|
|
3990
4070
|
const noBacklog = await bootMcpPair({ capabilities: ['products:read'], routes: queueRoute });
|
|
3991
4071
|
const without = parseToolResult(
|
|
3992
|
-
await noBacklog.client.callTool({ name: '
|
|
4072
|
+
await noBacklog.client.callTool({ name: 'taiga_get_line', arguments: { productId: PRODUCT_ID } })
|
|
3993
4073
|
);
|
|
3994
4074
|
expect(noBacklog.fetchSpy.mock.calls.some((c: unknown[]) => (c[0] as string).includes('/backlog/'))).toBe(false);
|
|
3995
4075
|
const failing = await bootMcpPair({
|
|
@@ -3997,10 +4077,10 @@ describe('MCP server integration', () => {
|
|
|
3997
4077
|
routes: { ...queueRoute, [`/v1/backlog/${HEAD_ITEM}`]: refusal(500, 'INTERNAL', 'boom') },
|
|
3998
4078
|
});
|
|
3999
4079
|
const failed = parseToolResult(
|
|
4000
|
-
await failing.client.callTool({ name: '
|
|
4080
|
+
await failing.client.callTool({ name: 'taiga_get_line', arguments: { productId: PRODUCT_ID } })
|
|
4001
4081
|
);
|
|
4002
4082
|
|
|
4003
|
-
expect(without.entries[0]).toEqual({ position: 1,
|
|
4083
|
+
expect(without.entries[0]).toEqual({ position: 1, initiativeId: HEAD_ITEM, entryState: 'active' });
|
|
4004
4084
|
expect(without.note).toContain('backlog:read');
|
|
4005
4085
|
expect(failed.entries[0]).not.toHaveProperty('title');
|
|
4006
4086
|
expect(failed.note).toContain('could not be read');
|
|
@@ -4020,7 +4100,10 @@ describe('MCP server integration', () => {
|
|
|
4020
4100
|
});
|
|
4021
4101
|
|
|
4022
4102
|
const result = parseToolResult(
|
|
4023
|
-
await client.callTool({
|
|
4103
|
+
await client.callTool({
|
|
4104
|
+
name: 'taiga_queue_initiative',
|
|
4105
|
+
arguments: { initiativeId: ITEM_ID, autoBuild: false },
|
|
4106
|
+
})
|
|
4024
4107
|
);
|
|
4025
4108
|
|
|
4026
4109
|
// To place one item and say what the line waits on, only the blocking entry is read.
|
|
@@ -4056,14 +4139,14 @@ describe('MCP server integration', () => {
|
|
|
4056
4139
|
});
|
|
4057
4140
|
|
|
4058
4141
|
const result = parseToolResult(
|
|
4059
|
-
await client.callTool({ name: '
|
|
4142
|
+
await client.callTool({ name: 'taiga_queue_initiative', arguments: { initiativeId: ITEM_ID } })
|
|
4060
4143
|
);
|
|
4061
4144
|
|
|
4062
4145
|
expect(fetchSpy.mock.calls.some((c: unknown[]) => (c[0] as string).includes('/auto-build'))).toBe(false);
|
|
4063
4146
|
expect(result.reviewGate).toMatchObject({ buildsWithoutReview: true, decidedBy: 'product' });
|
|
4064
4147
|
// The queue read missed (no route); the outcome says so rather than failing the write it reports.
|
|
4065
4148
|
expect(result.queue).toBeNull();
|
|
4066
|
-
expect(result.note).toContain('
|
|
4149
|
+
expect(result.note).toContain('taiga_get_line');
|
|
4067
4150
|
});
|
|
4068
4151
|
|
|
4069
4152
|
it('should set an initiative back to the product default with null and report the gate that now applies', async () => {
|
|
@@ -4076,7 +4159,7 @@ describe('MCP server integration', () => {
|
|
|
4076
4159
|
});
|
|
4077
4160
|
|
|
4078
4161
|
const result = parseToolResult(
|
|
4079
|
-
await client.callTool({ name: 'taiga_set_auto_build', arguments: {
|
|
4162
|
+
await client.callTool({ name: 'taiga_set_auto_build', arguments: { initiativeId: ITEM_ID, autoBuild: null } })
|
|
4080
4163
|
);
|
|
4081
4164
|
|
|
4082
4165
|
expect(sentBody(fetchSpy, 'PUT', '/auto-build')).toEqual({ autoBuild: null });
|
|
@@ -4098,8 +4181,8 @@ describe('MCP server integration', () => {
|
|
|
4098
4181
|
|
|
4099
4182
|
const result = parseToolResult(
|
|
4100
4183
|
await client.callTool({
|
|
4101
|
-
name: '
|
|
4102
|
-
arguments: {
|
|
4184
|
+
name: 'taiga_wait_for_initiative',
|
|
4185
|
+
arguments: { initiativeId: ITEM_ID, until: 'planned', timeoutSeconds: 30 },
|
|
4103
4186
|
})
|
|
4104
4187
|
);
|
|
4105
4188
|
|
|
@@ -4123,7 +4206,10 @@ describe('MCP server integration', () => {
|
|
|
4123
4206
|
});
|
|
4124
4207
|
|
|
4125
4208
|
const result = parseToolResult(
|
|
4126
|
-
await client.callTool({
|
|
4209
|
+
await client.callTool({
|
|
4210
|
+
name: 'taiga_wait_for_initiative',
|
|
4211
|
+
arguments: { initiativeId: ITEM_ID, until: 'pr_open' },
|
|
4212
|
+
})
|
|
4127
4213
|
);
|
|
4128
4214
|
|
|
4129
4215
|
expect(result).toMatchObject({ reached: false, stoppedBecause: 'needs a person: planning_failed' });
|
|
@@ -4143,8 +4229,8 @@ describe('MCP server integration', () => {
|
|
|
4143
4229
|
});
|
|
4144
4230
|
const plan = parseToolResult(
|
|
4145
4231
|
await verdict.client.callTool({
|
|
4146
|
-
name: '
|
|
4147
|
-
arguments: {
|
|
4232
|
+
name: 'taiga_wait_for_initiative',
|
|
4233
|
+
arguments: { initiativeId: ITEM_ID, until: 'pr_open' },
|
|
4148
4234
|
})
|
|
4149
4235
|
);
|
|
4150
4236
|
expect(plan).toMatchObject({ reached: false, stoppedBecause: 'waits on a person: start_build' });
|
|
@@ -4156,8 +4242,8 @@ describe('MCP server integration', () => {
|
|
|
4156
4242
|
});
|
|
4157
4243
|
const unsure = parseToolResult(
|
|
4158
4244
|
await unknown.client.callTool({
|
|
4159
|
-
name: '
|
|
4160
|
-
arguments: {
|
|
4245
|
+
name: 'taiga_wait_for_initiative',
|
|
4246
|
+
arguments: { initiativeId: ITEM_ID, until: 'your_turn' },
|
|
4161
4247
|
})
|
|
4162
4248
|
);
|
|
4163
4249
|
expect(unsure.reached).toBe(false);
|
|
@@ -4169,8 +4255,8 @@ describe('MCP server integration', () => {
|
|
|
4169
4255
|
});
|
|
4170
4256
|
const pr = parseToolResult(
|
|
4171
4257
|
await review.client.callTool({
|
|
4172
|
-
name: '
|
|
4173
|
-
arguments: {
|
|
4258
|
+
name: 'taiga_wait_for_initiative',
|
|
4259
|
+
arguments: { initiativeId: ITEM_ID, until: 'done' },
|
|
4174
4260
|
})
|
|
4175
4261
|
);
|
|
4176
4262
|
expect(pr).toMatchObject({ reached: false, stoppedBecause: 'waits on a person: review_pr' });
|
|
@@ -4196,8 +4282,8 @@ describe('MCP server integration', () => {
|
|
|
4196
4282
|
const started = Date.now();
|
|
4197
4283
|
const result = parseToolResult(
|
|
4198
4284
|
await client.callTool({
|
|
4199
|
-
name: '
|
|
4200
|
-
arguments: {
|
|
4285
|
+
name: 'taiga_wait_for_initiative',
|
|
4286
|
+
arguments: { initiativeId: ITEM_ID, until: 'planned', timeoutSeconds: 1 },
|
|
4201
4287
|
})
|
|
4202
4288
|
);
|
|
4203
4289
|
|
|
@@ -4218,8 +4304,8 @@ describe('MCP server integration', () => {
|
|
|
4218
4304
|
});
|
|
4219
4305
|
|
|
4220
4306
|
const result = await client.callTool({
|
|
4221
|
-
name: '
|
|
4222
|
-
arguments: {
|
|
4307
|
+
name: 'taiga_wait_for_initiative',
|
|
4308
|
+
arguments: { initiativeId: ITEM_ID, until: 'planned', timeoutSeconds: 1 },
|
|
4223
4309
|
});
|
|
4224
4310
|
|
|
4225
4311
|
expect(result.isError).toBeFalsy();
|
|
@@ -4244,7 +4330,10 @@ describe('MCP server integration', () => {
|
|
|
4244
4330
|
});
|
|
4245
4331
|
|
|
4246
4332
|
const result = parseToolResult(
|
|
4247
|
-
await client.callTool({
|
|
4333
|
+
await client.callTool({
|
|
4334
|
+
name: 'taiga_wait_for_initiative',
|
|
4335
|
+
arguments: { initiativeId: ITEM_ID, until: 'changed' },
|
|
4336
|
+
})
|
|
4248
4337
|
);
|
|
4249
4338
|
|
|
4250
4339
|
expect(result).toMatchObject({ reached: true, item: { latestBuild: { prState: 'closed' } } });
|
|
@@ -4268,7 +4357,10 @@ describe('MCP server integration', () => {
|
|
|
4268
4357
|
const controller = new AbortController();
|
|
4269
4358
|
|
|
4270
4359
|
const pending = client.callTool(
|
|
4271
|
-
{
|
|
4360
|
+
{
|
|
4361
|
+
name: 'taiga_wait_for_initiative',
|
|
4362
|
+
arguments: { initiativeId: ITEM_ID, until: 'planned', timeoutSeconds: 60 },
|
|
4363
|
+
},
|
|
4272
4364
|
{ signal: controller.signal }
|
|
4273
4365
|
);
|
|
4274
4366
|
await vi.waitFor(() => expect(hung).toBeDefined());
|
|
@@ -4301,8 +4393,8 @@ describe('MCP server integration', () => {
|
|
|
4301
4393
|
});
|
|
4302
4394
|
const pr = parseToolResult(
|
|
4303
4395
|
await shipped.client.callTool({
|
|
4304
|
-
name: '
|
|
4305
|
-
arguments: {
|
|
4396
|
+
name: 'taiga_wait_for_initiative',
|
|
4397
|
+
arguments: { initiativeId: ITEM_ID, until: 'pr_open' },
|
|
4306
4398
|
})
|
|
4307
4399
|
);
|
|
4308
4400
|
expect(pr).toMatchObject({ reached: false, stoppedBecause: 'the initiative is done' });
|
|
@@ -4318,8 +4410,8 @@ describe('MCP server integration', () => {
|
|
|
4318
4410
|
});
|
|
4319
4411
|
const done = parseToolResult(
|
|
4320
4412
|
await dropped.client.callTool({
|
|
4321
|
-
name: '
|
|
4322
|
-
arguments: {
|
|
4413
|
+
name: 'taiga_wait_for_initiative',
|
|
4414
|
+
arguments: { initiativeId: ITEM_ID, until: 'done' },
|
|
4323
4415
|
})
|
|
4324
4416
|
);
|
|
4325
4417
|
expect(done).toMatchObject({ reached: false, stoppedBecause: 'the initiative is canceled' });
|
|
@@ -4338,8 +4430,8 @@ describe('MCP server integration', () => {
|
|
|
4338
4430
|
});
|
|
4339
4431
|
const runPr = parseToolResult(
|
|
4340
4432
|
await finished.client.callTool({
|
|
4341
|
-
name: '
|
|
4342
|
-
arguments: {
|
|
4433
|
+
name: 'taiga_wait_for_run',
|
|
4434
|
+
arguments: { runId: BUILD_ID, until: 'pr_open' },
|
|
4343
4435
|
})
|
|
4344
4436
|
);
|
|
4345
4437
|
expect(runPr).toMatchObject({ reached: false, stoppedBecause: 'the run is completed' });
|
|
@@ -4374,7 +4466,10 @@ describe('MCP server integration', () => {
|
|
|
4374
4466
|
});
|
|
4375
4467
|
|
|
4376
4468
|
const result = parseToolResult(
|
|
4377
|
-
await client.callTool({
|
|
4469
|
+
await client.callTool({
|
|
4470
|
+
name: 'taiga_wait_for_initiative',
|
|
4471
|
+
arguments: { initiativeId: ITEM_ID, until: 'pr_open' },
|
|
4472
|
+
})
|
|
4378
4473
|
);
|
|
4379
4474
|
|
|
4380
4475
|
// Not stopped at the landed plan: the open gate builds it without anyone.
|
|
@@ -4402,8 +4497,8 @@ describe('MCP server integration', () => {
|
|
|
4402
4497
|
|
|
4403
4498
|
const result = parseToolResult(
|
|
4404
4499
|
await client.callTool({
|
|
4405
|
-
name: '
|
|
4406
|
-
arguments: {
|
|
4500
|
+
name: 'taiga_wait_for_initiative',
|
|
4501
|
+
arguments: { initiativeId: ITEM_ID, until: 'your_turn' },
|
|
4407
4502
|
})
|
|
4408
4503
|
);
|
|
4409
4504
|
|
|
@@ -4436,8 +4531,8 @@ describe('MCP server integration', () => {
|
|
|
4436
4531
|
|
|
4437
4532
|
const result = parseToolResult(
|
|
4438
4533
|
await client.callTool({
|
|
4439
|
-
name: '
|
|
4440
|
-
arguments: {
|
|
4534
|
+
name: 'taiga_wait_for_initiative',
|
|
4535
|
+
arguments: { initiativeId: ITEM_ID, until: 'your_turn' },
|
|
4441
4536
|
})
|
|
4442
4537
|
);
|
|
4443
4538
|
|
|
@@ -4460,7 +4555,10 @@ describe('MCP server integration', () => {
|
|
|
4460
4555
|
});
|
|
4461
4556
|
|
|
4462
4557
|
const result = parseToolResult(
|
|
4463
|
-
await client.callTool({
|
|
4558
|
+
await client.callTool({
|
|
4559
|
+
name: 'taiga_wait_for_initiative',
|
|
4560
|
+
arguments: { initiativeId: ITEM_ID, until: 'changed' },
|
|
4561
|
+
})
|
|
4464
4562
|
);
|
|
4465
4563
|
|
|
4466
4564
|
expect(result).toMatchObject({ reached: true, item: { autoBuild: false } });
|
|
@@ -4474,8 +4572,8 @@ describe('MCP server integration', () => {
|
|
|
4474
4572
|
|
|
4475
4573
|
const result = parseToolResult(
|
|
4476
4574
|
await client.callTool({
|
|
4477
|
-
name: '
|
|
4478
|
-
arguments: {
|
|
4575
|
+
name: 'taiga_wait_for_initiative',
|
|
4576
|
+
arguments: { initiativeId: ITEM_ID, until: 'changed', timeoutSeconds: 1 },
|
|
4479
4577
|
})
|
|
4480
4578
|
);
|
|
4481
4579
|
|
|
@@ -4492,7 +4590,7 @@ describe('MCP server integration', () => {
|
|
|
4492
4590
|
const onprogress = vi.fn();
|
|
4493
4591
|
|
|
4494
4592
|
const result = await client.callTool(
|
|
4495
|
-
{ name: '
|
|
4593
|
+
{ name: 'taiga_wait_for_initiative', arguments: { initiativeId: ITEM_ID, until: 'changed' } },
|
|
4496
4594
|
{ onprogress }
|
|
4497
4595
|
);
|
|
4498
4596
|
|
|
@@ -4509,8 +4607,8 @@ describe('MCP server integration', () => {
|
|
|
4509
4607
|
});
|
|
4510
4608
|
const step = parseToolResult(
|
|
4511
4609
|
await stepped.client.callTool({
|
|
4512
|
-
name: '
|
|
4513
|
-
arguments: {
|
|
4610
|
+
name: 'taiga_wait_for_run',
|
|
4611
|
+
arguments: { runId: BUILD_ID, until: 'step_done' },
|
|
4514
4612
|
})
|
|
4515
4613
|
);
|
|
4516
4614
|
const failed = await bootMcpPair({
|
|
@@ -4519,8 +4617,8 @@ describe('MCP server integration', () => {
|
|
|
4519
4617
|
});
|
|
4520
4618
|
const settled = parseToolResult(
|
|
4521
4619
|
await failed.client.callTool({
|
|
4522
|
-
name: '
|
|
4523
|
-
arguments: {
|
|
4620
|
+
name: 'taiga_wait_for_run',
|
|
4621
|
+
arguments: { runId: BUILD_ID, until: 'pr_open' },
|
|
4524
4622
|
})
|
|
4525
4623
|
);
|
|
4526
4624
|
|
|
@@ -4539,8 +4637,8 @@ describe('MCP server integration', () => {
|
|
|
4539
4637
|
});
|
|
4540
4638
|
const result = parseToolResult(
|
|
4541
4639
|
await recovered.client.callTool({
|
|
4542
|
-
name: '
|
|
4543
|
-
arguments: {
|
|
4640
|
+
name: 'taiga_wait_for_initiative',
|
|
4641
|
+
arguments: { initiativeId: ITEM_ID, until: 'planned' },
|
|
4544
4642
|
})
|
|
4545
4643
|
);
|
|
4546
4644
|
expect(result.reached).toBe(true);
|
|
@@ -4551,8 +4649,8 @@ describe('MCP server integration', () => {
|
|
|
4551
4649
|
routes: { [`/v1/backlog/${ITEM_ID}`]: () => (calls++ === 0 ? answer({ data: item }) : failing) },
|
|
4552
4650
|
});
|
|
4553
4651
|
const failed = await broken.client.callTool({
|
|
4554
|
-
name: '
|
|
4555
|
-
arguments: {
|
|
4652
|
+
name: 'taiga_wait_for_initiative',
|
|
4653
|
+
arguments: { initiativeId: ITEM_ID, until: 'planned' },
|
|
4556
4654
|
});
|
|
4557
4655
|
expect(failed.isError).toBe(true);
|
|
4558
4656
|
expect(calls).toBe(4);
|
|
@@ -4566,8 +4664,8 @@ describe('MCP server integration', () => {
|
|
|
4566
4664
|
});
|
|
4567
4665
|
const stopped = parseToolResult(
|
|
4568
4666
|
await planning.client.callTool({
|
|
4569
|
-
name: '
|
|
4570
|
-
arguments: {
|
|
4667
|
+
name: 'taiga_wait_for_initiative',
|
|
4668
|
+
arguments: { initiativeId: ITEM_ID, until: 'planned' },
|
|
4571
4669
|
})
|
|
4572
4670
|
);
|
|
4573
4671
|
expect(stopped).toMatchObject({ reached: false, stoppedBecause: 'planning failed' });
|
|
@@ -4579,8 +4677,8 @@ describe('MCP server integration', () => {
|
|
|
4579
4677
|
});
|
|
4580
4678
|
const changed = parseToolResult(
|
|
4581
4679
|
await waiting.client.callTool({
|
|
4582
|
-
name: '
|
|
4583
|
-
arguments: {
|
|
4680
|
+
name: 'taiga_wait_for_initiative',
|
|
4681
|
+
arguments: { initiativeId: ITEM_ID, until: 'changed' },
|
|
4584
4682
|
})
|
|
4585
4683
|
);
|
|
4586
4684
|
expect(changed).toMatchObject({ reached: true, item: { state: 'plan', needsHumanCause: null } });
|
|
@@ -4596,8 +4694,8 @@ describe('MCP server integration', () => {
|
|
|
4596
4694
|
});
|
|
4597
4695
|
|
|
4598
4696
|
const result = await client.callTool({
|
|
4599
|
-
name: '
|
|
4600
|
-
arguments: {
|
|
4697
|
+
name: 'taiga_queue_initiative',
|
|
4698
|
+
arguments: { initiativeId: ITEM_ID, autoBuild: false },
|
|
4601
4699
|
});
|
|
4602
4700
|
|
|
4603
4701
|
expect(result.isError).toBe(true);
|
|
@@ -4615,7 +4713,7 @@ describe('MCP server integration', () => {
|
|
|
4615
4713
|
});
|
|
4616
4714
|
|
|
4617
4715
|
const result = parseToolResult(
|
|
4618
|
-
await client.callTool({ name: 'taiga_set_auto_build', arguments: {
|
|
4716
|
+
await client.callTool({ name: 'taiga_set_auto_build', arguments: { initiativeId: ITEM_ID, autoBuild: null } })
|
|
4619
4717
|
);
|
|
4620
4718
|
|
|
4621
4719
|
expect(result.reviewGate).toBeNull();
|
|
@@ -4629,7 +4727,7 @@ describe('MCP server integration', () => {
|
|
|
4629
4727
|
});
|
|
4630
4728
|
|
|
4631
4729
|
const result = parseToolResult(
|
|
4632
|
-
await client.callTool({ name: 'taiga_set_auto_build', arguments: {
|
|
4730
|
+
await client.callTool({ name: 'taiga_set_auto_build', arguments: { initiativeId: ITEM_ID, autoBuild: false } })
|
|
4633
4731
|
);
|
|
4634
4732
|
|
|
4635
4733
|
expect(result.reviewGate).toMatchObject({ buildsWithoutReview: false, decidedBy: 'initiative' });
|
|
@@ -4654,17 +4752,17 @@ describe('MCP server integration', () => {
|
|
|
4654
4752
|
});
|
|
4655
4753
|
|
|
4656
4754
|
const [failed, refused, unknown] = parseToolResult(
|
|
4657
|
-
await client.callTool({ name: '
|
|
4755
|
+
await client.callTool({ name: 'taiga_get_line', arguments: { productId: PRODUCT_ID } })
|
|
4658
4756
|
).entries;
|
|
4659
4757
|
|
|
4660
4758
|
expect(failed.halt.nextStep).toMatch(/^It clears when the run is retried/);
|
|
4661
4759
|
expect(refused.halt).toMatchObject({
|
|
4662
4760
|
reason: 'No seats',
|
|
4663
|
-
nextStep: expect.stringMatching(/^
|
|
4761
|
+
nextStep: expect.stringMatching(/^taiga_resume_initiative lifts it/),
|
|
4664
4762
|
});
|
|
4665
4763
|
expect(unknown.halt).toMatchObject({
|
|
4666
4764
|
reason: 'Stored prose',
|
|
4667
|
-
nextStep: expect.stringContaining('
|
|
4765
|
+
nextStep: expect.stringContaining('taiga_get_initiative'),
|
|
4668
4766
|
});
|
|
4669
4767
|
});
|
|
4670
4768
|
|
|
@@ -4679,7 +4777,7 @@ describe('MCP server integration', () => {
|
|
|
4679
4777
|
},
|
|
4680
4778
|
});
|
|
4681
4779
|
|
|
4682
|
-
const result = await client.callTool({ name: '
|
|
4780
|
+
const result = await client.callTool({ name: 'taiga_get_line', arguments: { productId: PRODUCT_ID } });
|
|
4683
4781
|
|
|
4684
4782
|
expect(result.isError).toBe(true);
|
|
4685
4783
|
expect((result.content as { text: string }[])[0]!.text).toContain('a bug, not the API');
|
|
@@ -4816,14 +4914,10 @@ describe('MCP server integration', () => {
|
|
|
4816
4914
|
// One shape whatever matched (PRO-2778): none right still answers, listing every real id with its size.
|
|
4817
4915
|
expect(miss.isError).toBeFalsy();
|
|
4818
4916
|
const missed = parseToolResult(miss);
|
|
4819
|
-
|
|
4917
|
+
// No text matched, so no empty data.sections beside the ids (PRO-2800).
|
|
4918
|
+
expect(missed).not.toHaveProperty('data');
|
|
4820
4919
|
expect(missed.unknownSections).toEqual(['nope']);
|
|
4821
|
-
expect(missed.
|
|
4822
|
-
'overview',
|
|
4823
|
-
'features',
|
|
4824
|
-
'flows',
|
|
4825
|
-
'risks',
|
|
4826
|
-
]);
|
|
4920
|
+
expect(missed.outline.map((s: { id: string }) => s.id)).toEqual(['overview', 'features', 'flows', 'risks']);
|
|
4827
4921
|
});
|
|
4828
4922
|
|
|
4829
4923
|
it('should read a product document as its outline with sections: []', async () => {
|
|
@@ -4836,8 +4930,8 @@ describe('MCP server integration', () => {
|
|
|
4836
4930
|
})
|
|
4837
4931
|
);
|
|
4838
4932
|
|
|
4839
|
-
expect(outline.
|
|
4840
|
-
expect(outline.
|
|
4933
|
+
expect(outline).not.toHaveProperty('data');
|
|
4934
|
+
expect(outline.outline).toEqual([
|
|
4841
4935
|
{ id: 'overview', characters: 5_000 },
|
|
4842
4936
|
{ id: 'features', characters: expect.any(Number) },
|
|
4843
4937
|
{ id: 'flows', characters: 5_000 },
|
|
@@ -5000,7 +5094,7 @@ describe('MCP server integration', () => {
|
|
|
5000
5094
|
arguments: { productId: PRODUCT_ID, findingId: FINDING_ID },
|
|
5001
5095
|
})
|
|
5002
5096
|
);
|
|
5003
|
-
expect(one).toMatchObject({
|
|
5097
|
+
expect(one).toMatchObject({ initiativeId: ITEM_ID, finding: { id: FINDING_ID, status: 'remediating' } });
|
|
5004
5098
|
expect(one.finding).not.toHaveProperty('fingerprint');
|
|
5005
5099
|
|
|
5006
5100
|
const group = parseToolResult(
|
|
@@ -5016,7 +5110,7 @@ describe('MCP server integration', () => {
|
|
|
5016
5110
|
currentVersion: '4.17.20',
|
|
5017
5111
|
productRepositoryId: repoId,
|
|
5018
5112
|
});
|
|
5019
|
-
expect(group).toEqual({
|
|
5113
|
+
expect(group).toEqual({ initiativeId: ITEM_ID, findingIds: [FINDING_ID] });
|
|
5020
5114
|
|
|
5021
5115
|
// A finding of another product is not fixed as a group of this one.
|
|
5022
5116
|
const before = callsTo(fetchSpy, 'POST').length;
|
|
@@ -5122,7 +5216,7 @@ describe('MCP server integration', () => {
|
|
|
5122
5216
|
arguments: { productId: PRODUCT_ID, controlId: 'SEC-3.4', violationId: VIOLATION_ID },
|
|
5123
5217
|
});
|
|
5124
5218
|
|
|
5125
|
-
expect(control).toEqual({
|
|
5219
|
+
expect(control).toEqual({ initiativeId: ITEM_ID, divergenceIds: [VIOLATION_ID], created: true });
|
|
5126
5220
|
expect(callsTo(fetchSpy, 'POST').map((u) => u.pathname)).toEqual([
|
|
5127
5221
|
`/v1/products/${PRODUCT_ID}/maintenance/policies/controls/SEC-3.4/remediate`,
|
|
5128
5222
|
`/v1/products/${PRODUCT_ID}/maintenance/policies/divergences/${VIOLATION_ID}/remediate`,
|
|
@@ -5180,17 +5274,17 @@ describe('MCP server integration', () => {
|
|
|
5180
5274
|
expect(product.searchParams.has('resolved')).toBe(false);
|
|
5181
5275
|
|
|
5182
5276
|
const done = parseToolResult(
|
|
5183
|
-
await client.callTool({ name: 'taiga_resolve_action', arguments: {
|
|
5277
|
+
await client.callTool({ name: 'taiga_resolve_action', arguments: { actionId: INBOX_ID, status: 'done' } })
|
|
5184
5278
|
);
|
|
5185
5279
|
expect(done.status).toBe('done');
|
|
5186
5280
|
expect(sentBody(fetchSpy, 'PATCH', '/inbox/')).toEqual({ status: 'done' });
|
|
5187
5281
|
|
|
5188
5282
|
fetchSpy.mockClear();
|
|
5189
5283
|
vi.stubGlobal('fetch', fetchSpy);
|
|
5190
|
-
await client.callTool({ name: 'taiga_resolve_action', arguments: {
|
|
5284
|
+
await client.callTool({ name: 'taiga_resolve_action', arguments: { actionId: INBOX_ID, snoozedUntil: null } });
|
|
5191
5285
|
expect(sentBody(fetchSpy, 'PATCH', '/inbox/')).toEqual({ snoozedUntil: null });
|
|
5192
5286
|
|
|
5193
|
-
const nothing = await client.callTool({ name: 'taiga_resolve_action', arguments: {
|
|
5287
|
+
const nothing = await client.callTool({ name: 'taiga_resolve_action', arguments: { actionId: INBOX_ID } });
|
|
5194
5288
|
expect(nothing.isError).toBe(true);
|
|
5195
5289
|
});
|
|
5196
5290
|
|
|
@@ -5511,7 +5605,7 @@ describe('MCP server integration', () => {
|
|
|
5511
5605
|
expect(product).not.toHaveProperty('settings');
|
|
5512
5606
|
|
|
5513
5607
|
const item = parseToolResult(
|
|
5514
|
-
await client.callTool({ name: 'taiga_set_auto_merge', arguments: {
|
|
5608
|
+
await client.callTool({ name: 'taiga_set_auto_merge', arguments: { initiativeId: ITEM_ID, autoMerge: false } })
|
|
5515
5609
|
);
|
|
5516
5610
|
expect(sentBody(fetchSpy, 'PATCH', `/backlog/${ITEM_ID}`)).toEqual({ autoMerge: false });
|
|
5517
5611
|
expect(item).not.toHaveProperty('description');
|
|
@@ -6332,11 +6426,11 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6332
6426
|
},
|
|
6333
6427
|
});
|
|
6334
6428
|
|
|
6335
|
-
const item = await client.callTool({ name: '
|
|
6429
|
+
const item = await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } });
|
|
6336
6430
|
expect(parseToolResult(item)).toEqual({
|
|
6337
6431
|
error: 'NOT_FOUND',
|
|
6338
6432
|
message:
|
|
6339
|
-
"Backlog item not found.
|
|
6433
|
+
"Backlog item not found. taiga_list_initiatives lists a product's initiatives with their IDs; a run or " +
|
|
6340
6434
|
'plan ID is not one.',
|
|
6341
6435
|
});
|
|
6342
6436
|
|
|
@@ -6344,10 +6438,34 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6344
6438
|
name: 'taiga_list_repository_branches',
|
|
6345
6439
|
arguments: { productId: PRODUCT_ID, repositoryId: LINK_ID },
|
|
6346
6440
|
});
|
|
6441
|
+
// The API names the link, so only the link is hinted: the product the call
|
|
6442
|
+
// also named was right, and its hint read as blaming it (PRO-2800).
|
|
6347
6443
|
expect(parseToolResult(branches).message).toBe(
|
|
6348
6444
|
"Repository link not found. taiga_list_product_repositories lists the product's repository links; a GitHub " +
|
|
6349
|
-
'repository id is not a link ID.
|
|
6350
|
-
|
|
6445
|
+
'repository id is not a link ID.'
|
|
6446
|
+
);
|
|
6447
|
+
});
|
|
6448
|
+
|
|
6449
|
+
it('hints every ID the call named when the 404 does not say which one is missing (PRO-2800)', async () => {
|
|
6450
|
+
const { client } = await bootMcpPair({
|
|
6451
|
+
capabilities: ALL_CAPABILITIES,
|
|
6452
|
+
routes: {
|
|
6453
|
+
[`GET /v1/products/${PRODUCT_ID}/repositories/${LINK_ID}/branches`]: refusal(
|
|
6454
|
+
404,
|
|
6455
|
+
'NOT_FOUND',
|
|
6456
|
+
'The requested resource does not exist'
|
|
6457
|
+
),
|
|
6458
|
+
},
|
|
6459
|
+
});
|
|
6460
|
+
|
|
6461
|
+
const branches = await client.callTool({
|
|
6462
|
+
name: 'taiga_list_repository_branches',
|
|
6463
|
+
arguments: { productId: PRODUCT_ID, repositoryId: LINK_ID },
|
|
6464
|
+
});
|
|
6465
|
+
expect(parseToolResult(branches).message).toBe(
|
|
6466
|
+
"The requested resource does not exist. taiga_list_product_repositories lists the product's repository " +
|
|
6467
|
+
'links; a GitHub repository id is not a link ID. taiga_list_products lists the products the key can see; ' +
|
|
6468
|
+
'a name is not an ID (taiga_get_product takes a slug).'
|
|
6351
6469
|
);
|
|
6352
6470
|
});
|
|
6353
6471
|
|
|
@@ -6361,12 +6479,16 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6361
6479
|
},
|
|
6362
6480
|
});
|
|
6363
6481
|
|
|
6364
|
-
const noPlan = parseToolResult(
|
|
6365
|
-
|
|
6482
|
+
const noPlan = parseToolResult(
|
|
6483
|
+
await client.callTool({ name: 'taiga_get_plan', arguments: { initiativeId: ITEM_ID } })
|
|
6484
|
+
);
|
|
6485
|
+
const noItem = parseToolResult(
|
|
6486
|
+
await client.callTool({ name: 'taiga_get_plan', arguments: { initiativeId: OTHER } })
|
|
6487
|
+
);
|
|
6366
6488
|
|
|
6367
6489
|
expect(noPlan.message).toContain('taiga_plan_again asks for a new one');
|
|
6368
6490
|
expect(noItem.message).toBe(
|
|
6369
|
-
"Backlog item not found.
|
|
6491
|
+
"Backlog item not found. taiga_list_initiatives lists a product's initiatives with their IDs; a run or " +
|
|
6370
6492
|
'plan ID is not one.'
|
|
6371
6493
|
);
|
|
6372
6494
|
});
|
|
@@ -6380,9 +6502,7 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6380
6502
|
},
|
|
6381
6503
|
});
|
|
6382
6504
|
|
|
6383
|
-
const result = parseToolResult(
|
|
6384
|
-
await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } })
|
|
6385
|
-
);
|
|
6505
|
+
const result = parseToolResult(await client.callTool({ name: 'taiga_get_run', arguments: { runId: BUILD_ID } }));
|
|
6386
6506
|
|
|
6387
6507
|
expect(result.stoppedIn).toMatch(/^before the first step started/);
|
|
6388
6508
|
});
|
|
@@ -6390,7 +6510,7 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6390
6510
|
it('says where a product ID comes from when a name is passed for one', async () => {
|
|
6391
6511
|
const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
6392
6512
|
|
|
6393
|
-
const result = await client.callTool({ name: '
|
|
6513
|
+
const result = await client.callTool({ name: 'taiga_list_initiatives', arguments: { productId: 'Web shop' } });
|
|
6394
6514
|
|
|
6395
6515
|
expect(result.isError).toBe(true);
|
|
6396
6516
|
expect((result.content as { text: string }[])[0]!.text).toContain(
|
|
@@ -6456,12 +6576,12 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6456
6576
|
// One shape whatever matched (PRO-2778): every real id with its size, the miss named.
|
|
6457
6577
|
expect(none.isError).toBeFalsy();
|
|
6458
6578
|
const missed = parseToolResult(none);
|
|
6459
|
-
expect(missed.
|
|
6579
|
+
expect(missed).not.toHaveProperty('data');
|
|
6460
6580
|
expect(missed.unknownSections).toEqual(['secrets']);
|
|
6461
|
-
expect(missed.
|
|
6581
|
+
expect(missed.outline.map((s: { id: string }) => s.id)).toEqual(['summary', 'scope']);
|
|
6462
6582
|
});
|
|
6463
6583
|
|
|
6464
|
-
it('says what a product in setup
|
|
6584
|
+
it('says what a product in setup still needs, from what is linked and what the API would refuse on (PRO-2800)', async () => {
|
|
6465
6585
|
const draft = {
|
|
6466
6586
|
id: PRODUCT_ID,
|
|
6467
6587
|
name: 'Web',
|
|
@@ -6473,29 +6593,46 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6473
6593
|
ownsInfrastructure: false,
|
|
6474
6594
|
deploymentNotes: null,
|
|
6475
6595
|
};
|
|
6596
|
+
const blockers = { data: { inSetup: true, missingDocuments: ['specification', 'architecture'], openChecks: [] } };
|
|
6597
|
+
let links: unknown[] = [];
|
|
6476
6598
|
const { client } = await bootMcpPair({
|
|
6477
6599
|
capabilities: ALL_CAPABILITIES,
|
|
6478
6600
|
routes: {
|
|
6479
6601
|
[`GET /v1/products/${PRODUCT_ID}`]: { data: draft },
|
|
6480
6602
|
[`PATCH /v1/products/${PRODUCT_ID}`]: { data: draft },
|
|
6603
|
+
[`GET /v1/products/${PRODUCT_ID}/setup`]: blockers,
|
|
6604
|
+
[`GET /v1/products/${PRODUCT_ID}/repositories`]: () => ({
|
|
6605
|
+
ok: true,
|
|
6606
|
+
status: 200,
|
|
6607
|
+
json: async () => ({ data: links, nextCursor: null }),
|
|
6608
|
+
text: async () => JSON.stringify({ data: links, nextCursor: null }),
|
|
6609
|
+
}),
|
|
6481
6610
|
},
|
|
6482
6611
|
});
|
|
6483
6612
|
|
|
6484
6613
|
const product = parseToolResult(
|
|
6485
6614
|
await client.callTool({ name: 'taiga_get_product', arguments: { productId: PRODUCT_ID } })
|
|
6486
6615
|
);
|
|
6487
|
-
expect(product.
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6616
|
+
expect(product.readiness).toEqual({
|
|
6617
|
+
ready: false,
|
|
6618
|
+
repository: { linked: false },
|
|
6619
|
+
import: 'not started',
|
|
6620
|
+
missingDocuments: ['Specification', 'Architecture'],
|
|
6621
|
+
openChecks: [],
|
|
6622
|
+
nextStep:
|
|
6623
|
+
'Link its repository (taiga_list_linkable_repositories, then taiga_link_repository), then taiga_start_import.',
|
|
6624
|
+
});
|
|
6625
|
+
|
|
6626
|
+
// Linked: the next move follows, which the step alone never said.
|
|
6627
|
+
links = [{ id: LINK_ID, repositoryName: 'acme/web', effectiveBranch: 'main' }];
|
|
6492
6628
|
const updated = parseToolResult(
|
|
6493
6629
|
await client.callTool({
|
|
6494
6630
|
name: 'taiga_update_product',
|
|
6495
6631
|
arguments: { productId: PRODUCT_ID, ownsInfrastructure: false },
|
|
6496
6632
|
})
|
|
6497
6633
|
);
|
|
6498
|
-
expect(updated.
|
|
6634
|
+
expect(updated.readiness.repository).toEqual({ linked: true, name: 'acme/web', branch: 'main' });
|
|
6635
|
+
expect(updated.readiness.nextStep).toMatch(/^taiga_start_import derives the documents from the code/);
|
|
6499
6636
|
expect(updated.warnings).toEqual([expect.stringContaining('has no deploymentNotes')]);
|
|
6500
6637
|
});
|
|
6501
6638
|
|
|
@@ -6510,16 +6647,56 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6510
6647
|
},
|
|
6511
6648
|
});
|
|
6512
6649
|
|
|
6650
|
+
// An API without the setup read (404 here): the step is what there is to go on.
|
|
6513
6651
|
const read = parseToolResult(
|
|
6514
6652
|
await client.callTool({ name: 'taiga_update_product', arguments: { productId: PRODUCT_ID, name: 'Web' } })
|
|
6515
6653
|
);
|
|
6516
|
-
// The product's read is unreachable
|
|
6654
|
+
// The product's read is unreachable too: no next step, not the conversation's.
|
|
6517
6655
|
const unread = parseToolResult(
|
|
6518
6656
|
await client.callTool({ name: 'taiga_update_product', arguments: { productId: PRODUCT_ID_2, name: 'Web' } })
|
|
6519
6657
|
);
|
|
6520
6658
|
|
|
6521
|
-
expect(read.
|
|
6522
|
-
expect(
|
|
6659
|
+
expect(read.readiness.ready).toBeNull();
|
|
6660
|
+
expect(read.readiness.nextStep).toMatch(/^The specification is done/);
|
|
6661
|
+
expect(unread.readiness.nextStep).toBeNull();
|
|
6662
|
+
expect(unread.readiness.repository).toBeNull();
|
|
6663
|
+
});
|
|
6664
|
+
|
|
6665
|
+
it('says what a scratch product still needs, from the Discovery conversation to the last document (PRO-2800)', async () => {
|
|
6666
|
+
const draft = {
|
|
6667
|
+
id: PRODUCT_ID,
|
|
6668
|
+
name: 'Web',
|
|
6669
|
+
slug: 'web',
|
|
6670
|
+
status: 'draft',
|
|
6671
|
+
factoryId: FACTORY_ID,
|
|
6672
|
+
discoveryStep: 'documents',
|
|
6673
|
+
settings: {},
|
|
6674
|
+
};
|
|
6675
|
+
let missing = ['specification', 'architecture'];
|
|
6676
|
+
const { client } = await bootMcpPair({
|
|
6677
|
+
capabilities: ALL_CAPABILITIES,
|
|
6678
|
+
routes: {
|
|
6679
|
+
[`GET /v1/products/${PRODUCT_ID}`]: { data: draft },
|
|
6680
|
+
[`GET /v1/products/${PRODUCT_ID}/repositories`]: { data: [], nextCursor: null },
|
|
6681
|
+
[`GET /v1/products/${PRODUCT_ID}/setup`]: () => {
|
|
6682
|
+
const body = { data: { inSetup: true, missingDocuments: missing, openChecks: [] } };
|
|
6683
|
+
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
|
|
6684
|
+
},
|
|
6685
|
+
},
|
|
6686
|
+
});
|
|
6687
|
+
const read = async () =>
|
|
6688
|
+
parseToolResult(await client.callTool({ name: 'taiga_get_product', arguments: { productId: PRODUCT_ID } }))
|
|
6689
|
+
.readiness;
|
|
6690
|
+
|
|
6691
|
+
expect((await read()).nextStep).toMatch(
|
|
6692
|
+
/^The specification comes from the Discovery conversation, held in the web/
|
|
6693
|
+
);
|
|
6694
|
+
missing = ['architecture'];
|
|
6695
|
+
expect((await read()).nextStep).toMatch(/^Still to publish: Architecture\. taiga_generate_product_documents/);
|
|
6696
|
+
missing = [];
|
|
6697
|
+
const done = await read();
|
|
6698
|
+
expect(done.ready).toBe(true);
|
|
6699
|
+
expect(done.nextStep).toMatch(/^Every required document is published: taiga_finish_discovery/);
|
|
6523
6700
|
});
|
|
6524
6701
|
|
|
6525
6702
|
it('says nothing of where a merged run stopped, whatever its stale status says', async () => {
|
|
@@ -6533,9 +6710,7 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6533
6710
|
},
|
|
6534
6711
|
});
|
|
6535
6712
|
|
|
6536
|
-
const result = parseToolResult(
|
|
6537
|
-
await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } })
|
|
6538
|
-
);
|
|
6713
|
+
const result = parseToolResult(await client.callTool({ name: 'taiga_get_run', arguments: { runId: BUILD_ID } }));
|
|
6539
6714
|
|
|
6540
6715
|
expect(result).not.toHaveProperty('stoppedIn');
|
|
6541
6716
|
});
|
|
@@ -6572,9 +6747,7 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6572
6747
|
},
|
|
6573
6748
|
});
|
|
6574
6749
|
|
|
6575
|
-
const result = parseToolResult(
|
|
6576
|
-
await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } })
|
|
6577
|
-
);
|
|
6750
|
+
const result = parseToolResult(await client.callTool({ name: 'taiga_get_run', arguments: { runId: BUILD_ID } }));
|
|
6578
6751
|
|
|
6579
6752
|
expect(result.stoppedIn).toMatch(/^after the last step, in hardening/);
|
|
6580
6753
|
});
|
|
@@ -6708,7 +6881,7 @@ describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
|
|
|
6708
6881
|
});
|
|
6709
6882
|
|
|
6710
6883
|
const item = parseToolResult(
|
|
6711
|
-
await client.callTool({ name: '
|
|
6884
|
+
await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
|
|
6712
6885
|
);
|
|
6713
6886
|
expect(item.planFailure).toEqual({
|
|
6714
6887
|
cause: 'cancelled',
|
|
@@ -6725,7 +6898,7 @@ describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
|
|
|
6725
6898
|
},
|
|
6726
6899
|
});
|
|
6727
6900
|
const bare = parseToolResult(
|
|
6728
|
-
await refused.client.callTool({ name: '
|
|
6901
|
+
await refused.client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
|
|
6729
6902
|
);
|
|
6730
6903
|
expect(bare).toEqual(failed);
|
|
6731
6904
|
|
|
@@ -6738,7 +6911,7 @@ describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
|
|
|
6738
6911
|
},
|
|
6739
6912
|
});
|
|
6740
6913
|
const staleItem = parseToolResult(
|
|
6741
|
-
await stale.client.callTool({ name: '
|
|
6914
|
+
await stale.client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
|
|
6742
6915
|
);
|
|
6743
6916
|
expect(staleItem.planFailure).toEqual({
|
|
6744
6917
|
cause: 'worker_lost',
|
|
@@ -6767,7 +6940,9 @@ describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
|
|
|
6767
6940
|
},
|
|
6768
6941
|
});
|
|
6769
6942
|
expect(
|
|
6770
|
-
parseToolResult(
|
|
6943
|
+
parseToolResult(
|
|
6944
|
+
await retried.client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
|
|
6945
|
+
)
|
|
6771
6946
|
).toEqual(failed);
|
|
6772
6947
|
|
|
6773
6948
|
// No failure in the history, or no history reachable: the item as it is.
|
|
@@ -6779,7 +6954,9 @@ describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
|
|
|
6779
6954
|
},
|
|
6780
6955
|
});
|
|
6781
6956
|
expect(
|
|
6782
|
-
parseToolResult(
|
|
6957
|
+
parseToolResult(
|
|
6958
|
+
await silent.client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
|
|
6959
|
+
)
|
|
6783
6960
|
).toEqual(failed);
|
|
6784
6961
|
const offline = await bootMcpPair({
|
|
6785
6962
|
capabilities: ALL_CAPABILITIES,
|
|
@@ -6790,7 +6967,9 @@ describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
|
|
|
6790
6967
|
},
|
|
6791
6968
|
});
|
|
6792
6969
|
expect(
|
|
6793
|
-
parseToolResult(
|
|
6970
|
+
parseToolResult(
|
|
6971
|
+
await offline.client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
|
|
6972
|
+
)
|
|
6794
6973
|
).toEqual(failed);
|
|
6795
6974
|
|
|
6796
6975
|
// A plan that did not fail reads no history.
|
|
@@ -6798,7 +6977,7 @@ describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
|
|
|
6798
6977
|
capabilities: ALL_CAPABILITIES,
|
|
6799
6978
|
routes: { [`GET /v1/backlog/${ITEM_ID}`]: { data: { ...failed, planningStatus: 'planned' } } },
|
|
6800
6979
|
});
|
|
6801
|
-
await planned.client.callTool({ name: '
|
|
6980
|
+
await planned.client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } });
|
|
6802
6981
|
expect(planned.fetchSpy.mock.calls.some((c: unknown[]) => String(c[0]).includes('/activity'))).toBe(false);
|
|
6803
6982
|
});
|
|
6804
6983
|
|
|
@@ -6865,7 +7044,7 @@ describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
|
|
|
6865
7044
|
expect(description, tool.name).toMatch(/^Acts on paid or shared work: ask the person first\. /);
|
|
6866
7045
|
else expect(description, tool.name).toMatch(/^Writes\. /);
|
|
6867
7046
|
}
|
|
6868
|
-
expect(tools.find((t) => t.name === '
|
|
7047
|
+
expect(tools.find((t) => t.name === 'taiga_queue_initiative')!.description).toMatch(/^Acts on paid/);
|
|
6869
7048
|
expect(tools.find((t) => t.name === 'taiga_create_factory')!.description).toMatch(/^Writes\. /);
|
|
6870
7049
|
});
|
|
6871
7050
|
|
|
@@ -6904,3 +7083,359 @@ describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
|
|
|
6904
7083
|
expect(standing.controls[0].findingsByTier).toEqual(tiers);
|
|
6905
7084
|
});
|
|
6906
7085
|
});
|
|
7086
|
+
|
|
7087
|
+
describe('what the third agent review found (PRO-2800)', () => {
|
|
7088
|
+
beforeEach(() => {
|
|
7089
|
+
vi.restoreAllMocks();
|
|
7090
|
+
});
|
|
7091
|
+
|
|
7092
|
+
it('loads the three entry tools upfront in Claude Code, and only those', async () => {
|
|
7093
|
+
const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
7094
|
+
|
|
7095
|
+
const { tools } = await client.listTools();
|
|
7096
|
+
const upfront = tools.filter((tool) => tool._meta?.['anthropic/alwaysLoad'] === true).map((tool) => tool.name);
|
|
7097
|
+
|
|
7098
|
+
expect(upfront.sort()).toEqual(['taiga_get_product_context', 'taiga_list_action_required', 'taiga_list_products']);
|
|
7099
|
+
});
|
|
7100
|
+
|
|
7101
|
+
it('opens Action Required with what is open by product and kind, rows without their metadata unless asked', async () => {
|
|
7102
|
+
const created = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString();
|
|
7103
|
+
const rows = [
|
|
7104
|
+
{
|
|
7105
|
+
id: 'i1',
|
|
7106
|
+
productId: PRODUCT_ID,
|
|
7107
|
+
productName: 'Web',
|
|
7108
|
+
kind: 'review_pr',
|
|
7109
|
+
status: 'open',
|
|
7110
|
+
title: 'Review & merge PR #9',
|
|
7111
|
+
createdAt: created,
|
|
7112
|
+
metadata: { prUrl: 'https://github.com/acme/web/pull/9', prNumber: 9 },
|
|
7113
|
+
},
|
|
7114
|
+
{
|
|
7115
|
+
id: 'i2',
|
|
7116
|
+
productId: PRODUCT_ID,
|
|
7117
|
+
productName: 'Web',
|
|
7118
|
+
kind: 'review_doc',
|
|
7119
|
+
status: 'open',
|
|
7120
|
+
createdAt: created,
|
|
7121
|
+
metadata: { decision: { detail: 'x'.repeat(2_000) } },
|
|
7122
|
+
},
|
|
7123
|
+
{ id: 'i3', productId: PRODUCT_ID_2, productName: 'Api', kind: 'review_pr', status: 'open', createdAt: created },
|
|
7124
|
+
];
|
|
7125
|
+
// The API pages: a short page says there is more, so the summary reads up to 100 itself.
|
|
7126
|
+
const inbox = (url: string) => {
|
|
7127
|
+
const limit = Number(new URL(url).searchParams.get('limit') ?? 25);
|
|
7128
|
+
const body = { data: rows.slice(0, limit), nextCursor: limit < rows.length ? 'more' : null, totalCount: 3 };
|
|
7129
|
+
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
|
|
7130
|
+
};
|
|
7131
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
7132
|
+
capabilities: ['products:read'],
|
|
7133
|
+
routes: { 'GET /v1/inbox': inbox },
|
|
7134
|
+
});
|
|
7135
|
+
|
|
7136
|
+
const list = parseToolResult(
|
|
7137
|
+
await client.callTool({ name: 'taiga_list_action_required', arguments: { limit: 2 } })
|
|
7138
|
+
);
|
|
7139
|
+
const full = parseToolResult(
|
|
7140
|
+
await client.callTool({ name: 'taiga_list_action_required', arguments: { full: true } })
|
|
7141
|
+
);
|
|
7142
|
+
|
|
7143
|
+
expect(list.summary).toEqual([
|
|
7144
|
+
{ productId: PRODUCT_ID, product: 'Web', open: 2, byKind: { review_pr: 1, review_doc: 1 } },
|
|
7145
|
+
{ productId: PRODUCT_ID_2, product: 'Api', open: 1, byKind: { review_pr: 1 } },
|
|
7146
|
+
]);
|
|
7147
|
+
expect(list.totalCount).toBe(3);
|
|
7148
|
+
expect(list.data[0]).toMatchObject({ id: 'i1', prUrl: 'https://github.com/acme/web/pull/9', waitingDays: 3 });
|
|
7149
|
+
expect(JSON.stringify(list.data)).not.toContain('metadata');
|
|
7150
|
+
expect(full.data[1].metadata).toEqual(rows[1]!.metadata);
|
|
7151
|
+
// The page asked for and the summary's own read, nothing on a later page.
|
|
7152
|
+
const limits = fetchSpy.mock.calls
|
|
7153
|
+
.map((call: unknown[]) => new URL(call[0] as string))
|
|
7154
|
+
.filter((url: URL) => url.pathname === '/v1/inbox')
|
|
7155
|
+
.map((url: URL) => url.searchParams.get('limit'));
|
|
7156
|
+
// The short page and the summary's read; the full page held everything, so it read once.
|
|
7157
|
+
expect(limits).toEqual(['2', '100', null]);
|
|
7158
|
+
});
|
|
7159
|
+
|
|
7160
|
+
it("counts a resolved item's wait up to its resolution", async () => {
|
|
7161
|
+
const day = 24 * 60 * 60 * 1000;
|
|
7162
|
+
const { client } = await bootMcpPair({
|
|
7163
|
+
capabilities: ['products:read'],
|
|
7164
|
+
routes: {
|
|
7165
|
+
'GET /v1/inbox': {
|
|
7166
|
+
data: [
|
|
7167
|
+
{
|
|
7168
|
+
id: 'r1',
|
|
7169
|
+
productId: PRODUCT_ID,
|
|
7170
|
+
kind: 'review_pr',
|
|
7171
|
+
status: 'done',
|
|
7172
|
+
createdAt: new Date(Date.now() - 40 * day).toISOString(),
|
|
7173
|
+
resolvedAt: new Date(Date.now() - 38 * day).toISOString(),
|
|
7174
|
+
},
|
|
7175
|
+
],
|
|
7176
|
+
nextCursor: null,
|
|
7177
|
+
},
|
|
7178
|
+
},
|
|
7179
|
+
});
|
|
7180
|
+
|
|
7181
|
+
const archive = parseToolResult(
|
|
7182
|
+
await client.callTool({ name: 'taiga_list_action_required', arguments: { resolved: true } })
|
|
7183
|
+
);
|
|
7184
|
+
|
|
7185
|
+
expect(archive.data[0].waitingDays).toBe(2);
|
|
7186
|
+
});
|
|
7187
|
+
|
|
7188
|
+
it('lists a variable filed under two categories once, naming the other record', async () => {
|
|
7189
|
+
const task = (id: string, category: string, key: string | null) => ({
|
|
7190
|
+
id,
|
|
7191
|
+
productId: PRODUCT_ID,
|
|
7192
|
+
category,
|
|
7193
|
+
key,
|
|
7194
|
+
title: `Set ${key ?? 'something'}`,
|
|
7195
|
+
});
|
|
7196
|
+
const { client } = await bootMcpPair({
|
|
7197
|
+
capabilities: ['products:read'],
|
|
7198
|
+
routes: {
|
|
7199
|
+
'GET /v1/setup-tasks': {
|
|
7200
|
+
data: [
|
|
7201
|
+
task('t1', 'secret', 'JWT_SECRET'),
|
|
7202
|
+
task('t2', 'manual_config', null),
|
|
7203
|
+
task('t3', 'env_var', 'JWT_SECRET'),
|
|
7204
|
+
],
|
|
7205
|
+
nextCursor: null,
|
|
7206
|
+
},
|
|
7207
|
+
},
|
|
7208
|
+
});
|
|
7209
|
+
|
|
7210
|
+
const tasks = parseToolResult(await client.callTool({ name: 'taiga_list_setup_tasks', arguments: {} }));
|
|
7211
|
+
|
|
7212
|
+
expect(tasks.data.map((row: { id: string }) => row.id)).toEqual(['t1', 't2']);
|
|
7213
|
+
expect(tasks.data[0].alsoFiledAs).toEqual([{ id: 't3', category: 'env_var', title: 'Set JWT_SECRET' }]);
|
|
7214
|
+
});
|
|
7215
|
+
|
|
7216
|
+
it('leads a variable with its open record when an older one is done', async () => {
|
|
7217
|
+
const { client } = await bootMcpPair({
|
|
7218
|
+
capabilities: ['products:read'],
|
|
7219
|
+
routes: {
|
|
7220
|
+
'GET /v1/setup-tasks': {
|
|
7221
|
+
data: [
|
|
7222
|
+
{ id: 't1', productId: PRODUCT_ID, category: 'secret', key: 'K', title: 'Old', inboxItemStatus: 'done' },
|
|
7223
|
+
{ id: 't2', productId: PRODUCT_ID, category: 'env_var', key: 'K', title: 'New', inboxItemStatus: 'open' },
|
|
7224
|
+
],
|
|
7225
|
+
nextCursor: null,
|
|
7226
|
+
},
|
|
7227
|
+
},
|
|
7228
|
+
});
|
|
7229
|
+
|
|
7230
|
+
const tasks = parseToolResult(
|
|
7231
|
+
await client.callTool({ name: 'taiga_list_setup_tasks', arguments: { includeDone: true } })
|
|
7232
|
+
);
|
|
7233
|
+
|
|
7234
|
+
expect(tasks.data).toHaveLength(1);
|
|
7235
|
+
expect(tasks.data[0]).toMatchObject({ id: 't2', inboxItemStatus: 'open' });
|
|
7236
|
+
expect(tasks.data[0].alsoFiledAs).toEqual([
|
|
7237
|
+
{ id: 't1', category: 'secret', title: 'Old', inboxItemStatus: 'done' },
|
|
7238
|
+
]);
|
|
7239
|
+
});
|
|
7240
|
+
|
|
7241
|
+
it("names a finding's sweep sweepId, so runId stays what taiga_get_run takes", async () => {
|
|
7242
|
+
const FINDING = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4d01';
|
|
7243
|
+
const { client } = await bootMcpPair({
|
|
7244
|
+
capabilities: ['products:read'],
|
|
7245
|
+
routes: {
|
|
7246
|
+
[`GET /v1/maintenance/findings/${FINDING}`]: {
|
|
7247
|
+
data: { id: FINDING, runId: 'sweep-1', remediation: { buildRunId: BUILD_ID } },
|
|
7248
|
+
},
|
|
7249
|
+
},
|
|
7250
|
+
});
|
|
7251
|
+
|
|
7252
|
+
const finding = parseToolResult(
|
|
7253
|
+
await client.callTool({ name: 'taiga_get_finding', arguments: { findingId: FINDING } })
|
|
7254
|
+
);
|
|
7255
|
+
|
|
7256
|
+
expect(finding).toEqual({ sweepId: 'sweep-1', id: FINDING, remediation: { runId: BUILD_ID } });
|
|
7257
|
+
});
|
|
7258
|
+
|
|
7259
|
+
it('says how each run stands in a sentence, and says nothing of a draft where there is no pull request', async () => {
|
|
7260
|
+
const base = { productId: PRODUCT_ID, completedSteps: 6, totalSteps: 6, prDraft: true };
|
|
7261
|
+
const { client } = await bootMcpPair({
|
|
7262
|
+
capabilities: ['products:read'],
|
|
7263
|
+
routes: {
|
|
7264
|
+
[`/v1/products/${PRODUCT_ID}/builds`]: {
|
|
7265
|
+
data: [
|
|
7266
|
+
{ ...base, id: 'r1', status: 'closed', prNumber: 28, prUrl: 'https://x/pull/28', prDraft: false },
|
|
7267
|
+
{
|
|
7268
|
+
...base,
|
|
7269
|
+
id: 'r2',
|
|
7270
|
+
status: 'completed',
|
|
7271
|
+
prMerged: true,
|
|
7272
|
+
prNumber: 7,
|
|
7273
|
+
prUrl: 'https://x/7',
|
|
7274
|
+
baseBranch: 'main',
|
|
7275
|
+
},
|
|
7276
|
+
{ ...base, id: 'r3', status: 'failed', failedReason: 'agent_error', completedSteps: 2, prUrl: null },
|
|
7277
|
+
{ ...base, id: 'r4', status: 'needs_review', prNumber: 9, prUrl: null },
|
|
7278
|
+
{ ...base, id: 'r5', status: 'hardening', prUrl: null },
|
|
7279
|
+
// Merged, but the completion write after the merge failed: the latch decides.
|
|
7280
|
+
{
|
|
7281
|
+
...base,
|
|
7282
|
+
id: 'r6',
|
|
7283
|
+
status: 'failed',
|
|
7284
|
+
prMerged: true,
|
|
7285
|
+
prNumber: 8,
|
|
7286
|
+
prUrl: 'https://x/8',
|
|
7287
|
+
baseBranch: 'main',
|
|
7288
|
+
},
|
|
7289
|
+
],
|
|
7290
|
+
nextCursor: null,
|
|
7291
|
+
},
|
|
7292
|
+
},
|
|
7293
|
+
});
|
|
7294
|
+
|
|
7295
|
+
const runs = parseToolResult(
|
|
7296
|
+
await client.callTool({ name: 'taiga_list_runs', arguments: { productId: PRODUCT_ID } })
|
|
7297
|
+
).data as Record<string, unknown>[];
|
|
7298
|
+
|
|
7299
|
+
expect(runs.map((run) => [run['disposition'], run['outcome']])).toEqual([
|
|
7300
|
+
['rejected', 'PR #28 was closed without merging, so the work did not land.'],
|
|
7301
|
+
['succeeded', 'Merged: PR #7 is in main.'],
|
|
7302
|
+
['failed', 'Failed: agent_error (2 of 6 steps done).'],
|
|
7303
|
+
['gated', 'Waiting for a person to review and merge PR #9.'],
|
|
7304
|
+
['in_flight', 'Running: hardening: running the security scanners and fixing what they find (6 of 6 steps done).'],
|
|
7305
|
+
['succeeded', 'Merged: PR #8 is in main.'],
|
|
7306
|
+
]);
|
|
7307
|
+
expect(runs[0]).toHaveProperty('prDraft', false);
|
|
7308
|
+
expect(runs[2]).not.toHaveProperty('prDraft');
|
|
7309
|
+
// A recorded PR number with no URL still has a pull request, and its draft state.
|
|
7310
|
+
expect(runs[3]).toHaveProperty('prDraft', true);
|
|
7311
|
+
});
|
|
7312
|
+
|
|
7313
|
+
it('says where an initiative stands and the call that moves it on, in one sentence', async () => {
|
|
7314
|
+
const item = {
|
|
7315
|
+
id: ITEM_ID,
|
|
7316
|
+
title: 'Add login',
|
|
7317
|
+
status: 'in_progress',
|
|
7318
|
+
state: 'build',
|
|
7319
|
+
needsHumanCause: null,
|
|
7320
|
+
lifecycle: {
|
|
7321
|
+
turn: 'you',
|
|
7322
|
+
stage: 'building',
|
|
7323
|
+
cta: 'retry_build',
|
|
7324
|
+
planFailureCause: null,
|
|
7325
|
+
latestBuild: { status: 'closed', prNumber: 28 },
|
|
7326
|
+
},
|
|
7327
|
+
};
|
|
7328
|
+
const { client } = await bootMcpPair({
|
|
7329
|
+
capabilities: ['backlog:read'],
|
|
7330
|
+
routes: {
|
|
7331
|
+
[`GET /v1/backlog/${ITEM_ID}`]: { data: item },
|
|
7332
|
+
[`/v1/products/${PRODUCT_ID}/backlog`]: { data: [item], nextCursor: null },
|
|
7333
|
+
},
|
|
7334
|
+
});
|
|
7335
|
+
|
|
7336
|
+
const got = parseToolResult(
|
|
7337
|
+
await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
|
|
7338
|
+
);
|
|
7339
|
+
const listed = parseToolResult(
|
|
7340
|
+
await client.callTool({ name: 'taiga_list_initiatives', arguments: { productId: PRODUCT_ID } })
|
|
7341
|
+
);
|
|
7342
|
+
|
|
7343
|
+
const said =
|
|
7344
|
+
"Being built; its latest run had its pull request closed without merging (PR #28). A person's turn: a new " +
|
|
7345
|
+
'run starts over from the web; taiga_move_initiative takes it off the line.';
|
|
7346
|
+
expect(got.situation).toBe(said);
|
|
7347
|
+
expect(listed.data[0].situation).toBe(said);
|
|
7348
|
+
});
|
|
7349
|
+
|
|
7350
|
+
it("keeps the run's own reason beside the halt that stopped the line", async () => {
|
|
7351
|
+
const item = {
|
|
7352
|
+
id: ITEM_ID,
|
|
7353
|
+
title: 'Add login',
|
|
7354
|
+
needsHumanCause: 'pr_closed',
|
|
7355
|
+
needsHumanDetail: 'The pull request was closed by alice.',
|
|
7356
|
+
lifecycle: { turn: 'you', stage: 'building', cta: 'retry_build', planFailureCause: null, latestBuild: null },
|
|
7357
|
+
};
|
|
7358
|
+
const { client } = await bootMcpPair({
|
|
7359
|
+
capabilities: ['backlog:read'],
|
|
7360
|
+
routes: { [`GET /v1/backlog/${ITEM_ID}`]: { data: item } },
|
|
7361
|
+
});
|
|
7362
|
+
|
|
7363
|
+
const got = parseToolResult(
|
|
7364
|
+
await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
|
|
7365
|
+
);
|
|
7366
|
+
|
|
7367
|
+
expect(got.situation).toContain('The pull request was closed by alice.');
|
|
7368
|
+
expect(got.situation).toMatch(/^Being built\. The line stopped on it: .+\. The pull request was closed by alice\./);
|
|
7369
|
+
});
|
|
7370
|
+
|
|
7371
|
+
it("leaves the keys of a document's sections as their writers named them", async () => {
|
|
7372
|
+
const { client } = await bootMcpPair({
|
|
7373
|
+
capabilities: ALL_CAPABILITIES,
|
|
7374
|
+
routes: {
|
|
7375
|
+
'GET /v1/tenants/current/documents/policy_security': {
|
|
7376
|
+
data: {
|
|
7377
|
+
id: 'doc-1',
|
|
7378
|
+
type: 'policy_security',
|
|
7379
|
+
version: 1,
|
|
7380
|
+
isDraft: false,
|
|
7381
|
+
data: { version: '1.0.0', sections: { backlogItemId: { content: 'a section by that name' } } },
|
|
7382
|
+
},
|
|
7383
|
+
},
|
|
7384
|
+
},
|
|
7385
|
+
});
|
|
7386
|
+
|
|
7387
|
+
const doc = parseToolResult(
|
|
7388
|
+
await client.callTool({ name: 'taiga_get_document', arguments: { type: 'policy_security' } })
|
|
7389
|
+
);
|
|
7390
|
+
|
|
7391
|
+
expect(Object.keys(doc.data.sections)).toEqual(['backlogItemId']);
|
|
7392
|
+
});
|
|
7393
|
+
|
|
7394
|
+
it("names IDs in refusal details in the product's words", async () => {
|
|
7395
|
+
const { client } = await bootMcpPair({
|
|
7396
|
+
capabilities: ALL_CAPABILITIES,
|
|
7397
|
+
routes: {
|
|
7398
|
+
[`POST /v1/builds/${BUILD_ID}/stop`]: refusal(409, 'CONFLICT', 'Not running', { buildRunId: BUILD_ID }),
|
|
7399
|
+
},
|
|
7400
|
+
});
|
|
7401
|
+
|
|
7402
|
+
const result = await client.callTool({ name: 'taiga_stop_run', arguments: { runId: BUILD_ID } });
|
|
7403
|
+
|
|
7404
|
+
expect(parseToolResult(result).details).toEqual({ runId: BUILD_ID });
|
|
7405
|
+
});
|
|
7406
|
+
|
|
7407
|
+
it('leaves stopping the line to the person when a move meets work in flight', async () => {
|
|
7408
|
+
const { client } = await bootMcpPair({
|
|
7409
|
+
capabilities: ALL_CAPABILITIES,
|
|
7410
|
+
routes: {
|
|
7411
|
+
[`PUT /v1/products/${PRODUCT_ID}/factory`]: refusal(
|
|
7412
|
+
409,
|
|
7413
|
+
'WORK_IN_FLIGHT',
|
|
7414
|
+
'This product is planning right now. Stop the line, then move the product.'
|
|
7415
|
+
),
|
|
7416
|
+
},
|
|
7417
|
+
});
|
|
7418
|
+
|
|
7419
|
+
const result = await client.callTool({
|
|
7420
|
+
name: 'taiga_move_product',
|
|
7421
|
+
arguments: { productId: PRODUCT_ID, factoryId: FACTORY_ID },
|
|
7422
|
+
});
|
|
7423
|
+
|
|
7424
|
+
expect(result.isError).toBe(true);
|
|
7425
|
+
const said = parseToolResult(result);
|
|
7426
|
+
expect(said.error).toBe('WORK_IN_FLIGHT');
|
|
7427
|
+
expect(said.message).toMatch(/^This product is planning right now\. Moving it now means stopping the line/);
|
|
7428
|
+
expect(said.message).toMatch(/ask the person, and pass stopLine: true only if they say so\.$/);
|
|
7429
|
+
expect(said.message).not.toContain('Stop the line, then');
|
|
7430
|
+
});
|
|
7431
|
+
|
|
7432
|
+
it('fits the server instructions inside what Claude Code keeps', async () => {
|
|
7433
|
+
const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
7434
|
+
|
|
7435
|
+
const instructions = client.getInstructions() ?? '';
|
|
7436
|
+
|
|
7437
|
+
expect(instructions.length).toBeGreaterThan(500);
|
|
7438
|
+
expect(instructions.length).toBeLessThanOrEqual(2048);
|
|
7439
|
+
expect(instructions).toContain('taiga_get_product_context');
|
|
7440
|
+
});
|
|
7441
|
+
});
|