@azure-devops/mcp 2.8.1 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -7
- package/dist/shared/command.js +34 -0
- package/dist/tools/advanced-security.js +31 -11
- package/dist/tools/pipelines.dto.js +95 -0
- package/dist/tools/pipelines.js +350 -373
- package/dist/tools/repositories.js +695 -1485
- package/dist/tools/test-plans.js +297 -396
- package/dist/tools/wiki.js +242 -273
- package/dist/tools/work-items.js +851 -1174
- package/dist/tools/work.js +239 -285
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/tools/test-plans.js
CHANGED
|
@@ -2,35 +2,29 @@
|
|
|
2
2
|
// Licensed under the MIT License.
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { apiVersion } from "../utils.js";
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
create_test_case: "testplan_create_test_case",
|
|
8
|
-
update_test_case_steps: "testplan_update_test_case_steps",
|
|
9
|
-
add_test_cases_to_suite: "testplan_add_test_cases_to_suite",
|
|
5
|
+
const TEST_PLAN_TOOLS = {
|
|
6
|
+
testplan: "testplan",
|
|
10
7
|
test_results_from_build_id: "testplan_show_test_results_from_build_id",
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
create_test_suite: "testplan_create_test_suite",
|
|
8
|
+
testplan_test_plan_write: "testplan_test_plan_write",
|
|
9
|
+
testplan_test_suite_write: "testplan_test_suite_write",
|
|
10
|
+
testplan_test_case_write: "testplan_test_case_write",
|
|
15
11
|
};
|
|
16
12
|
function configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider) {
|
|
17
|
-
|
|
13
|
+
// ─── testplan (read-only) ────────────────────────────────────────────
|
|
14
|
+
server.tool(TEST_PLAN_TOOLS.testplan, "Retrieve paginated test plan, suite, and case data for a project. Use the action parameter to specify the operation. When a response includes a continuationToken, pass it back with the same action and query parameters to fetch the next batch; null token indicates the last batch.", {
|
|
15
|
+
action: z
|
|
16
|
+
.enum(["list_plans", "list_suites", "list_cases"])
|
|
17
|
+
.describe("The action to perform. Options: list_plans (list test plans in a project), list_suites (list test suites under a test plan), list_cases (list test cases under a test suite)."),
|
|
18
18
|
project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
|
|
19
|
-
filterActivePlans: z.boolean().default(true).describe("Filter to include only active test plans. Defaults to true."),
|
|
20
|
-
includePlanDetails: z.boolean().default(false).describe("Include detailed information about each test plan."),
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
filterActivePlans: z.boolean().default(true).describe("Filter to include only active test plans. Used for: list_plans. Defaults to true."),
|
|
20
|
+
includePlanDetails: z.boolean().default(false).describe("Include detailed information about each test plan. Used for: list_plans."),
|
|
21
|
+
planId: z.coerce.number().min(1).optional().describe("The ID of the test plan. Required for: list_suites, list_cases."),
|
|
22
|
+
suiteId: z.coerce.number().min(1).optional().describe("The ID of the test suite. Required for: list_cases."),
|
|
23
|
+
continuationToken: z.string().optional().describe("Token to continue fetching results from a previous request. Used for: list_plans, list_suites, list_cases."),
|
|
24
|
+
}, async ({ action, project, filterActivePlans, includePlanDetails, planId, suiteId, continuationToken }) => {
|
|
23
25
|
try {
|
|
24
26
|
const connection = await connectionProvider();
|
|
25
27
|
const accessToken = await tokenProvider();
|
|
26
|
-
const params = new URLSearchParams({ "api-version": apiVersion });
|
|
27
|
-
if (filterActivePlans)
|
|
28
|
-
params.append("filterActivePlans", "true");
|
|
29
|
-
if (includePlanDetails)
|
|
30
|
-
params.append("includePlanDetails", "true");
|
|
31
|
-
if (continuationToken)
|
|
32
|
-
params.append("continuationToken", continuationToken);
|
|
33
|
-
const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans?${params.toString()}`;
|
|
34
28
|
const headers = {
|
|
35
29
|
Authorization: `Bearer ${accessToken}`,
|
|
36
30
|
};
|
|
@@ -38,302 +32,109 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
|
|
|
38
32
|
if (userAgent) {
|
|
39
33
|
headers["User-Agent"] = userAgent;
|
|
40
34
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
};
|
|
55
|
-
if (nextToken) {
|
|
56
|
-
result.continuationToken = nextToken;
|
|
57
|
-
}
|
|
58
|
-
return {
|
|
59
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
catch (error) {
|
|
63
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
64
|
-
return {
|
|
65
|
-
content: [{ type: "text", text: `Error listing test plans: ${errorMessage}` }],
|
|
66
|
-
isError: true,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
server.tool(Test_Plan_Tools.create_test_plan, "Creates a new test plan in the project.", {
|
|
71
|
-
project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project where the test plan will be created."),
|
|
72
|
-
name: z.string().describe("The name of the test plan to be created."),
|
|
73
|
-
iteration: z.string().describe("The iteration path for the test plan"),
|
|
74
|
-
description: z.string().optional().describe("The description of the test plan"),
|
|
75
|
-
startDate: z.string().optional().describe("The start date of the test plan"),
|
|
76
|
-
endDate: z.string().optional().describe("The end date of the test plan"),
|
|
77
|
-
areaPath: z.string().optional().describe("The area path for the test plan"),
|
|
78
|
-
}, async ({ project, name, iteration, description, startDate, endDate, areaPath }) => {
|
|
79
|
-
try {
|
|
80
|
-
const connection = await connectionProvider();
|
|
81
|
-
const testPlanApi = await connection.getTestPlanApi();
|
|
82
|
-
const testPlanToCreate = {
|
|
83
|
-
name,
|
|
84
|
-
iteration,
|
|
85
|
-
description,
|
|
86
|
-
startDate: startDate ? new Date(startDate) : undefined,
|
|
87
|
-
endDate: endDate ? new Date(endDate) : undefined,
|
|
88
|
-
areaPath,
|
|
89
|
-
};
|
|
90
|
-
const createdTestPlan = await testPlanApi.createTestPlan(testPlanToCreate, project);
|
|
91
|
-
return {
|
|
92
|
-
content: [{ type: "text", text: JSON.stringify(createdTestPlan, null, 2) }],
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
catch (error) {
|
|
96
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
97
|
-
return {
|
|
98
|
-
content: [{ type: "text", text: `Error creating test plan: ${errorMessage}` }],
|
|
99
|
-
isError: true,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
server.tool(Test_Plan_Tools.create_test_suite, "Creates a new test suite in a test plan.", {
|
|
104
|
-
project: z.string().describe("Project ID or project name"),
|
|
105
|
-
planId: z.coerce.number().min(1).describe("ID of the test plan that contains the suites"),
|
|
106
|
-
parentSuiteId: z.coerce.number().min(1).describe("ID of the parent suite under which the new suite will be created, if not given by user this can be id of a root suite of the test plan"),
|
|
107
|
-
name: z.string().describe("Name of the child test suite"),
|
|
108
|
-
}, async ({ project, planId, parentSuiteId, name }) => {
|
|
109
|
-
const maxRetries = 5;
|
|
110
|
-
const baseDelay = 500; // milliseconds
|
|
111
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
112
|
-
try {
|
|
113
|
-
const connection = await connectionProvider();
|
|
114
|
-
const testPlanApi = await connection.getTestPlanApi();
|
|
115
|
-
const testSuiteToCreate = {
|
|
116
|
-
name,
|
|
117
|
-
parentSuite: {
|
|
118
|
-
id: parentSuiteId,
|
|
119
|
-
name: "",
|
|
120
|
-
},
|
|
121
|
-
suiteType: 2,
|
|
122
|
-
};
|
|
123
|
-
const createdTestSuite = await testPlanApi.createTestSuite(testSuiteToCreate, project, planId);
|
|
124
|
-
return {
|
|
125
|
-
content: [{ type: "text", text: JSON.stringify(createdTestSuite, null, 2) }],
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
catch (error) {
|
|
129
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
130
|
-
// Check if it's a concurrency conflict error
|
|
131
|
-
const isConcurrencyError = errorMessage.includes("TF26071") || errorMessage.includes("got update") || errorMessage.includes("changed by someone else");
|
|
132
|
-
// If it's a concurrency error and we have retries left, wait and retry
|
|
133
|
-
if (isConcurrencyError && attempt < maxRetries) {
|
|
134
|
-
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 200; // Exponential backoff with jitter
|
|
135
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
136
|
-
continue; // Retry
|
|
35
|
+
if (action === "list_plans") {
|
|
36
|
+
const params = new URLSearchParams({ "api-version": apiVersion });
|
|
37
|
+
if (filterActivePlans)
|
|
38
|
+
params.append("filterActivePlans", "true");
|
|
39
|
+
if (includePlanDetails)
|
|
40
|
+
params.append("includePlanDetails", "true");
|
|
41
|
+
if (continuationToken)
|
|
42
|
+
params.append("continuationToken", continuationToken);
|
|
43
|
+
const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans?${params.toString()}`;
|
|
44
|
+
const response = await fetch(url, { method: "GET", headers });
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
const errorText = await response.text();
|
|
47
|
+
throw new Error(`Failed to list test plans (${response.status}): ${errorText}`);
|
|
137
48
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
49
|
+
const body = await response.json();
|
|
50
|
+
const testPlans = body.value ?? [];
|
|
51
|
+
const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
|
|
52
|
+
const result = { testPlans };
|
|
53
|
+
if (nextToken)
|
|
54
|
+
result.continuationToken = nextToken;
|
|
55
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
143
56
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
catch (error) {
|
|
168
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
169
|
-
return {
|
|
170
|
-
content: [{ type: "text", text: `Error adding test cases to suite: ${errorMessage}` }],
|
|
171
|
-
isError: true,
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
});
|
|
175
|
-
server.tool(Test_Plan_Tools.create_test_case, "Creates a new test case work item.", {
|
|
176
|
-
project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
|
|
177
|
-
title: z.string().describe("The title of the test case."),
|
|
178
|
-
steps: z
|
|
179
|
-
.string()
|
|
180
|
-
.optional()
|
|
181
|
-
.describe("The steps to reproduce the test case. Make sure to format each step as '1. Step one|Expected result one\n2. Step two|Expected result two. USE '|' as the delimiter between step and expected result. DO NOT use '|' in the description of the step or expected result."),
|
|
182
|
-
priority: z.coerce.number().optional().describe("The priority of the test case."),
|
|
183
|
-
areaPath: z.string().optional().describe("The area path for the test case."),
|
|
184
|
-
iterationPath: z.string().optional().describe("The iteration path for the test case."),
|
|
185
|
-
testsWorkItemId: z.coerce.number().min(1).optional().describe("Optional work item id that will be set as a Microsoft.VSTS.Common.TestedBy-Reverse link to the test case."),
|
|
186
|
-
}, async ({ project, title, steps, priority, areaPath, iterationPath, testsWorkItemId }) => {
|
|
187
|
-
try {
|
|
188
|
-
const connection = await connectionProvider();
|
|
189
|
-
const witClient = await connection.getWorkItemTrackingApi();
|
|
190
|
-
let stepsXml;
|
|
191
|
-
if (steps) {
|
|
192
|
-
stepsXml = convertStepsToXml(steps);
|
|
193
|
-
}
|
|
194
|
-
// Create JSON patch document for work item
|
|
195
|
-
const patchDocument = [];
|
|
196
|
-
patchDocument.push({
|
|
197
|
-
op: "add",
|
|
198
|
-
path: "/fields/System.Title",
|
|
199
|
-
value: title,
|
|
200
|
-
});
|
|
201
|
-
if (testsWorkItemId) {
|
|
202
|
-
patchDocument.push({
|
|
203
|
-
op: "add",
|
|
204
|
-
path: "/relations/-",
|
|
205
|
-
value: {
|
|
206
|
-
rel: "Microsoft.VSTS.Common.TestedBy-Reverse",
|
|
207
|
-
url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${testsWorkItemId}`,
|
|
208
|
-
},
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
if (stepsXml) {
|
|
212
|
-
patchDocument.push({
|
|
213
|
-
op: "add",
|
|
214
|
-
path: "/fields/Microsoft.VSTS.TCM.Steps",
|
|
215
|
-
value: stepsXml,
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
if (priority) {
|
|
219
|
-
patchDocument.push({
|
|
220
|
-
op: "add",
|
|
221
|
-
path: "/fields/Microsoft.VSTS.Common.Priority",
|
|
222
|
-
value: priority,
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
if (areaPath) {
|
|
226
|
-
patchDocument.push({
|
|
227
|
-
op: "add",
|
|
228
|
-
path: "/fields/System.AreaPath",
|
|
229
|
-
value: areaPath,
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
if (iterationPath) {
|
|
233
|
-
patchDocument.push({
|
|
234
|
-
op: "add",
|
|
235
|
-
path: "/fields/System.IterationPath",
|
|
236
|
-
value: iterationPath,
|
|
57
|
+
else if (action === "list_suites") {
|
|
58
|
+
if (!planId)
|
|
59
|
+
return { content: [{ type: "text", text: "planId is required for list_suites" }], isError: true };
|
|
60
|
+
const params = new URLSearchParams({ "api-version": apiVersion, "expand": "children" });
|
|
61
|
+
if (continuationToken)
|
|
62
|
+
params.append("continuationToken", continuationToken);
|
|
63
|
+
const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planId}/Suites?${params.toString()}`;
|
|
64
|
+
const response = await fetch(url, { method: "GET", headers });
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
const errorText = await response.text();
|
|
67
|
+
throw new Error(`Failed to list test suites (${response.status}): ${errorText}`);
|
|
68
|
+
}
|
|
69
|
+
const body = await response.json();
|
|
70
|
+
const testSuites = body.value ?? [];
|
|
71
|
+
const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
|
|
72
|
+
const suiteMap = new Map();
|
|
73
|
+
testSuites.forEach((suite) => {
|
|
74
|
+
suiteMap.set(suite.id, {
|
|
75
|
+
id: suite.id,
|
|
76
|
+
name: suite.name,
|
|
77
|
+
parentSuiteId: suite.parentSuite?.id,
|
|
78
|
+
children: [],
|
|
79
|
+
});
|
|
237
80
|
});
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return {
|
|
247
|
-
content: [{ type: "text", text: `Error creating test case: ${errorMessage}` }],
|
|
248
|
-
isError: true,
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
});
|
|
252
|
-
server.tool(Test_Plan_Tools.update_test_case_steps, "Update an existing test case work item.", {
|
|
253
|
-
id: z.coerce.number().min(1).describe("The ID of the test case work item to update."),
|
|
254
|
-
steps: z
|
|
255
|
-
.string()
|
|
256
|
-
.describe("The steps to reproduce the test case. Make sure to format each step as '1. Step one|Expected result one\n2. Step two|Expected result two. USE '|' as the delimiter between step and expected result. DO NOT use '|' in the description of the step or expected result."),
|
|
257
|
-
}, async ({ id, steps }) => {
|
|
258
|
-
try {
|
|
259
|
-
const connection = await connectionProvider();
|
|
260
|
-
const witClient = await connection.getWorkItemTrackingApi();
|
|
261
|
-
let stepsXml;
|
|
262
|
-
if (steps) {
|
|
263
|
-
stepsXml = convertStepsToXml(steps);
|
|
264
|
-
}
|
|
265
|
-
// Create JSON patch document for work item
|
|
266
|
-
const patchDocument = [];
|
|
267
|
-
if (stepsXml) {
|
|
268
|
-
patchDocument.push({
|
|
269
|
-
op: "add",
|
|
270
|
-
path: "/fields/Microsoft.VSTS.TCM.Steps",
|
|
271
|
-
value: stepsXml,
|
|
81
|
+
const roots = [];
|
|
82
|
+
suiteMap.forEach((suite) => {
|
|
83
|
+
if (suite.parentSuiteId && suiteMap.has(suite.parentSuiteId)) {
|
|
84
|
+
suiteMap.get(suite.parentSuiteId).children.push(suite);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
roots.push(suite);
|
|
88
|
+
}
|
|
272
89
|
});
|
|
90
|
+
const cleanSuite = (suite) => {
|
|
91
|
+
const cleaned = { id: suite.id, name: suite.name };
|
|
92
|
+
if (suite.children && suite.children.length > 0) {
|
|
93
|
+
cleaned.children = suite.children.map((child) => cleanSuite(child));
|
|
94
|
+
}
|
|
95
|
+
return cleaned;
|
|
96
|
+
};
|
|
97
|
+
const cleanedSuites = roots.map((root) => cleanSuite(root));
|
|
98
|
+
const result = { testSuites: cleanedSuites };
|
|
99
|
+
if (nextToken)
|
|
100
|
+
result.continuationToken = nextToken;
|
|
101
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
273
102
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const accessToken = await tokenProvider();
|
|
296
|
-
const params = new URLSearchParams({ "api-version": "7.2-preview.3" });
|
|
297
|
-
if (continuationToken)
|
|
298
|
-
params.append("continuationToken", continuationToken);
|
|
299
|
-
const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planid}/Suites/${suiteid}/TestCase?${params.toString()}`;
|
|
300
|
-
const headers = {
|
|
301
|
-
Authorization: `Bearer ${accessToken}`,
|
|
302
|
-
};
|
|
303
|
-
const userAgent = userAgentProvider?.();
|
|
304
|
-
if (userAgent) {
|
|
305
|
-
headers["User-Agent"] = userAgent;
|
|
306
|
-
}
|
|
307
|
-
const response = await fetch(url, {
|
|
308
|
-
method: "GET",
|
|
309
|
-
headers,
|
|
310
|
-
});
|
|
311
|
-
if (!response.ok) {
|
|
312
|
-
const errorText = await response.text();
|
|
313
|
-
throw new Error(`Failed to list test cases (${response.status}): ${errorText}`);
|
|
314
|
-
}
|
|
315
|
-
const body = await response.json();
|
|
316
|
-
const testcases = body.value ?? [];
|
|
317
|
-
const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
|
|
318
|
-
const result = {
|
|
319
|
-
testCases: testcases,
|
|
320
|
-
};
|
|
321
|
-
if (nextToken) {
|
|
322
|
-
result.continuationToken = nextToken;
|
|
103
|
+
else if (action === "list_cases") {
|
|
104
|
+
if (!planId)
|
|
105
|
+
return { content: [{ type: "text", text: "planId is required for list_cases" }], isError: true };
|
|
106
|
+
if (!suiteId)
|
|
107
|
+
return { content: [{ type: "text", text: "suiteId is required for list_cases" }], isError: true };
|
|
108
|
+
const params = new URLSearchParams({ "api-version": "7.2-preview.3" });
|
|
109
|
+
if (continuationToken)
|
|
110
|
+
params.append("continuationToken", continuationToken);
|
|
111
|
+
const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planId}/Suites/${suiteId}/TestCase?${params.toString()}`;
|
|
112
|
+
const response = await fetch(url, { method: "GET", headers });
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
const errorText = await response.text();
|
|
115
|
+
throw new Error(`Failed to list test cases (${response.status}): ${errorText}`);
|
|
116
|
+
}
|
|
117
|
+
const body = await response.json();
|
|
118
|
+
const testcases = body.value ?? [];
|
|
119
|
+
const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined;
|
|
120
|
+
const result = { testCases: testcases };
|
|
121
|
+
if (nextToken)
|
|
122
|
+
result.continuationToken = nextToken;
|
|
123
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
323
124
|
}
|
|
324
|
-
return {
|
|
325
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
326
|
-
};
|
|
125
|
+
return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
|
|
327
126
|
}
|
|
328
127
|
catch (error) {
|
|
329
128
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
129
|
+
const prefix = action === "list_plans" ? "Error listing test plans" : action === "list_suites" ? "Error listing test suites" : "Error listing test cases";
|
|
330
130
|
return {
|
|
331
|
-
content: [{ type: "text", text:
|
|
131
|
+
content: [{ type: "text", text: `${prefix}: ${errorMessage}` }],
|
|
332
132
|
isError: true,
|
|
333
133
|
};
|
|
334
134
|
}
|
|
335
135
|
});
|
|
336
|
-
|
|
136
|
+
// ─── testplan_show_test_results_from_build_id ──────────────────────────────────────
|
|
137
|
+
server.tool(TEST_PLAN_TOOLS.test_results_from_build_id, "Gets a list of test results for a given project and build ID. Can filter by test outcome (e.g. Failed, Passed, Aborted). Returns test case titles, error messages, stack traces, and outcomes. Efficiently handles builds with large numbers of test runs.", {
|
|
337
138
|
project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
|
|
338
139
|
buildid: z.coerce.number().min(1).describe("The ID of the build."),
|
|
339
140
|
outcomes: z.array(z.string()).optional().describe("Filter results by test outcome, e.g. ['Failed', 'Passed', 'Aborted']."),
|
|
@@ -341,19 +142,8 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
|
|
|
341
142
|
try {
|
|
342
143
|
const connection = await connectionProvider();
|
|
343
144
|
const testResultsApi = await connection.getTestResultsApi();
|
|
344
|
-
// Build filter expression for outcomes if specified.
|
|
345
|
-
// The API accepts: Outcome eq Failed,Passed (unquoted, comma-separated)
|
|
346
145
|
const outcomeFilter = outcomes?.length ? `Outcome eq ${outcomes.join(",")}` : undefined;
|
|
347
|
-
|
|
348
|
-
// This is more efficient than getTestRuns + getTestResults per run,
|
|
349
|
-
// especially for builds with many test runs (e.g., cloud testing with one run per test case)
|
|
350
|
-
const testResultDetails = await testResultsApi.getTestResultDetailsForBuild(project, buildid, undefined, // publishContext
|
|
351
|
-
undefined, // groupBy
|
|
352
|
-
outcomeFilter, // filter by outcome
|
|
353
|
-
undefined, // orderby
|
|
354
|
-
true // shouldIncludeResults - get individual test results, not just aggregates
|
|
355
|
-
);
|
|
356
|
-
// Extract individual test results from the grouped response
|
|
146
|
+
const testResultDetails = await testResultsApi.getTestResultDetailsForBuild(project, buildid, undefined, undefined, outcomeFilter, undefined, true);
|
|
357
147
|
const allResults = [];
|
|
358
148
|
if (testResultDetails.resultsForGroup) {
|
|
359
149
|
for (const group of testResultDetails.resultsForGroup) {
|
|
@@ -364,7 +154,6 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
|
|
|
364
154
|
}
|
|
365
155
|
}
|
|
366
156
|
}
|
|
367
|
-
// Format results to extract useful fields
|
|
368
157
|
const formattedResults = allResults.map((r) => ({
|
|
369
158
|
id: r.id,
|
|
370
159
|
testCaseTitle: r.testCaseTitle,
|
|
@@ -388,87 +177,204 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
|
|
|
388
177
|
};
|
|
389
178
|
}
|
|
390
179
|
});
|
|
391
|
-
|
|
180
|
+
// ─── testplan_test_plan_write ─────────────────────────────────────────────────────
|
|
181
|
+
server.tool(TEST_PLAN_TOOLS.testplan_test_plan_write, "Write operations for test plans. Use the action parameter to specify the operation.", {
|
|
182
|
+
action: z.enum(["create"]).describe("The action to perform. Options: create (create a new test plan)."),
|
|
392
183
|
project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
184
|
+
name: z.string().optional().describe("The name of the test plan. Required for: create."),
|
|
185
|
+
iteration: z.string().optional().describe("The iteration path for the test plan. Required for: create."),
|
|
186
|
+
description: z.string().optional().describe("The description of the test plan. Used for: create."),
|
|
187
|
+
startDate: z.string().optional().describe("The start date of the test plan. Used for: create."),
|
|
188
|
+
endDate: z.string().optional().describe("The end date of the test plan. Used for: create."),
|
|
189
|
+
areaPath: z.string().optional().describe("The area path for the test plan. Used for: create."),
|
|
190
|
+
}, async ({ project, name, iteration, description, startDate, endDate, areaPath }) => {
|
|
396
191
|
try {
|
|
192
|
+
if (!name)
|
|
193
|
+
return { content: [{ type: "text", text: "name is required for create" }], isError: true };
|
|
194
|
+
if (!iteration)
|
|
195
|
+
return { content: [{ type: "text", text: "iteration is required for create" }], isError: true };
|
|
397
196
|
const connection = await connectionProvider();
|
|
398
|
-
const
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
197
|
+
const testPlanApi = await connection.getTestPlanApi();
|
|
198
|
+
const testPlanToCreate = {
|
|
199
|
+
name,
|
|
200
|
+
iteration,
|
|
201
|
+
description,
|
|
202
|
+
startDate: startDate ? new Date(startDate) : undefined,
|
|
203
|
+
endDate: endDate ? new Date(endDate) : undefined,
|
|
204
|
+
areaPath,
|
|
405
205
|
};
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
206
|
+
const createdTestPlan = await testPlanApi.createTestPlan(testPlanToCreate, project);
|
|
207
|
+
return {
|
|
208
|
+
content: [{ type: "text", text: JSON.stringify(createdTestPlan, null, 2) }],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
213
|
+
return {
|
|
214
|
+
content: [{ type: "text", text: `Error creating test plan: ${errorMessage}` }],
|
|
215
|
+
isError: true,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
// ─── testplan_test_suite_write ────────────────────────────────────────────────────
|
|
220
|
+
server.tool(TEST_PLAN_TOOLS.testplan_test_suite_write, "Write operations for test suites. Use the action parameter to specify the operation.", {
|
|
221
|
+
action: z
|
|
222
|
+
.enum(["create", "add_test_cases"])
|
|
223
|
+
.describe("The action to perform. Options: create (create a new test suite in a test plan), add_test_cases (add existing test cases to a test suite)."),
|
|
224
|
+
project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
|
|
225
|
+
planId: z.coerce.number().min(1).optional().describe("The ID of the test plan. Required for: create, add_test_cases."),
|
|
226
|
+
parentSuiteId: z.coerce.number().min(1).optional().describe("ID of the parent suite under which the new suite will be created. Required for: create."),
|
|
227
|
+
name: z.string().optional().describe("Name of the child test suite. Required for: create."),
|
|
228
|
+
suiteId: z.coerce.number().min(1).optional().describe("The ID of the test suite. Required for: add_test_cases."),
|
|
229
|
+
testCaseIds: z.string().or(z.array(z.string())).optional().describe("The ID(s) of the test case(s) to add. Required for: add_test_cases."),
|
|
230
|
+
}, async ({ action, project, planId, parentSuiteId, name, suiteId, testCaseIds }) => {
|
|
231
|
+
try {
|
|
232
|
+
if (action === "create") {
|
|
233
|
+
if (!planId)
|
|
234
|
+
return { content: [{ type: "text", text: "planId is required for create" }], isError: true };
|
|
235
|
+
if (!parentSuiteId)
|
|
236
|
+
return { content: [{ type: "text", text: "parentSuiteId is required for create" }], isError: true };
|
|
237
|
+
if (!name)
|
|
238
|
+
return { content: [{ type: "text", text: "name is required for create" }], isError: true };
|
|
239
|
+
const maxRetries = 5;
|
|
240
|
+
const baseDelay = 500;
|
|
241
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
242
|
+
try {
|
|
243
|
+
const connection = await connectionProvider();
|
|
244
|
+
const testPlanApi = await connection.getTestPlanApi();
|
|
245
|
+
const testSuiteToCreate = {
|
|
246
|
+
name,
|
|
247
|
+
parentSuite: { id: parentSuiteId, name: "" },
|
|
248
|
+
suiteType: 2,
|
|
249
|
+
};
|
|
250
|
+
const createdTestSuite = await testPlanApi.createTestSuite(testSuiteToCreate, project, planId);
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: "text", text: JSON.stringify(createdTestSuite, null, 2) }],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
257
|
+
const isConcurrencyError = errorMessage.includes("TF26071") || errorMessage.includes("got update") || errorMessage.includes("changed by someone else");
|
|
258
|
+
if (isConcurrencyError && attempt < maxRetries) {
|
|
259
|
+
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 200;
|
|
260
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
content: [{ type: "text", text: `Error creating test suite: ${errorMessage}` }],
|
|
265
|
+
isError: true,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/* istanbul ignore next */
|
|
270
|
+
return {
|
|
271
|
+
content: [{ type: "text", text: "Error creating test suite: Maximum retries exceeded" }],
|
|
272
|
+
isError: true,
|
|
273
|
+
};
|
|
409
274
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
275
|
+
else if (action === "add_test_cases") {
|
|
276
|
+
if (!planId)
|
|
277
|
+
return { content: [{ type: "text", text: "planId is required for add_test_cases" }], isError: true };
|
|
278
|
+
if (!suiteId)
|
|
279
|
+
return { content: [{ type: "text", text: "suiteId is required for add_test_cases" }], isError: true };
|
|
280
|
+
if (!testCaseIds)
|
|
281
|
+
return { content: [{ type: "text", text: "testCaseIds is required for add_test_cases" }], isError: true };
|
|
282
|
+
const connection = await connectionProvider();
|
|
283
|
+
const testApi = await connection.getTestApi();
|
|
284
|
+
const testCaseIdsString = Array.isArray(testCaseIds) ? testCaseIds.join(",") : testCaseIds;
|
|
285
|
+
const addedTestCases = await testApi.addTestCasesToSuite(project, planId, suiteId, testCaseIdsString);
|
|
286
|
+
return {
|
|
287
|
+
content: [{ type: "text", text: JSON.stringify(addedTestCases, null, 2) }],
|
|
288
|
+
};
|
|
417
289
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
290
|
+
return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
294
|
+
return {
|
|
295
|
+
content: [{ type: "text", text: `Error adding test cases to suite: ${errorMessage}` }],
|
|
296
|
+
isError: true,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
// ─── testplan_test_case_write ─────────────────────────────────────────────────────
|
|
301
|
+
server.tool(TEST_PLAN_TOOLS.testplan_test_case_write, "Write operations for test cases. Use the action parameter to specify the operation.", {
|
|
302
|
+
action: z.enum(["create", "update_steps"]).describe("The action to perform. Options: create (create a new test case work item), update_steps (update steps on an existing test case)."),
|
|
303
|
+
project: z.string().optional().describe("The unique identifier (ID or name) of the Azure DevOps project. Required for: create."),
|
|
304
|
+
title: z.string().optional().describe("The title of the test case. Required for: create."),
|
|
305
|
+
priority: z.coerce.number().optional().describe("The priority of the test case. Used for: create."),
|
|
306
|
+
areaPath: z.string().optional().describe("The area path for the test case. Used for: create."),
|
|
307
|
+
iterationPath: z.string().optional().describe("The iteration path for the test case. Used for: create."),
|
|
308
|
+
testsWorkItemId: z.coerce.number().min(1).optional().describe("Work item ID to set as a Microsoft.VSTS.Common.TestedBy-Reverse link. Used for: create."),
|
|
309
|
+
id: z.coerce.number().min(1).optional().describe("The ID of the test case work item to update. Required for: update_steps."),
|
|
310
|
+
steps: z
|
|
311
|
+
.string()
|
|
312
|
+
.optional()
|
|
313
|
+
.describe("The steps for the test case. Format each step as '1. Step one|Expected result one\n2. Step two|Expected result two'. Use '|' as the delimiter between step and expected result. Required for: update_steps. Used for: create."),
|
|
314
|
+
}, async ({ action, project, title, steps, priority, areaPath, iterationPath, testsWorkItemId, id }) => {
|
|
315
|
+
try {
|
|
316
|
+
if (action === "create") {
|
|
317
|
+
if (!project)
|
|
318
|
+
return { content: [{ type: "text", text: "project is required for create" }], isError: true };
|
|
319
|
+
if (!title)
|
|
320
|
+
return { content: [{ type: "text", text: "title is required for create" }], isError: true };
|
|
321
|
+
const connection = await connectionProvider();
|
|
322
|
+
const witClient = await connection.getWorkItemTrackingApi();
|
|
323
|
+
let stepsXml;
|
|
324
|
+
if (steps) {
|
|
325
|
+
stepsXml = convertStepsToXml(steps);
|
|
440
326
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
327
|
+
const patchDocument = [];
|
|
328
|
+
patchDocument.push({ op: "add", path: "/fields/System.Title", value: title });
|
|
329
|
+
if (testsWorkItemId) {
|
|
330
|
+
patchDocument.push({
|
|
331
|
+
op: "add",
|
|
332
|
+
path: "/relations/-",
|
|
333
|
+
value: {
|
|
334
|
+
rel: "Microsoft.VSTS.Common.TestedBy-Reverse",
|
|
335
|
+
url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${testsWorkItemId}`,
|
|
336
|
+
},
|
|
337
|
+
});
|
|
444
338
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
const cleanSuite = (suite) => {
|
|
448
|
-
const cleaned = {
|
|
449
|
-
id: suite.id,
|
|
450
|
-
name: suite.name,
|
|
451
|
-
};
|
|
452
|
-
if (suite.children && suite.children.length > 0) {
|
|
453
|
-
cleaned.children = suite.children.map((child) => cleanSuite(child));
|
|
339
|
+
if (stepsXml) {
|
|
340
|
+
patchDocument.push({ op: "add", path: "/fields/Microsoft.VSTS.TCM.Steps", value: stepsXml });
|
|
454
341
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
342
|
+
if (priority) {
|
|
343
|
+
patchDocument.push({ op: "add", path: "/fields/Microsoft.VSTS.Common.Priority", value: priority });
|
|
344
|
+
}
|
|
345
|
+
if (areaPath) {
|
|
346
|
+
patchDocument.push({ op: "add", path: "/fields/System.AreaPath", value: areaPath });
|
|
347
|
+
}
|
|
348
|
+
if (iterationPath) {
|
|
349
|
+
patchDocument.push({ op: "add", path: "/fields/System.IterationPath", value: iterationPath });
|
|
350
|
+
}
|
|
351
|
+
const workItem = await witClient.createWorkItem({}, patchDocument, project, "Test Case");
|
|
352
|
+
return {
|
|
353
|
+
content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }],
|
|
354
|
+
};
|
|
463
355
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
356
|
+
else if (action === "update_steps") {
|
|
357
|
+
if (!id)
|
|
358
|
+
return { content: [{ type: "text", text: "id is required for update_steps" }], isError: true };
|
|
359
|
+
if (!steps)
|
|
360
|
+
return { content: [{ type: "text", text: "steps is required for update_steps" }], isError: true };
|
|
361
|
+
const connection = await connectionProvider();
|
|
362
|
+
const witClient = await connection.getWorkItemTrackingApi();
|
|
363
|
+
const stepsXml = convertStepsToXml(steps);
|
|
364
|
+
const patchDocument = [];
|
|
365
|
+
patchDocument.push({ op: "add", path: "/fields/Microsoft.VSTS.TCM.Steps", value: stepsXml });
|
|
366
|
+
const workItem = await witClient.updateWorkItem({}, patchDocument, id);
|
|
367
|
+
return {
|
|
368
|
+
content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
|
|
467
372
|
}
|
|
468
373
|
catch (error) {
|
|
469
374
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
375
|
+
const prefix = action === "create" ? "Error creating test case" : "Error updating test case steps";
|
|
470
376
|
return {
|
|
471
|
-
content: [{ type: "text", text:
|
|
377
|
+
content: [{ type: "text", text: `${prefix}: ${errorMessage}` }],
|
|
472
378
|
isError: true,
|
|
473
379
|
};
|
|
474
380
|
}
|
|
@@ -480,37 +386,31 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
|
|
|
480
386
|
* element, which is the format Azure DevOps expects for rendered step content.
|
|
481
387
|
*/
|
|
482
388
|
function formatStepContent(text) {
|
|
483
|
-
// Convert Markdown markers to HTML tags (** before * and __ before _ to avoid conflicts)
|
|
484
389
|
const htmlContent = text
|
|
485
390
|
.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
|
|
486
391
|
.replace(/\*(.+?)\*/g, "<i>$1</i>")
|
|
487
392
|
.replace(/__(.+?)__/g, "<u>$1</u>")
|
|
488
393
|
.replace(/`(.+?)`/g, "<code>$1</code>")
|
|
489
394
|
.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2">$1</a>');
|
|
490
|
-
// Wrap in ADO rich text envelope and XML-escape the entire HTML string
|
|
491
395
|
return escapeXml(`${htmlContent}`);
|
|
492
396
|
}
|
|
493
397
|
/*
|
|
494
398
|
* Helper function to convert steps text to XML format required
|
|
495
399
|
*/
|
|
496
400
|
function convertStepsToXml(steps) {
|
|
497
|
-
// Accepts steps in the format: '1. Step one|Expected result one\n2. Step two|Expected result two'
|
|
498
401
|
const stepsLines = steps.split("\n").filter((line) => line.trim() !== "");
|
|
499
402
|
let xmlSteps = `<steps id="0" last="${stepsLines.length}">`;
|
|
500
403
|
for (let i = 0; i < stepsLines.length; i++) {
|
|
501
404
|
const stepLine = stepsLines[i].trim();
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
const expectedText = expectedPart || "Verify step completes successfully";
|
|
508
|
-
xmlSteps += `
|
|
405
|
+
const [stepPart, expectedPart] = stepLine.split("|").map((s) => s.trim());
|
|
406
|
+
const stepMatch = stepPart.match(/^(\d+)\.\s*(.+)$/);
|
|
407
|
+
const stepText = stepMatch ? stepMatch[2] : stepPart;
|
|
408
|
+
const expectedText = expectedPart || "Verify step completes successfully";
|
|
409
|
+
xmlSteps += `
|
|
509
410
|
<step id="${i + 1}" type="ActionStep">
|
|
510
411
|
<parameterizedString isformatted="true">${formatStepContent(stepText)}</parameterizedString>
|
|
511
412
|
<parameterizedString isformatted="true">${formatStepContent(expectedText)}</parameterizedString>
|
|
512
413
|
</step>`;
|
|
513
|
-
}
|
|
514
414
|
}
|
|
515
415
|
xmlSteps += "</steps>";
|
|
516
416
|
return xmlSteps;
|
|
@@ -531,9 +431,10 @@ function escapeXml(unsafe) {
|
|
|
531
431
|
return "'";
|
|
532
432
|
case '"':
|
|
533
433
|
return """;
|
|
434
|
+
/* istanbul ignore next */
|
|
534
435
|
default:
|
|
535
436
|
return c;
|
|
536
437
|
}
|
|
537
438
|
});
|
|
538
439
|
}
|
|
539
|
-
export {
|
|
440
|
+
export { TEST_PLAN_TOOLS, configureTestPlanTools };
|