@azure-devops/mcp 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -21
- package/README.md +30 -145
- package/dist/shared/tool-validation.js +92 -0
- package/dist/tools/advsec.js +108 -0
- package/dist/tools/core.test.js +1 -0
- package/dist/tools/repos.js +181 -19
- package/dist/tools/search.js +15 -15
- package/dist/tools/testplan.test.js +125 -0
- package/dist/tools/testplans.js +8 -4
- package/dist/tools/utils.js +6 -0
- package/dist/tools/wiki.test.js +87 -0
- package/dist/tools/workitem.test.js +101 -0
- package/dist/tools/workitems.js +88 -57
- package/dist/tools/workitems.test.js +530 -0
- package/dist/tools.js +8 -6
- package/dist/utils.js +26 -0
- package/dist/version.js +1 -1
- package/package.json +4 -2
- package/dist/http.js +0 -52
- package/dist/server.js +0 -36
package/dist/tools/repos.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Copyright (c) Microsoft Corporation.
|
|
2
2
|
// Licensed under the MIT License.
|
|
3
|
-
import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, } from "azure-devops-node-api/interfaces/GitInterfaces.js";
|
|
3
|
+
import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus, } from "azure-devops-node-api/interfaces/GitInterfaces.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { getCurrentUserDetails } from "./auth.js";
|
|
6
6
|
import { getEnumKeys } from "../utils.js";
|
|
@@ -16,9 +16,10 @@ const REPO_TOOLS = {
|
|
|
16
16
|
get_branch_by_name: "repo_get_branch_by_name",
|
|
17
17
|
get_pull_request_by_id: "repo_get_pull_request_by_id",
|
|
18
18
|
create_pull_request: "repo_create_pull_request",
|
|
19
|
-
|
|
19
|
+
update_pull_request: "repo_update_pull_request",
|
|
20
20
|
update_pull_request_reviewers: "repo_update_pull_request_reviewers",
|
|
21
21
|
reply_to_comment: "repo_reply_to_comment",
|
|
22
|
+
create_pull_request_thread: "repo_create_pull_request_thread",
|
|
22
23
|
resolve_comment: "repo_resolve_comment",
|
|
23
24
|
search_commits: "repo_search_commits",
|
|
24
25
|
list_pull_requests_by_commits: "repo_list_pull_requests_by_commits",
|
|
@@ -31,6 +32,26 @@ function branchesFilterOutIrrelevantProperties(branches, top) {
|
|
|
31
32
|
.sort((a, b) => b.localeCompare(a))
|
|
32
33
|
.slice(0, top);
|
|
33
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Trims comment data to essential properties, filtering out deleted comments
|
|
37
|
+
* @param comments Array of comments to trim (can be undefined/null)
|
|
38
|
+
* @returns Array of trimmed comment objects with essential properties only
|
|
39
|
+
*/
|
|
40
|
+
function trimComments(comments) {
|
|
41
|
+
return comments
|
|
42
|
+
?.filter((comment) => !comment.isDeleted) // Exclude deleted comments
|
|
43
|
+
?.map((comment) => ({
|
|
44
|
+
id: comment.id,
|
|
45
|
+
author: {
|
|
46
|
+
displayName: comment.author?.displayName,
|
|
47
|
+
uniqueName: comment.author?.uniqueName,
|
|
48
|
+
},
|
|
49
|
+
content: comment.content,
|
|
50
|
+
publishedDate: comment.publishedDate,
|
|
51
|
+
lastUpdatedDate: comment.lastUpdatedDate,
|
|
52
|
+
lastContentUpdatedDate: comment.lastContentUpdatedDate,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
34
55
|
function pullRequestStatusStringToInt(status) {
|
|
35
56
|
switch (status) {
|
|
36
57
|
case "Abandoned":
|
|
@@ -61,10 +82,18 @@ function configureRepoTools(server, tokenProvider, connectionProvider) {
|
|
|
61
82
|
description: z.string().optional().describe("The description of the pull request. Optional."),
|
|
62
83
|
isDraft: z.boolean().optional().default(false).describe("Indicates whether the pull request is a draft. Defaults to false."),
|
|
63
84
|
workItems: z.string().optional().describe("Work item IDs to associate with the pull request, space-separated."),
|
|
64
|
-
|
|
85
|
+
forkSourceRepositoryId: z.string().optional().describe("The ID of the fork repository that the pull request originates from. Optional, used when creating a pull request from a fork."),
|
|
86
|
+
}, async ({ repositoryId, sourceRefName, targetRefName, title, description, isDraft, workItems, forkSourceRepositoryId }) => {
|
|
65
87
|
const connection = await connectionProvider();
|
|
66
88
|
const gitApi = await connection.getGitApi();
|
|
67
89
|
const workItemRefs = workItems ? workItems.split(" ").map((id) => ({ id: id.trim() })) : [];
|
|
90
|
+
const forkSource = forkSourceRepositoryId
|
|
91
|
+
? {
|
|
92
|
+
repository: {
|
|
93
|
+
id: forkSourceRepositoryId,
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
: undefined;
|
|
68
97
|
const pullRequest = await gitApi.createPullRequest({
|
|
69
98
|
sourceRefName,
|
|
70
99
|
targetRefName,
|
|
@@ -72,20 +101,44 @@ function configureRepoTools(server, tokenProvider, connectionProvider) {
|
|
|
72
101
|
description,
|
|
73
102
|
isDraft,
|
|
74
103
|
workItemRefs: workItemRefs,
|
|
104
|
+
forkSource,
|
|
75
105
|
}, repositoryId);
|
|
76
106
|
return {
|
|
77
107
|
content: [{ type: "text", text: JSON.stringify(pullRequest, null, 2) }],
|
|
78
108
|
};
|
|
79
109
|
});
|
|
80
|
-
server.tool(REPO_TOOLS.
|
|
110
|
+
server.tool(REPO_TOOLS.update_pull_request, "Update a Pull Request by ID with specified fields.", {
|
|
81
111
|
repositoryId: z.string().describe("The ID of the repository where the pull request exists."),
|
|
82
|
-
pullRequestId: z.number().describe("The ID of the pull request to
|
|
83
|
-
|
|
84
|
-
|
|
112
|
+
pullRequestId: z.number().describe("The ID of the pull request to update."),
|
|
113
|
+
title: z.string().optional().describe("The new title for the pull request."),
|
|
114
|
+
description: z.string().optional().describe("The new description for the pull request."),
|
|
115
|
+
isDraft: z.boolean().optional().describe("Whether the pull request should be a draft."),
|
|
116
|
+
targetRefName: z.string().optional().describe("The new target branch name (e.g., 'refs/heads/main')."),
|
|
117
|
+
status: z.enum(["Active", "Abandoned"]).optional().describe("The new status of the pull request. Can be 'Active' or 'Abandoned'."),
|
|
118
|
+
}, async ({ repositoryId, pullRequestId, title, description, isDraft, targetRefName, status }) => {
|
|
85
119
|
const connection = await connectionProvider();
|
|
86
120
|
const gitApi = await connection.getGitApi();
|
|
87
|
-
|
|
88
|
-
const
|
|
121
|
+
// Build update object with only provided fields
|
|
122
|
+
const updateRequest = {};
|
|
123
|
+
if (title !== undefined)
|
|
124
|
+
updateRequest.title = title;
|
|
125
|
+
if (description !== undefined)
|
|
126
|
+
updateRequest.description = description;
|
|
127
|
+
if (isDraft !== undefined)
|
|
128
|
+
updateRequest.isDraft = isDraft;
|
|
129
|
+
if (targetRefName !== undefined)
|
|
130
|
+
updateRequest.targetRefName = targetRefName;
|
|
131
|
+
if (status !== undefined) {
|
|
132
|
+
updateRequest.status = status === "Active" ? PullRequestStatus.Active.valueOf() : PullRequestStatus.Abandoned.valueOf();
|
|
133
|
+
}
|
|
134
|
+
// Validate that at least one field is provided for update
|
|
135
|
+
if (Object.keys(updateRequest).length === 0) {
|
|
136
|
+
return {
|
|
137
|
+
content: [{ type: "text", text: "Error: At least one field (title, description, isDraft, targetRefName, or status) must be provided for update." }],
|
|
138
|
+
isError: true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const updatedPullRequest = await gitApi.updatePullRequest(updateRequest, repositoryId, pullRequestId);
|
|
89
142
|
return {
|
|
90
143
|
content: [{ type: "text", text: JSON.stringify(updatedPullRequest, null, 2) }],
|
|
91
144
|
};
|
|
@@ -242,13 +295,27 @@ function configureRepoTools(server, tokenProvider, connectionProvider) {
|
|
|
242
295
|
baseIteration: z.number().optional().describe("The base iteration ID for which to retrieve threads. Optional, defaults to the latest base iteration."),
|
|
243
296
|
top: z.number().default(100).describe("The maximum number of threads to return."),
|
|
244
297
|
skip: z.number().default(0).describe("The number of threads to skip."),
|
|
245
|
-
|
|
298
|
+
fullResponse: z.boolean().optional().default(false).describe("Return full thread JSON response instead of trimmed data."),
|
|
299
|
+
}, async ({ repositoryId, pullRequestId, project, iteration, baseIteration, top, skip, fullResponse }) => {
|
|
246
300
|
const connection = await connectionProvider();
|
|
247
301
|
const gitApi = await connection.getGitApi();
|
|
248
302
|
const threads = await gitApi.getThreads(repositoryId, pullRequestId, project, iteration, baseIteration);
|
|
249
303
|
const paginatedThreads = threads?.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top);
|
|
304
|
+
if (fullResponse) {
|
|
305
|
+
return {
|
|
306
|
+
content: [{ type: "text", text: JSON.stringify(paginatedThreads, null, 2) }],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
// Return trimmed thread data focusing on essential information
|
|
310
|
+
const trimmedThreads = paginatedThreads?.map((thread) => ({
|
|
311
|
+
id: thread.id,
|
|
312
|
+
publishedDate: thread.publishedDate,
|
|
313
|
+
lastUpdatedDate: thread.lastUpdatedDate,
|
|
314
|
+
status: thread.status,
|
|
315
|
+
comments: trimComments(thread.comments),
|
|
316
|
+
}));
|
|
250
317
|
return {
|
|
251
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
318
|
+
content: [{ type: "text", text: JSON.stringify(trimmedThreads, null, 2) }],
|
|
252
319
|
};
|
|
253
320
|
});
|
|
254
321
|
server.tool(REPO_TOOLS.list_pull_request_thread_comments, "Retrieve a list of comments in a pull request thread.", {
|
|
@@ -258,14 +325,22 @@ function configureRepoTools(server, tokenProvider, connectionProvider) {
|
|
|
258
325
|
project: z.string().optional().describe("Project ID or project name (optional)"),
|
|
259
326
|
top: z.number().default(100).describe("The maximum number of comments to return."),
|
|
260
327
|
skip: z.number().default(0).describe("The number of comments to skip."),
|
|
261
|
-
|
|
328
|
+
fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of trimmed data."),
|
|
329
|
+
}, async ({ repositoryId, pullRequestId, threadId, project, top, skip, fullResponse }) => {
|
|
262
330
|
const connection = await connectionProvider();
|
|
263
331
|
const gitApi = await connection.getGitApi();
|
|
264
332
|
// Get thread comments - GitApi uses getComments for retrieving comments from a specific thread
|
|
265
333
|
const comments = await gitApi.getComments(repositoryId, pullRequestId, threadId, project);
|
|
266
334
|
const paginatedComments = comments?.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top);
|
|
335
|
+
if (fullResponse) {
|
|
336
|
+
return {
|
|
337
|
+
content: [{ type: "text", text: JSON.stringify(paginatedComments, null, 2) }],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
// Return trimmed comment data focusing on essential information
|
|
341
|
+
const trimmedComments = trimComments(paginatedComments);
|
|
267
342
|
return {
|
|
268
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
343
|
+
content: [{ type: "text", text: JSON.stringify(trimmedComments, null, 2) }],
|
|
269
344
|
};
|
|
270
345
|
});
|
|
271
346
|
server.tool(REPO_TOOLS.list_branches_by_repo, "Retrieve a list of branches for a given repository.", {
|
|
@@ -332,10 +407,11 @@ function configureRepoTools(server, tokenProvider, connectionProvider) {
|
|
|
332
407
|
server.tool(REPO_TOOLS.get_pull_request_by_id, "Get a pull request by its ID.", {
|
|
333
408
|
repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
|
|
334
409
|
pullRequestId: z.number().describe("The ID of the pull request to retrieve."),
|
|
335
|
-
|
|
410
|
+
includeWorkItemRefs: z.boolean().optional().default(false).describe("Whether to reference work items associated with the pull request."),
|
|
411
|
+
}, async ({ repositoryId, pullRequestId, includeWorkItemRefs }) => {
|
|
336
412
|
const connection = await connectionProvider();
|
|
337
413
|
const gitApi = await connection.getGitApi();
|
|
338
|
-
const pullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId);
|
|
414
|
+
const pullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, undefined, undefined, undefined, undefined, undefined, includeWorkItemRefs);
|
|
339
415
|
return {
|
|
340
416
|
content: [{ type: "text", text: JSON.stringify(pullRequest, null, 2) }],
|
|
341
417
|
};
|
|
@@ -346,25 +422,111 @@ function configureRepoTools(server, tokenProvider, connectionProvider) {
|
|
|
346
422
|
threadId: z.number().describe("The ID of the thread to which the comment will be added."),
|
|
347
423
|
content: z.string().describe("The content of the comment to be added."),
|
|
348
424
|
project: z.string().optional().describe("Project ID or project name (optional)"),
|
|
349
|
-
|
|
425
|
+
fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of a simple confirmation message."),
|
|
426
|
+
}, async ({ repositoryId, pullRequestId, threadId, content, project, fullResponse }) => {
|
|
350
427
|
const connection = await connectionProvider();
|
|
351
428
|
const gitApi = await connection.getGitApi();
|
|
352
429
|
const comment = await gitApi.createComment({ content }, repositoryId, pullRequestId, threadId, project);
|
|
430
|
+
// Check if the comment was successfully created
|
|
431
|
+
if (!comment) {
|
|
432
|
+
return {
|
|
433
|
+
content: [{ type: "text", text: `Error: Failed to add comment to thread ${threadId}. The comment was not created successfully.` }],
|
|
434
|
+
isError: true,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
if (fullResponse) {
|
|
438
|
+
return {
|
|
439
|
+
content: [{ type: "text", text: JSON.stringify(comment, null, 2) }],
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
content: [{ type: "text", text: `Comment successfully added to thread ${threadId}.` }],
|
|
444
|
+
};
|
|
445
|
+
});
|
|
446
|
+
server.tool(REPO_TOOLS.create_pull_request_thread, "Creates a new comment thread on a pull request.", {
|
|
447
|
+
repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
|
|
448
|
+
pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."),
|
|
449
|
+
content: z.string().describe("The content of the comment to be added."),
|
|
450
|
+
project: z.string().optional().describe("Project ID or project name (optional)"),
|
|
451
|
+
filePath: z.string().optional().describe("The path of the file where the comment thread will be created. (optional)"),
|
|
452
|
+
status: z
|
|
453
|
+
.enum(getEnumKeys(CommentThreadStatus))
|
|
454
|
+
.optional()
|
|
455
|
+
.default(CommentThreadStatus[CommentThreadStatus.Active])
|
|
456
|
+
.describe("The status of the comment thread. Defaults to 'Active'."),
|
|
457
|
+
rightFileStartLine: z.number().optional().describe("Position of first character of the thread's span in right file. The line number of a thread's position. Starts at 1. (optional)"),
|
|
458
|
+
rightFileStartOffset: z
|
|
459
|
+
.number()
|
|
460
|
+
.optional()
|
|
461
|
+
.describe("Position of first character of the thread's span in right file. The line number of a thread's position. The character offset of a thread's position inside of a line. Starts at 1. Must only be set if rightFileStartLine is also specified. (optional)"),
|
|
462
|
+
rightFileEndLine: z
|
|
463
|
+
.number()
|
|
464
|
+
.optional()
|
|
465
|
+
.describe("Position of last character of the thread's span in right file. The line number of a thread's position. Starts at 1. Must only be set if rightFileStartLine is also specified. (optional)"),
|
|
466
|
+
rightFileEndOffset: z
|
|
467
|
+
.number()
|
|
468
|
+
.optional()
|
|
469
|
+
.describe("Position of last character of the thread's span in right file. The character offset of a thread's position inside of a line. Must only be set if rightFileEndLine is also specified. (optional)"),
|
|
470
|
+
}, async ({ repositoryId, pullRequestId, content, project, filePath, status, rightFileStartLine, rightFileStartOffset, rightFileEndLine, rightFileEndOffset }) => {
|
|
471
|
+
const connection = await connectionProvider();
|
|
472
|
+
const gitApi = await connection.getGitApi();
|
|
473
|
+
const threadContext = { filePath: filePath };
|
|
474
|
+
if (rightFileStartLine !== undefined) {
|
|
475
|
+
if (rightFileStartLine < 1) {
|
|
476
|
+
throw new Error("rightFileStartLine must be greater than or equal to 1.");
|
|
477
|
+
}
|
|
478
|
+
threadContext.rightFileStart = { line: rightFileStartLine };
|
|
479
|
+
if (rightFileStartOffset !== undefined) {
|
|
480
|
+
if (rightFileStartOffset < 1) {
|
|
481
|
+
throw new Error("rightFileStartOffset must be greater than or equal to 1.");
|
|
482
|
+
}
|
|
483
|
+
threadContext.rightFileStart.offset = rightFileStartOffset;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (rightFileEndLine !== undefined) {
|
|
487
|
+
if (rightFileStartLine === undefined) {
|
|
488
|
+
throw new Error("rightFileEndLine must only be specified if rightFileStartLine is also specified.");
|
|
489
|
+
}
|
|
490
|
+
if (rightFileEndLine < 1) {
|
|
491
|
+
throw new Error("rightFileEndLine must be greater than or equal to 1.");
|
|
492
|
+
}
|
|
493
|
+
threadContext.rightFileEnd = { line: rightFileEndLine };
|
|
494
|
+
if (rightFileEndOffset !== undefined) {
|
|
495
|
+
if (rightFileEndOffset < 1) {
|
|
496
|
+
throw new Error("rightFileEndOffset must be greater than or equal to 1.");
|
|
497
|
+
}
|
|
498
|
+
threadContext.rightFileEnd.offset = rightFileEndOffset;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const thread = await gitApi.createThread({ comments: [{ content: content }], threadContext: threadContext, status: CommentThreadStatus[status] }, repositoryId, pullRequestId, project);
|
|
353
502
|
return {
|
|
354
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
503
|
+
content: [{ type: "text", text: JSON.stringify(thread, null, 2) }],
|
|
355
504
|
};
|
|
356
505
|
});
|
|
357
506
|
server.tool(REPO_TOOLS.resolve_comment, "Resolves a specific comment thread on a pull request.", {
|
|
358
507
|
repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
|
|
359
508
|
pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."),
|
|
360
509
|
threadId: z.number().describe("The ID of the thread to be resolved."),
|
|
361
|
-
|
|
510
|
+
fullResponse: z.boolean().optional().default(false).describe("Return full thread JSON response instead of a simple confirmation message."),
|
|
511
|
+
}, async ({ repositoryId, pullRequestId, threadId, fullResponse }) => {
|
|
362
512
|
const connection = await connectionProvider();
|
|
363
513
|
const gitApi = await connection.getGitApi();
|
|
364
514
|
const thread = await gitApi.updateThread({ status: 2 }, // 2 corresponds to "Resolved" status
|
|
365
515
|
repositoryId, pullRequestId, threadId);
|
|
516
|
+
// Check if the thread was successfully resolved
|
|
517
|
+
if (!thread) {
|
|
518
|
+
return {
|
|
519
|
+
content: [{ type: "text", text: `Error: Failed to resolve thread ${threadId}. The thread status was not updated successfully.` }],
|
|
520
|
+
isError: true,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
if (fullResponse) {
|
|
524
|
+
return {
|
|
525
|
+
content: [{ type: "text", text: JSON.stringify(thread, null, 2) }],
|
|
526
|
+
};
|
|
527
|
+
}
|
|
366
528
|
return {
|
|
367
|
-
content: [{ type: "text", text:
|
|
529
|
+
content: [{ type: "text", text: `Thread ${threadId} was successfully resolved.` }],
|
|
368
530
|
};
|
|
369
531
|
});
|
|
370
532
|
const gitVersionTypeStrings = Object.values(GitVersionType).filter((value) => typeof value === "string");
|
package/dist/tools/search.js
CHANGED
|
@@ -17,17 +17,17 @@ function configureSearchTools(server, tokenProvider, connectionProvider, userAge
|
|
|
17
17
|
path: z.array(z.string()).optional().describe("Filter by paths"),
|
|
18
18
|
branch: z.array(z.string()).optional().describe("Filter by branches"),
|
|
19
19
|
includeFacets: z.boolean().default(false).describe("Include facets in the search results"),
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}, async ({ searchText, project, repository, path, branch, includeFacets,
|
|
20
|
+
skip: z.number().default(0).describe("Number of results to skip"),
|
|
21
|
+
top: z.number().default(5).describe("Maximum number of results to return"),
|
|
22
|
+
}, async ({ searchText, project, repository, path, branch, includeFacets, skip, top }) => {
|
|
23
23
|
const accessToken = await tokenProvider();
|
|
24
24
|
const connection = await connectionProvider();
|
|
25
25
|
const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/codesearchresults?api-version=${apiVersion}`;
|
|
26
26
|
const requestBody = {
|
|
27
27
|
searchText,
|
|
28
28
|
includeFacets,
|
|
29
|
-
$skip,
|
|
30
|
-
$top,
|
|
29
|
+
$skip: skip,
|
|
30
|
+
$top: top,
|
|
31
31
|
};
|
|
32
32
|
const filters = {};
|
|
33
33
|
if (project && project.length > 0)
|
|
@@ -66,16 +66,16 @@ function configureSearchTools(server, tokenProvider, connectionProvider, userAge
|
|
|
66
66
|
project: z.array(z.string()).optional().describe("Filter by projects"),
|
|
67
67
|
wiki: z.array(z.string()).optional().describe("Filter by wiki names"),
|
|
68
68
|
includeFacets: z.boolean().default(false).describe("Include facets in the search results"),
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}, async ({ searchText, project, wiki, includeFacets,
|
|
69
|
+
skip: z.number().default(0).describe("Number of results to skip"),
|
|
70
|
+
top: z.number().default(10).describe("Maximum number of results to return"),
|
|
71
|
+
}, async ({ searchText, project, wiki, includeFacets, skip, top }) => {
|
|
72
72
|
const accessToken = await tokenProvider();
|
|
73
73
|
const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/wikisearchresults?api-version=${apiVersion}`;
|
|
74
74
|
const requestBody = {
|
|
75
75
|
searchText,
|
|
76
76
|
includeFacets,
|
|
77
|
-
$skip,
|
|
78
|
-
$top,
|
|
77
|
+
$skip: skip,
|
|
78
|
+
$top: top,
|
|
79
79
|
};
|
|
80
80
|
const filters = {};
|
|
81
81
|
if (project && project.length > 0)
|
|
@@ -110,16 +110,16 @@ function configureSearchTools(server, tokenProvider, connectionProvider, userAge
|
|
|
110
110
|
state: z.array(z.string()).optional().describe("Filter by work item states"),
|
|
111
111
|
assignedTo: z.array(z.string()).optional().describe("Filter by assigned to users"),
|
|
112
112
|
includeFacets: z.boolean().default(false).describe("Include facets in the search results"),
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}, async ({ searchText, project, areaPath, workItemType, state, assignedTo, includeFacets,
|
|
113
|
+
skip: z.number().default(0).describe("Number of results to skip for pagination"),
|
|
114
|
+
top: z.number().default(10).describe("Number of results to return"),
|
|
115
|
+
}, async ({ searchText, project, areaPath, workItemType, state, assignedTo, includeFacets, skip, top }) => {
|
|
116
116
|
const accessToken = await tokenProvider();
|
|
117
117
|
const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/workitemsearchresults?api-version=${apiVersion}`;
|
|
118
118
|
const requestBody = {
|
|
119
119
|
searchText,
|
|
120
120
|
includeFacets,
|
|
121
|
-
$skip,
|
|
122
|
-
$top,
|
|
121
|
+
$skip: skip,
|
|
122
|
+
$top: top,
|
|
123
123
|
};
|
|
124
124
|
const filters = {};
|
|
125
125
|
if (project && project.length > 0)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Copyright (c) Microsoft Corporation.
|
|
2
|
+
// Licensed under the MIT License.
|
|
3
|
+
import { describe, expect, it } from '@jest/globals';
|
|
4
|
+
import { configureTestPlanTools } from '../../../src/tools/testplans';
|
|
5
|
+
describe("configureTestPlanTools", () => {
|
|
6
|
+
let server;
|
|
7
|
+
let tokenProvider;
|
|
8
|
+
let connectionProvider;
|
|
9
|
+
let mockConnection;
|
|
10
|
+
let mockTestPlanApi;
|
|
11
|
+
let mockTestResultsApi;
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
server = { tool: jest.fn() };
|
|
14
|
+
tokenProvider = jest.fn();
|
|
15
|
+
mockTestPlanApi = {
|
|
16
|
+
getTestPlans: jest.fn(),
|
|
17
|
+
createTestPlan: jest.fn(),
|
|
18
|
+
addTestCasesToSuite: jest.fn(),
|
|
19
|
+
getTestCaseList: jest.fn(),
|
|
20
|
+
};
|
|
21
|
+
mockTestResultsApi = {
|
|
22
|
+
getTestResultDetailsForBuild: jest.fn(),
|
|
23
|
+
};
|
|
24
|
+
mockConnection = {
|
|
25
|
+
getTestPlanApi: jest.fn().mockResolvedValue(mockTestPlanApi),
|
|
26
|
+
getTestResultsApi: jest.fn().mockResolvedValue(mockTestResultsApi),
|
|
27
|
+
};
|
|
28
|
+
connectionProvider = jest.fn().mockResolvedValue(mockConnection);
|
|
29
|
+
});
|
|
30
|
+
describe("tool registration", () => {
|
|
31
|
+
it("registers test plan tools on the server", () => {
|
|
32
|
+
configureTestPlanTools(server, tokenProvider, connectionProvider);
|
|
33
|
+
expect(server.tool.mock.calls.map(call => call[0])).toEqual(expect.arrayContaining([
|
|
34
|
+
"ado_list_test_plans",
|
|
35
|
+
"ado_create_test_plan",
|
|
36
|
+
"ado_add_test_cases_to_suite",
|
|
37
|
+
"ado_list_test_cases",
|
|
38
|
+
"ado_show_test_results_from_build_id",
|
|
39
|
+
]));
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe("list_test_plans tool", () => {
|
|
43
|
+
it("should call getTestPlans with the correct parameters and return the expected result", async () => {
|
|
44
|
+
configureTestPlanTools(server, tokenProvider, connectionProvider);
|
|
45
|
+
const call = server.tool.mock.calls.find(([toolName]) => toolName === "ado_list_test_plans");
|
|
46
|
+
if (!call)
|
|
47
|
+
throw new Error("ado_list_test_plans tool not registered");
|
|
48
|
+
const [, , , handler] = call;
|
|
49
|
+
mockTestPlanApi.getTestPlans.mockResolvedValue([{ id: 1, name: "Test Plan 1" }]);
|
|
50
|
+
const params = {
|
|
51
|
+
project: "proj1",
|
|
52
|
+
filterActivePlans: true,
|
|
53
|
+
includePlanDetails: false,
|
|
54
|
+
continuationToken: undefined,
|
|
55
|
+
};
|
|
56
|
+
const result = await handler(params);
|
|
57
|
+
expect(mockTestPlanApi.getTestPlans).toHaveBeenCalledWith("proj1", "", undefined, false, true);
|
|
58
|
+
expect(result.content[0].text).toBe(JSON.stringify([{ id: 1, name: "Test Plan 1" }], null, 2));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe("create_test_plan tool", () => {
|
|
62
|
+
it("should call createTestPlan with the correct parameters and return the expected result", async () => {
|
|
63
|
+
configureTestPlanTools(server, tokenProvider, connectionProvider);
|
|
64
|
+
const call = server.tool.mock.calls.find(([toolName]) => toolName === "ado_create_test_plan");
|
|
65
|
+
if (!call)
|
|
66
|
+
throw new Error("ado_create_test_plan tool not registered");
|
|
67
|
+
const [, , , handler] = call;
|
|
68
|
+
mockTestPlanApi.createTestPlan.mockResolvedValue({ id: 1, name: "New Test Plan" });
|
|
69
|
+
const params = {
|
|
70
|
+
project: "proj1",
|
|
71
|
+
name: "New Test Plan",
|
|
72
|
+
iteration: "Iteration 1",
|
|
73
|
+
description: "Description",
|
|
74
|
+
startDate: "2025-05-01",
|
|
75
|
+
endDate: "2025-05-31",
|
|
76
|
+
areaPath: "Area 1",
|
|
77
|
+
};
|
|
78
|
+
const result = await handler(params);
|
|
79
|
+
expect(mockTestPlanApi.createTestPlan).toHaveBeenCalledWith({
|
|
80
|
+
name: "New Test Plan",
|
|
81
|
+
iteration: "Iteration 1",
|
|
82
|
+
description: "Description",
|
|
83
|
+
startDate: new Date("2025-05-01"),
|
|
84
|
+
endDate: new Date("2025-05-31"),
|
|
85
|
+
areaPath: "Area 1",
|
|
86
|
+
}, "proj1");
|
|
87
|
+
expect(result.content[0].text).toBe(JSON.stringify({ id: 1, name: "New Test Plan" }, null, 2));
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe("list_test_cases tool", () => {
|
|
91
|
+
it("should call getTestCaseList with the correct parameters and return the expected result", async () => {
|
|
92
|
+
configureTestPlanTools(server, tokenProvider, connectionProvider);
|
|
93
|
+
const call = server.tool.mock.calls.find(([toolName]) => toolName === "ado_list_test_cases");
|
|
94
|
+
if (!call)
|
|
95
|
+
throw new Error("ado_list_test_cases tool not registered");
|
|
96
|
+
const [, , , handler] = call;
|
|
97
|
+
mockTestPlanApi.getTestCaseList.mockResolvedValue([{ id: 1, name: "Test Case 1" }]);
|
|
98
|
+
const params = {
|
|
99
|
+
project: "proj1",
|
|
100
|
+
planid: 1,
|
|
101
|
+
suiteid: 2,
|
|
102
|
+
};
|
|
103
|
+
const result = await handler(params);
|
|
104
|
+
expect(mockTestPlanApi.getTestCaseList).toHaveBeenCalledWith("proj1", 1, 2);
|
|
105
|
+
expect(result.content[0].text).toBe(JSON.stringify([{ id: 1, name: "Test Case 1" }], null, 2));
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
describe("test_results_from_build_id tool", () => {
|
|
109
|
+
it("should call getTestResultDetailsForBuild with the correct parameters and return the expected result", async () => {
|
|
110
|
+
configureTestPlanTools(server, tokenProvider, connectionProvider);
|
|
111
|
+
const call = server.tool.mock.calls.find(([toolName]) => toolName === "ado_show_test_results_from_build_id");
|
|
112
|
+
if (!call)
|
|
113
|
+
throw new Error("ado_show_test_results_from_build_id tool not registered");
|
|
114
|
+
const [, , , handler] = call;
|
|
115
|
+
mockTestResultsApi.getTestResultDetailsForBuild.mockResolvedValue({ results: ["Result 1"] });
|
|
116
|
+
const params = {
|
|
117
|
+
project: "proj1",
|
|
118
|
+
buildid: 123,
|
|
119
|
+
};
|
|
120
|
+
const result = await handler(params);
|
|
121
|
+
expect(mockTestResultsApi.getTestResultDetailsForBuild).toHaveBeenCalledWith("proj1", 123);
|
|
122
|
+
expect(result.content[0].text).toBe(JSON.stringify({ results: ["Result 1"] }, null, 2));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
package/dist/tools/testplans.js
CHANGED
|
@@ -79,7 +79,7 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider) {
|
|
|
79
79
|
server.tool(Test_Plan_Tools.create_test_case, "Creates a new test case work item.", {
|
|
80
80
|
project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
|
|
81
81
|
title: z.string().describe("The title of the test case."),
|
|
82
|
-
steps: z.string().optional().describe("The steps to reproduce the test case. Make sure to format each step as '1. Step one
|
|
82
|
+
steps: z.string().optional().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"),
|
|
83
83
|
priority: z.number().optional().describe("The priority of the test case."),
|
|
84
84
|
areaPath: z.string().optional().describe("The area path for the test case."),
|
|
85
85
|
iterationPath: z.string().optional().describe("The iteration path for the test case."),
|
|
@@ -165,17 +165,21 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider) {
|
|
|
165
165
|
* Helper function to convert steps text to XML format required
|
|
166
166
|
*/
|
|
167
167
|
function convertStepsToXml(steps) {
|
|
168
|
+
// Accepts steps in the format: '1. Step one|Expected result one\n2. Step two|Expected result two'
|
|
168
169
|
const stepsLines = steps.split("\n").filter((line) => line.trim() !== "");
|
|
169
170
|
let xmlSteps = `<steps id="0" last="${stepsLines.length}">`;
|
|
170
171
|
for (let i = 0; i < stepsLines.length; i++) {
|
|
171
172
|
const stepLine = stepsLines[i].trim();
|
|
172
173
|
if (stepLine) {
|
|
173
|
-
|
|
174
|
-
const
|
|
174
|
+
// Split step and expected result by '|', fallback to default if not provided
|
|
175
|
+
const [stepPart, expectedPart] = stepLine.split("|").map((s) => s.trim());
|
|
176
|
+
const stepMatch = stepPart.match(/^(\d+)\.\s*(.+)$/);
|
|
177
|
+
const stepText = stepMatch ? stepMatch[2] : stepPart;
|
|
178
|
+
const expectedText = expectedPart || "Verify step completes successfully";
|
|
175
179
|
xmlSteps += `
|
|
176
180
|
<step id="${i + 1}" type="ActionStep">
|
|
177
181
|
<parameterizedString isformatted="true">${escapeXml(stepText)}</parameterizedString>
|
|
178
|
-
<parameterizedString isformatted="true"
|
|
182
|
+
<parameterizedString isformatted="true">${escapeXml(expectedText)}</parameterizedString>
|
|
179
183
|
</step>`;
|
|
180
184
|
}
|
|
181
185
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Copyright (c) Microsoft Corporation.
|
|
2
|
+
// Licensed under the MIT License.
|
|
3
|
+
import { packageVersion } from "./version.js";
|
|
4
|
+
export const apiVersion = "7.2-preview.1";
|
|
5
|
+
export const batchApiVersion = "5.0";
|
|
6
|
+
export const userAgent = `AzureDevOps.MCP/${packageVersion} (local)`;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Copyright (c) Microsoft Corporation.
|
|
2
|
+
// Licensed under the MIT License.
|
|
3
|
+
import { describe, expect, it } from '@jest/globals';
|
|
4
|
+
import { configureWikiTools } from '../../../src/tools/wiki';
|
|
5
|
+
describe("configureWikiTools", () => {
|
|
6
|
+
let server;
|
|
7
|
+
let tokenProvider;
|
|
8
|
+
let connectionProvider;
|
|
9
|
+
let mockConnection;
|
|
10
|
+
let mockWikiApi;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
server = { tool: jest.fn() };
|
|
13
|
+
tokenProvider = jest.fn();
|
|
14
|
+
mockWikiApi = {
|
|
15
|
+
getWiki: jest.fn(),
|
|
16
|
+
getAllWikis: jest.fn(),
|
|
17
|
+
getPagesBatch: jest.fn(),
|
|
18
|
+
getPageText: jest.fn(),
|
|
19
|
+
};
|
|
20
|
+
mockConnection = {
|
|
21
|
+
getWikiApi: jest.fn().mockResolvedValue(mockWikiApi),
|
|
22
|
+
};
|
|
23
|
+
connectionProvider = jest.fn().mockResolvedValue(mockConnection);
|
|
24
|
+
});
|
|
25
|
+
describe("tool registration", () => {
|
|
26
|
+
it("registers wiki tools on the server", () => {
|
|
27
|
+
configureWikiTools(server, tokenProvider, connectionProvider);
|
|
28
|
+
expect(server.tool).toHaveBeenCalled();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe("get_wiki_page_content tool", () => {
|
|
32
|
+
it("should call getPageText with the correct parameters and return the expected result", async () => {
|
|
33
|
+
configureWikiTools(server, tokenProvider, connectionProvider);
|
|
34
|
+
const call = server.tool.mock.calls.find(([toolName]) => toolName === "ado_get_wiki_page_content");
|
|
35
|
+
if (!call)
|
|
36
|
+
throw new Error("ado_get_wiki_page_content tool not registered");
|
|
37
|
+
const [, , , handler] = call;
|
|
38
|
+
// Mock a stream-like object for getPageText
|
|
39
|
+
const mockStream = {
|
|
40
|
+
setEncoding: jest.fn(),
|
|
41
|
+
on: function (event, cb) {
|
|
42
|
+
if (event === "data") {
|
|
43
|
+
setImmediate(() => cb("mock page text"));
|
|
44
|
+
}
|
|
45
|
+
if (event === "end") {
|
|
46
|
+
setImmediate(() => cb());
|
|
47
|
+
}
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
mockWikiApi.getPageText.mockResolvedValue(mockStream);
|
|
52
|
+
const params = {
|
|
53
|
+
wikiIdentifier: "wiki1",
|
|
54
|
+
project: "proj1",
|
|
55
|
+
path: "/page1"
|
|
56
|
+
};
|
|
57
|
+
const result = await handler(params);
|
|
58
|
+
expect(mockWikiApi.getPageText).toHaveBeenCalledWith("proj1", "wiki1", "/page1", undefined, undefined, true);
|
|
59
|
+
expect(result.content[0].text).toBe("\"mock page text\"");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe("list_wiki_pages tool", () => {
|
|
63
|
+
it("should call getPagesBatch with the correct parameters and return the expected result", async () => {
|
|
64
|
+
configureWikiTools(server, tokenProvider, connectionProvider);
|
|
65
|
+
const call = server.tool.mock.calls.find(([toolName]) => toolName === "ado_list_wiki_pages");
|
|
66
|
+
if (!call)
|
|
67
|
+
throw new Error("ado_list_wiki_pages tool not registered");
|
|
68
|
+
const [, , , handler] = call;
|
|
69
|
+
mockWikiApi.getPagesBatch.mockResolvedValue({ value: ["page1", "page2"] });
|
|
70
|
+
const params = {
|
|
71
|
+
wikiIdentifier: "wiki2",
|
|
72
|
+
project: "proj2",
|
|
73
|
+
top: 10,
|
|
74
|
+
continuationToken: "token123",
|
|
75
|
+
pageViewsForDays: 7
|
|
76
|
+
};
|
|
77
|
+
const result = await handler(params);
|
|
78
|
+
const parsedResult = JSON.parse(result.content[0].text);
|
|
79
|
+
expect(mockWikiApi.getPagesBatch).toHaveBeenCalledWith({
|
|
80
|
+
top: 10,
|
|
81
|
+
continuationToken: "token123",
|
|
82
|
+
pageViewsForDays: 7
|
|
83
|
+
}, "proj2", "wiki2");
|
|
84
|
+
expect(parsedResult.value).toEqual(["page1", "page2"]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|