@azure-devops/mcp 2.10.0 → 2.11.0-nightly.20261022
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 +6 -25
- package/dist/auth.js +2 -103
- package/dist/index.js +0 -0
- package/dist/logger.js +0 -0
- package/dist/oauth.js +130 -0
- package/dist/org-tenants.js +0 -0
- package/dist/prompts.js +0 -0
- package/dist/shared/tool-annotations.js +85 -0
- package/dist/tools/pipelines.js +51 -12
- package/dist/tools/repositories.js +208 -26
- package/dist/tools/search.js +26 -3
- package/dist/tools/test-plans.js +55 -18
- package/dist/tools/wiki.js +9 -9
- package/dist/tools/work-items.js +149 -31
- package/dist/tools.js +20 -17
- package/dist/useragent.js +0 -0
- package/dist/utils.js +0 -0
- package/dist/version.js +1 -1
- package/package.json +5 -3
- package/dist/shared/command.js +0 -34
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Copyright (c) Microsoft Corporation.
|
|
2
2
|
// Licensed under the MIT License.
|
|
3
|
-
import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus, GitPullRequestMergeStrategy, VersionControlRecursionType, } from "azure-devops-node-api/interfaces/GitInterfaces.js";
|
|
3
|
+
import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus, GitPullRequestMergeStrategy, ItemContentType, VersionControlChangeType, VersionControlRecursionType, } from "azure-devops-node-api/interfaces/GitInterfaces.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { getCurrentUserDetails, getUserIdFromEmail } from "./auth.js";
|
|
6
6
|
import { extractAdoStreamError, getEnumKeys, streamToString, apiVersion } from "../utils.js";
|
|
@@ -9,9 +9,11 @@ import { createExternalContentResponse } from "../shared/content-safety.js";
|
|
|
9
9
|
const REPO_TOOLS = {
|
|
10
10
|
repo_repository: "repo_repository",
|
|
11
11
|
repo_pull_request: "repo_pull_request",
|
|
12
|
+
repo_pull_request_org: "repo_pull_request_org",
|
|
12
13
|
repo_pull_request_thread: "repo_pull_request_thread",
|
|
13
14
|
repo_branch: "repo_branch",
|
|
14
15
|
repo_file: "repo_file",
|
|
16
|
+
repo_file_write: "repo_file_write",
|
|
15
17
|
repo_search_commits: "repo_search_commits",
|
|
16
18
|
repo_pull_request_write: "repo_pull_request_write",
|
|
17
19
|
repo_pull_request_thread_write: "repo_pull_request_thread_write",
|
|
@@ -36,10 +38,8 @@ function trimPullRequestThread(thread) {
|
|
|
36
38
|
pullRequestThreadContext: thread.pullRequestThreadContext,
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
|
-
function
|
|
40
|
-
return
|
|
41
|
-
?.filter((comment) => !comment.isDeleted)
|
|
42
|
-
?.map((comment) => ({
|
|
41
|
+
function trimComment(comment) {
|
|
42
|
+
return {
|
|
43
43
|
id: comment.id,
|
|
44
44
|
author: {
|
|
45
45
|
displayName: comment.author?.displayName,
|
|
@@ -49,7 +49,10 @@ function trimComments(comments) {
|
|
|
49
49
|
publishedDate: comment.publishedDate,
|
|
50
50
|
lastUpdatedDate: comment.lastUpdatedDate,
|
|
51
51
|
lastContentUpdatedDate: comment.lastContentUpdatedDate,
|
|
52
|
-
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function trimComments(comments) {
|
|
55
|
+
return comments?.filter((comment) => !comment.isDeleted)?.map(trimComment);
|
|
53
56
|
}
|
|
54
57
|
function pullRequestStatusStringToInt(status) {
|
|
55
58
|
switch (status) {
|
|
@@ -96,6 +99,36 @@ function trimPullRequest(pr, includeDescription = false) {
|
|
|
96
99
|
project: pr.repository?.project?.name,
|
|
97
100
|
};
|
|
98
101
|
}
|
|
102
|
+
function trimOrganizationPullRequest(pr) {
|
|
103
|
+
return {
|
|
104
|
+
...trimPullRequest(pr),
|
|
105
|
+
projectId: pr.repository?.project?.id,
|
|
106
|
+
repositoryId: pr.repository?.id,
|
|
107
|
+
createdBy: {
|
|
108
|
+
id: pr.createdBy?.id,
|
|
109
|
+
displayName: pr.createdBy?.displayName,
|
|
110
|
+
uniqueName: pr.createdBy?.uniqueName,
|
|
111
|
+
},
|
|
112
|
+
reviewers: pr.reviewers?.map((reviewer) => ({
|
|
113
|
+
id: reviewer.id,
|
|
114
|
+
displayName: reviewer.displayName,
|
|
115
|
+
uniqueName: reviewer.uniqueName,
|
|
116
|
+
vote: reviewer.vote,
|
|
117
|
+
isRequired: reviewer.isRequired,
|
|
118
|
+
})),
|
|
119
|
+
webUrl: pr.remoteUrl,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async function getOrganizationPullRequests(connection, identityFilter, identityId, status, skip, top) {
|
|
123
|
+
const url = new URL(`${connection.serverUrl.replace(/\/$/, "")}/_apis/git/pullrequests`);
|
|
124
|
+
url.searchParams.set("api-version", "7.1");
|
|
125
|
+
url.searchParams.set("$skip", String(skip));
|
|
126
|
+
url.searchParams.set("$top", String(top));
|
|
127
|
+
url.searchParams.set(`searchCriteria.${identityFilter}`, identityId);
|
|
128
|
+
url.searchParams.set("searchCriteria.status", String(status));
|
|
129
|
+
const response = await connection.rest.get(url.toString(), { deserializeDates: true });
|
|
130
|
+
return response.result?.value ?? [];
|
|
131
|
+
}
|
|
99
132
|
function buildVersionDescriptor(version, versionType) {
|
|
100
133
|
if (!version)
|
|
101
134
|
return undefined;
|
|
@@ -159,18 +192,22 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
159
192
|
}
|
|
160
193
|
});
|
|
161
194
|
// --- repo_pull_request -----------------------------------------------------
|
|
162
|
-
server.tool(REPO_TOOLS.repo_pull_request, "Retrieve pull request data.
|
|
195
|
+
server.tool(REPO_TOOLS.repo_pull_request, "Retrieve pull request data for a specific repository or project, or retrieve one pull request by ID. For the authenticated user's active pull requests across the entire organization, use repo_pull_request_org.", {
|
|
163
196
|
action: z
|
|
164
197
|
.enum(["get", "list", "list_by_commits"])
|
|
165
198
|
.describe("The action to perform. Options: get (get a pull request by ID), list (list pull requests in a repository or project), list_by_commits (find pull requests that contain specific commit IDs)."),
|
|
166
199
|
repositoryId: z.string().optional().describe("The ID or name of the repository. Required for get. Optional for list. When using a name instead of a GUID, project must also be provided."),
|
|
167
200
|
pullRequestId: z.coerce.number().min(1).optional().describe("The ID of the pull request. Required for get."),
|
|
168
|
-
project: z.string().optional().describe("Project ID or
|
|
201
|
+
project: z.string().optional().describe("Project ID or name. Required for list_by_commits and when repositoryId is a name. Optional when repositoryId is a GUID."),
|
|
169
202
|
includeWorkItemRefs: z.boolean().optional().default(false).describe("Whether to include work item references. Used for get."),
|
|
170
203
|
includeLabels: z.boolean().optional().default(false).describe("Whether to include labels. Used for get."),
|
|
171
|
-
includeChangedFiles: z
|
|
172
|
-
|
|
173
|
-
|
|
204
|
+
includeChangedFiles: z
|
|
205
|
+
.boolean()
|
|
206
|
+
.optional()
|
|
207
|
+
.default(false)
|
|
208
|
+
.describe("Whether to include the list of changed files. When changedFilesSummary returns a nextSkip greater than 0, pass nextSkip and nextTop back as skip and top for the next page. Used for get."),
|
|
209
|
+
top: z.coerce.number().default(100).describe("The maximum number of pull requests to return (list), or of changed files to return (get with includeChangedFiles). Defaults to 100."),
|
|
210
|
+
skip: z.coerce.number().default(0).describe("The number of pull requests to skip (list), or of changed files to skip (get with includeChangedFiles). Defaults to 0."),
|
|
174
211
|
created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user. Used for list."),
|
|
175
212
|
created_by_user: z.string().optional().describe("Filter pull requests created by a specific user email. Used for list."),
|
|
176
213
|
i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer. Used for list."),
|
|
@@ -190,13 +227,19 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
190
227
|
.describe("Type of commit query. Used for list_by_commits."),
|
|
191
228
|
}, async ({ action, repositoryId, pullRequestId, project, includeWorkItemRefs, includeLabels, includeChangedFiles, top, skip, created_by_me, created_by_user, i_am_reviewer, user_is_reviewer, status, sourceRefName, targetRefName, repository, commits, queryType, }) => {
|
|
192
229
|
try {
|
|
193
|
-
const connection = await connectionProvider();
|
|
194
|
-
const gitApi = await connection.getGitApi();
|
|
195
230
|
if (action === "get") {
|
|
196
231
|
if (!repositoryId)
|
|
197
232
|
return { content: [{ type: "text", text: "repositoryId is required for get" }], isError: true };
|
|
198
233
|
if (!pullRequestId)
|
|
199
234
|
return { content: [{ type: "text", text: "pullRequestId is required for get" }], isError: true };
|
|
235
|
+
if (!project && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(repositoryId)) {
|
|
236
|
+
return {
|
|
237
|
+
content: [{ type: "text", text: "When repositoryId is a repository name, project is required. Provide the project name or ID, or use the repository GUID." }],
|
|
238
|
+
isError: true,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const connection = await connectionProvider();
|
|
242
|
+
const gitApi = await connection.getGitApi();
|
|
200
243
|
const pullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project, undefined, undefined, undefined, undefined, includeWorkItemRefs);
|
|
201
244
|
let enhancedResponse = { ...pullRequest };
|
|
202
245
|
if (includeLabels) {
|
|
@@ -218,7 +261,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
218
261
|
if (iterations?.length) {
|
|
219
262
|
const latestIteration = iterations[iterations.length - 1];
|
|
220
263
|
if (latestIteration.id != null) {
|
|
221
|
-
const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, latestIteration.id, project);
|
|
264
|
+
const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, latestIteration.id, project, top, skip);
|
|
222
265
|
enhancedResponse = {
|
|
223
266
|
...enhancedResponse,
|
|
224
267
|
changedFilesSummary: {
|
|
@@ -246,6 +289,8 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
246
289
|
}
|
|
247
290
|
return createExternalContentResponse(enhancedResponse, "pull request");
|
|
248
291
|
}
|
|
292
|
+
const connection = await connectionProvider();
|
|
293
|
+
const gitApi = await connection.getGitApi();
|
|
249
294
|
if (action === "list") {
|
|
250
295
|
if (!repositoryId && !project) {
|
|
251
296
|
return { content: [{ type: "text", text: "Either repositoryId or project must be provided." }], isError: true };
|
|
@@ -319,6 +364,41 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
319
364
|
return { content: [{ type: "text", text: `Error with pull request operation: ${errorMessage}` }], isError: true };
|
|
320
365
|
}
|
|
321
366
|
});
|
|
367
|
+
// --- repo_pull_request_org -------------------------------------------------
|
|
368
|
+
server.tool(REPO_TOOLS.repo_pull_request_org, "List active pull requests across all projects and repositories in the organization for the authenticated user. Use this tool for organization-wide requests such as 'my pull requests' or 'pull requests awaiting my review'. For a specific project, repository, or pull request ID, use repo_pull_request.", {
|
|
369
|
+
is_reviewer: z
|
|
370
|
+
.boolean()
|
|
371
|
+
.default(false)
|
|
372
|
+
.describe("Set to true for active pull requests where the authenticated user is a reviewer. Set to false for active pull requests created by the authenticated user."),
|
|
373
|
+
reviewStatus: z
|
|
374
|
+
.enum(["all", "approved", "pending"])
|
|
375
|
+
.default("all")
|
|
376
|
+
.describe("Filter by the authenticated user's reviewer vote when is_reviewer is true: all includes every vote, approved includes Approved and Approved with suggestions, and pending includes only No vote."),
|
|
377
|
+
top: z.coerce.number().default(100).describe("The maximum number of active pull requests to retrieve before applying the reviewStatus filter. Defaults to 100."),
|
|
378
|
+
skip: z.coerce.number().default(0).describe("The number of active pull requests to skip before applying the reviewStatus filter. Defaults to 0."),
|
|
379
|
+
}, async ({ is_reviewer, reviewStatus, top, skip }) => {
|
|
380
|
+
try {
|
|
381
|
+
const currentUser = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
|
|
382
|
+
const userId = currentUser.authenticatedUser.id;
|
|
383
|
+
const identityFilter = is_reviewer ? "reviewerId" : "creatorId";
|
|
384
|
+
const connection = await connectionProvider();
|
|
385
|
+
const pullRequests = await getOrganizationPullRequests(connection, identityFilter, userId, PullRequestStatus.Active, skip, top);
|
|
386
|
+
const filteredPullRequests = pullRequests.filter((pullRequest) => {
|
|
387
|
+
if (!is_reviewer || reviewStatus === "all")
|
|
388
|
+
return true;
|
|
389
|
+
const reviewer = pullRequest.reviewers?.find((item) => item.id === userId);
|
|
390
|
+
if (reviewStatus === "approved")
|
|
391
|
+
return reviewer?.vote === 10 || reviewer?.vote === 5;
|
|
392
|
+
return reviewer?.vote === 0;
|
|
393
|
+
});
|
|
394
|
+
const trimmedPullRequests = filteredPullRequests.map((pullRequest) => trimOrganizationPullRequest(pullRequest));
|
|
395
|
+
return { content: [{ type: "text", text: JSON.stringify(trimmedPullRequests, null, 2) }] };
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
399
|
+
return { content: [{ type: "text", text: `Error with organization pull request operation: ${errorMessage}` }], isError: true };
|
|
400
|
+
}
|
|
401
|
+
});
|
|
322
402
|
// --- repo_pull_request_thread ----------------------------------------------
|
|
323
403
|
server.tool(REPO_TOOLS.repo_pull_request_thread, "Retrieve pull request thread and comment data. Use the action parameter to specify the operation.", {
|
|
324
404
|
action: z.enum(["list", "list_comments"]).describe("The action to perform. Options: list (list comment threads on a pull request), list_comments (list comments in a specific thread)."),
|
|
@@ -440,8 +520,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
440
520
|
versionType: z
|
|
441
521
|
.enum(fileVersionTypeStrings)
|
|
442
522
|
.optional()
|
|
443
|
-
.
|
|
444
|
-
.describe("How to interpret the version parameter. Used for get_content. Defaults to 'Commit'."),
|
|
523
|
+
.describe("How to interpret the version parameter. Defaults to 'Commit' for get_content and 'Branch' for list_directory."),
|
|
445
524
|
recursive: z.boolean().optional().default(false).describe("Whether to list items recursively. Used for list_directory. Defaults to false."),
|
|
446
525
|
recursionDepth: z.coerce.number().min(1).optional().default(1).describe("Maximum depth for recursive listing. Used for list_directory when recursive is true. Defaults to 1."),
|
|
447
526
|
}, async ({ action, repositoryId, path, project, version, versionType, recursive, recursionDepth }) => {
|
|
@@ -451,7 +530,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
451
530
|
if (action === "get_content") {
|
|
452
531
|
if (!path)
|
|
453
532
|
return { content: [{ type: "text", text: "path is required for get_content" }], isError: true };
|
|
454
|
-
const versionDescriptor = version
|
|
533
|
+
const versionDescriptor = buildVersionDescriptor(version, versionType ?? "Commit");
|
|
455
534
|
const stream = await gitApi.getItemText(repositoryId, path, project, undefined, undefined, undefined, undefined, false, versionDescriptor, true);
|
|
456
535
|
const content = await streamToString(stream);
|
|
457
536
|
const streamError = extractAdoStreamError(content);
|
|
@@ -461,7 +540,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
461
540
|
return createExternalContentResponse(content, "repository file");
|
|
462
541
|
}
|
|
463
542
|
if (action === "list_directory") {
|
|
464
|
-
const versionDescriptor = buildVersionDescriptor(version, versionType
|
|
543
|
+
const versionDescriptor = buildVersionDescriptor(version, versionType);
|
|
465
544
|
const clampedDepth = Math.min(Math.max(recursionDepth || 1, 1), 10);
|
|
466
545
|
const recursionType = recursive ? VersionControlRecursionType.Full : VersionControlRecursionType.OneLevel;
|
|
467
546
|
const items = await gitApi.getItems(repositoryId, project, path, recursionType, true, false, false, false, versionDescriptor);
|
|
@@ -502,6 +581,63 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
502
581
|
return { content: [{ type: "text", text: `Error with file operation: ${errorMessage}` }], isError: true };
|
|
503
582
|
}
|
|
504
583
|
});
|
|
584
|
+
// --- repo_file_write -------------------------------------------------------
|
|
585
|
+
server.tool(REPO_TOOLS.repo_file_write, "Create or update a text file in an existing repository branch and commit the change.", {
|
|
586
|
+
action: z.enum(["create", "update"]).describe("Whether to create a new file or update an existing file."),
|
|
587
|
+
repositoryId: z.string().describe("The ID or name of the repository."),
|
|
588
|
+
project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name."),
|
|
589
|
+
branchName: z.string().min(1).describe("The name of the existing branch to update, without the 'refs/heads/' prefix."),
|
|
590
|
+
path: z.string().min(1).describe("The absolute repository path of the file, for example '/src/example.ts'."),
|
|
591
|
+
content: z.string().describe("The complete UTF-8 text content to write to the file."),
|
|
592
|
+
commitMessage: z.string().min(1).describe("The commit message for the file change."),
|
|
593
|
+
expectedOldObjectId: z
|
|
594
|
+
.string()
|
|
595
|
+
.optional()
|
|
596
|
+
.describe("The expected current commit ID of the branch. When provided, the push fails if the branch has moved. If omitted, the current branch head is retrieved before pushing."),
|
|
597
|
+
}, async ({ action, repositoryId, project, branchName, path, content, commitMessage, expectedOldObjectId }) => {
|
|
598
|
+
try {
|
|
599
|
+
const connection = await connectionProvider();
|
|
600
|
+
const gitApi = await connection.getGitApi();
|
|
601
|
+
const refName = `refs/heads/${branchName}`;
|
|
602
|
+
const refs = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, branchName);
|
|
603
|
+
const branch = refs.find((ref) => ref.name === refName);
|
|
604
|
+
if (!branch?.objectId) {
|
|
605
|
+
return { content: [{ type: "text", text: `Error: Branch '${branchName}' not found in repository ${repositoryId}` }], isError: true };
|
|
606
|
+
}
|
|
607
|
+
if (expectedOldObjectId && expectedOldObjectId !== branch.objectId) {
|
|
608
|
+
return { content: [{ type: "text", text: `Error: Branch '${branchName}' has moved from expected commit ${expectedOldObjectId}` }], isError: true };
|
|
609
|
+
}
|
|
610
|
+
const oldObjectId = branch.objectId;
|
|
611
|
+
const filePath = path.startsWith("/") ? path : `/${path}`;
|
|
612
|
+
const push = await gitApi.createPush({
|
|
613
|
+
refUpdates: [{ name: refName, oldObjectId }],
|
|
614
|
+
commits: [
|
|
615
|
+
{
|
|
616
|
+
comment: commitMessage,
|
|
617
|
+
changes: [
|
|
618
|
+
{
|
|
619
|
+
changeType: action === "create" ? VersionControlChangeType.Add : VersionControlChangeType.Edit,
|
|
620
|
+
item: { path: filePath },
|
|
621
|
+
newContent: { content, contentType: ItemContentType.RawText },
|
|
622
|
+
},
|
|
623
|
+
],
|
|
624
|
+
},
|
|
625
|
+
],
|
|
626
|
+
}, repositoryId, project);
|
|
627
|
+
return {
|
|
628
|
+
content: [
|
|
629
|
+
{
|
|
630
|
+
type: "text",
|
|
631
|
+
text: JSON.stringify({ pushId: push.pushId, commitId: push.commits?.[0]?.commitId, branchName, path: filePath }, null, 2),
|
|
632
|
+
},
|
|
633
|
+
],
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
638
|
+
return { content: [{ type: "text", text: `Error writing repository file: ${errorMessage}` }], isError: true };
|
|
639
|
+
}
|
|
640
|
+
});
|
|
505
641
|
// --- repo_search_commits ---------------------------------------------------
|
|
506
642
|
server.tool(REPO_TOOLS.repo_search_commits, "Search commits with filtering by text, author, date range, and more.", {
|
|
507
643
|
searchText: z.string().describe("Keywords to search for in commit messages"),
|
|
@@ -566,7 +702,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
566
702
|
targetRefName: z.string().optional().describe("The target branch name (e.g., 'refs/heads/main'). Required for create. Optional for update."),
|
|
567
703
|
title: z.string().optional().describe("The title of the pull request. Required for create. Optional for update."),
|
|
568
704
|
description: z.string().max(4000).optional().describe("The description of the pull request. Max 4000 characters. Used for create and update."),
|
|
569
|
-
isDraft: z.boolean().optional().
|
|
705
|
+
isDraft: z.boolean().optional().describe("Whether the pull request is a draft. Defaults to false for create. When omitted during update, the existing draft state is preserved."),
|
|
570
706
|
workItems: z.string().optional().describe("Work item IDs to associate, space-separated. Used for create."),
|
|
571
707
|
forkSourceRepositoryId: z.string().optional().describe("The ID of the fork repository. Used for create."),
|
|
572
708
|
labels: z.array(z.string()).optional().describe("Array of label names. Used for create and update."),
|
|
@@ -602,7 +738,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
602
738
|
"Try the repository GUID from repo_repository (list action) instead of the Project/RepoName slash format.";
|
|
603
739
|
const forkSource = forkSourceRepositoryId ? { repository: { id: forkSourceRepositoryId } } : undefined;
|
|
604
740
|
const labelDefinitions = labels ? labels.map((label) => ({ name: label })) : undefined;
|
|
605
|
-
let pullRequest = await gitApi.createPullRequest({ sourceRefName, targetRefName, title, description, isDraft, workItemRefs, forkSource, labels: labelDefinitions, supportsIterations: true }, repositoryId, project);
|
|
741
|
+
let pullRequest = await gitApi.createPullRequest({ sourceRefName, targetRefName, title, description, isDraft: isDraft ?? false, workItemRefs, forkSource, labels: labelDefinitions, supportsIterations: true }, repositoryId, project);
|
|
606
742
|
if (!pullRequest) {
|
|
607
743
|
const prs = await gitApi.getPullRequests(repositoryId, { sourceRefName, targetRefName, status: PullRequestStatus.Active }, project, undefined, 0, 1);
|
|
608
744
|
if (prs && prs.length > 0) {
|
|
@@ -766,14 +902,13 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
766
902
|
status: z
|
|
767
903
|
.enum(getEnumKeys(CommentThreadStatus))
|
|
768
904
|
.optional()
|
|
769
|
-
.
|
|
770
|
-
.describe("The thread status. Used for create (defaults to 'Active') and required for update_status."),
|
|
905
|
+
.describe("The thread status. Used for create (defaults to 'Active'), optionally for reply, and required for update_status."),
|
|
771
906
|
filePath: z.string().optional().describe("The file path for the comment thread. Used for create."),
|
|
772
907
|
fullResponse: z.boolean().optional().default(false).describe("Return full JSON response. Used for reply and update."),
|
|
773
908
|
rightFileStartLine: z.coerce.number().min(1).optional().describe("Start line in the right file. Used for create."),
|
|
774
909
|
rightFileStartOffset: z.number().optional().describe("Start character offset in the right file. Used for create."),
|
|
775
|
-
rightFileEndLine: z.number().optional().describe("End line in the right file. Used for create."),
|
|
776
|
-
rightFileEndOffset: z.number().optional().describe("End character offset in the right file. Used for create."),
|
|
910
|
+
rightFileEndLine: z.number().optional().describe("End line in the right file. Used for create. Must be supplied together with rightFileEndOffset."),
|
|
911
|
+
rightFileEndOffset: z.number().optional().describe("End character offset in the right file. Used for create. Must be supplied together with rightFileEndLine."),
|
|
777
912
|
changeTrackingId: z.coerce.number().int().min(1).optional().describe("The file change tracking ID from the pull request iteration changes. Used for create."),
|
|
778
913
|
firstComparingIteration: z.coerce.number().int().min(0).optional().describe("The iteration on the left side of the diff. Used for create."),
|
|
779
914
|
secondComparingIteration: z.coerce.number().int().min(1).optional().describe("The iteration on the right side of the diff. Used for create."),
|
|
@@ -837,7 +972,8 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
837
972
|
const pullRequestThreadContext = changeTrackingId !== undefined && firstComparingIteration !== undefined && secondComparingIteration !== undefined
|
|
838
973
|
? { changeTrackingId, iterationContext: { firstComparingIteration, secondComparingIteration } }
|
|
839
974
|
: undefined;
|
|
840
|
-
const
|
|
975
|
+
const threadStatus = status === undefined ? CommentThreadStatus.Active : CommentThreadStatus[status];
|
|
976
|
+
const thread = await gitApi.createThread({ comments: [{ content, commentType: 1 }], threadContext, pullRequestThreadContext, status: threadStatus }, repositoryId, pullRequestId, project);
|
|
841
977
|
return { content: [{ type: "text", text: JSON.stringify(trimPullRequestThread(thread), null, 2) }] };
|
|
842
978
|
}
|
|
843
979
|
if (action === "reply") {
|
|
@@ -849,9 +985,55 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
849
985
|
if (!comment) {
|
|
850
986
|
return { content: [{ type: "text", text: `Error: Failed to add comment to thread ${threadId}. The comment was not created successfully.` }], isError: true };
|
|
851
987
|
}
|
|
988
|
+
if (status !== undefined) {
|
|
989
|
+
let statusUpdateError;
|
|
990
|
+
let resultingThreadStatus;
|
|
991
|
+
try {
|
|
992
|
+
const thread = await gitApi.updateThread({ status: CommentThreadStatus[status] }, repositoryId, pullRequestId, threadId, project);
|
|
993
|
+
if (!thread) {
|
|
994
|
+
statusUpdateError = `Thread ${threadId} was not updated successfully.`;
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
resultingThreadStatus = thread.status;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
catch (error) {
|
|
1001
|
+
statusUpdateError = error instanceof Error ? error.message : "Unknown error occurred";
|
|
1002
|
+
}
|
|
1003
|
+
if (statusUpdateError !== undefined) {
|
|
1004
|
+
return {
|
|
1005
|
+
content: [
|
|
1006
|
+
{
|
|
1007
|
+
type: "text",
|
|
1008
|
+
text: JSON.stringify({
|
|
1009
|
+
message: `Comment ${comment.id} was posted successfully, but the thread status update failed. Do not retry the reply.`,
|
|
1010
|
+
comment: trimComment(comment),
|
|
1011
|
+
statusUpdate: {
|
|
1012
|
+
succeeded: false,
|
|
1013
|
+
requestedStatus: status,
|
|
1014
|
+
error: statusUpdateError,
|
|
1015
|
+
},
|
|
1016
|
+
}, null, 2),
|
|
1017
|
+
},
|
|
1018
|
+
],
|
|
1019
|
+
isError: true,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
return {
|
|
1023
|
+
content: [
|
|
1024
|
+
{
|
|
1025
|
+
type: "text",
|
|
1026
|
+
text: JSON.stringify({
|
|
1027
|
+
comment: trimComment(comment),
|
|
1028
|
+
status: resultingThreadStatus,
|
|
1029
|
+
}, null, 2),
|
|
1030
|
+
},
|
|
1031
|
+
],
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
852
1034
|
if (fullResponse)
|
|
853
1035
|
return { content: [{ type: "text", text: JSON.stringify(comment, null, 2) }] };
|
|
854
|
-
return { content: [{ type: "text", text:
|
|
1036
|
+
return { content: [{ type: "text", text: JSON.stringify(trimComment(comment), null, 2) }] };
|
|
855
1037
|
}
|
|
856
1038
|
if (action === "update") {
|
|
857
1039
|
if (!threadId)
|
package/dist/tools/search.js
CHANGED
|
@@ -63,7 +63,7 @@ function configureSearchTools(server, tokenProvider, connectionProvider, userAge
|
|
|
63
63
|
const combinedResults = await fetchCombinedResults(resultJson.results ?? [], gitApi);
|
|
64
64
|
return createExternalContentResponse(resultText + JSON.stringify(combinedResults), "code search results");
|
|
65
65
|
});
|
|
66
|
-
server.tool(SEARCH_TOOLS.search_wiki, "Search Azure DevOps Wiki for a given search text", {
|
|
66
|
+
server.tool(SEARCH_TOOLS.search_wiki, "Search Azure DevOps Wiki for a given search text. Results include the repository path and a wiki-relative pagePath that can be used with the wiki tool.", {
|
|
67
67
|
searchText: z.string().describe("Keywords to search for wiki pages"),
|
|
68
68
|
project: z.array(z.string()).optional().describe("Filter by projects"),
|
|
69
69
|
wiki: z.array(z.string()).optional().describe("Filter by wiki names"),
|
|
@@ -99,8 +99,10 @@ function configureSearchTools(server, tokenProvider, connectionProvider, userAge
|
|
|
99
99
|
if (!response.ok) {
|
|
100
100
|
throw new Error(`Azure DevOps Wiki Search API error: ${response.status} ${response.statusText}`);
|
|
101
101
|
}
|
|
102
|
-
const result = await response.
|
|
103
|
-
|
|
102
|
+
const result = (await response.json());
|
|
103
|
+
const results = result.results?.map(addWikiPagePath);
|
|
104
|
+
const enrichedResult = results ? { ...result, results } : result;
|
|
105
|
+
return createExternalContentResponse(JSON.stringify(enrichedResult), "wiki search results");
|
|
104
106
|
});
|
|
105
107
|
server.tool(SEARCH_TOOLS.search_workitem, "Get Azure DevOps Work Item search results for a given search text", {
|
|
106
108
|
searchText: z.string().describe("Search text to find in work items"),
|
|
@@ -151,6 +153,27 @@ function configureSearchTools(server, tokenProvider, connectionProvider, userAge
|
|
|
151
153
|
return createExternalContentResponse(result, "work item search results");
|
|
152
154
|
});
|
|
153
155
|
}
|
|
156
|
+
function addWikiPagePath(result) {
|
|
157
|
+
if (!result || result.pagePath || !result.path || !result.wiki?.mappedPath) {
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
const mappedPath = result.wiki.mappedPath === "/" ? "" : result.wiki.mappedPath.replace(/\/+$/, "");
|
|
161
|
+
if (mappedPath && result.path !== mappedPath && !result.path.startsWith(`${mappedPath}/`)) {
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
let relativePath;
|
|
165
|
+
try {
|
|
166
|
+
relativePath = decodeURIComponent(result.path.slice(mappedPath.length).replace(/\.md$/i, ""));
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error instanceof URIError) {
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
const pagePath = relativePath.startsWith("/") ? relativePath : `/${relativePath}`;
|
|
175
|
+
return { ...result, pagePath };
|
|
176
|
+
}
|
|
154
177
|
async function fetchCombinedResults(topSearchResults, gitApi) {
|
|
155
178
|
const combinedResults = [];
|
|
156
179
|
for (const searchResult of topSearchResults) {
|
package/dist/tools/test-plans.js
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
// Copyright (c) Microsoft Corporation.
|
|
2
2
|
// Licensed under the MIT License.
|
|
3
|
+
import { TestOutcome } from "azure-devops-node-api/interfaces/TestInterfaces.js";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { apiVersion } from "../utils.js";
|
|
6
|
+
const TEST_RESULT_PAGE_SIZE = 1_000;
|
|
7
|
+
const TEST_RESULT_CONCURRENCY = 5;
|
|
8
|
+
const TEST_OUTCOMES = [
|
|
9
|
+
"Unspecified",
|
|
10
|
+
"None",
|
|
11
|
+
"Passed",
|
|
12
|
+
"Failed",
|
|
13
|
+
"Inconclusive",
|
|
14
|
+
"Timeout",
|
|
15
|
+
"Aborted",
|
|
16
|
+
"Blocked",
|
|
17
|
+
"NotExecuted",
|
|
18
|
+
"Warning",
|
|
19
|
+
"Error",
|
|
20
|
+
"NotApplicable",
|
|
21
|
+
"Paused",
|
|
22
|
+
"InProgress",
|
|
23
|
+
"NotImpacted",
|
|
24
|
+
];
|
|
5
25
|
const TEST_PLAN_TOOLS = {
|
|
6
26
|
testplan: "testplan",
|
|
7
27
|
test_results_from_build_id: "testplan_show_test_results_from_build_id",
|
|
@@ -137,33 +157,50 @@ function configureTestPlanTools(server, tokenProvider, connectionProvider, userA
|
|
|
137
157
|
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.", {
|
|
138
158
|
project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."),
|
|
139
159
|
buildid: z.coerce.number().min(1).describe("The ID of the build."),
|
|
140
|
-
outcomes: z.array(z.
|
|
160
|
+
outcomes: z.array(z.enum(TEST_OUTCOMES)).optional().describe("Filter results by test outcome, e.g. ['Failed', 'Passed', 'Aborted']."),
|
|
141
161
|
}, async ({ project, buildid, outcomes }) => {
|
|
142
162
|
try {
|
|
143
163
|
const connection = await connectionProvider();
|
|
144
164
|
const testResultsApi = await connection.getTestResultsApi();
|
|
145
|
-
const
|
|
146
|
-
const
|
|
165
|
+
const buildUri = `vstfs:///Build/Build/${buildid}`;
|
|
166
|
+
const testRuns = [];
|
|
167
|
+
for (let skip = 0;; skip += TEST_RESULT_PAGE_SIZE) {
|
|
168
|
+
const page = await testResultsApi.getTestRuns(project, buildUri, undefined, undefined, undefined, undefined, undefined, skip, TEST_RESULT_PAGE_SIZE);
|
|
169
|
+
testRuns.push(...page);
|
|
170
|
+
if (page.length < TEST_RESULT_PAGE_SIZE) {
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const outcomeFilter = outcomes?.map((outcome) => TestOutcome[outcome]);
|
|
175
|
+
const runsWithIds = testRuns.filter((run) => run.id !== undefined);
|
|
147
176
|
const allResults = [];
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
177
|
+
for (let index = 0; index < runsWithIds.length; index += TEST_RESULT_CONCURRENCY) {
|
|
178
|
+
const batch = runsWithIds.slice(index, index + TEST_RESULT_CONCURRENCY);
|
|
179
|
+
const batchResults = await Promise.all(batch.map(async (run) => {
|
|
180
|
+
const results = [];
|
|
181
|
+
for (let skip = 0;; skip += TEST_RESULT_PAGE_SIZE) {
|
|
182
|
+
const page = await testResultsApi.getTestResults(project, run.id, undefined, skip, TEST_RESULT_PAGE_SIZE, outcomeFilter);
|
|
183
|
+
results.push(...page.map((result) => ({ result, runId: run.id })));
|
|
184
|
+
if (page.length < TEST_RESULT_PAGE_SIZE) {
|
|
185
|
+
break;
|
|
153
186
|
}
|
|
154
187
|
}
|
|
188
|
+
return results;
|
|
189
|
+
}));
|
|
190
|
+
for (const results of batchResults) {
|
|
191
|
+
allResults.push(...results);
|
|
155
192
|
}
|
|
156
193
|
}
|
|
157
|
-
const formattedResults = allResults.map((
|
|
158
|
-
id:
|
|
159
|
-
testCaseTitle:
|
|
160
|
-
outcome:
|
|
161
|
-
errorMessage:
|
|
162
|
-
stackTrace:
|
|
163
|
-
automatedTestName:
|
|
164
|
-
automatedTestStorage:
|
|
165
|
-
durationInMs:
|
|
166
|
-
runId
|
|
194
|
+
const formattedResults = allResults.map(({ result, runId }) => ({
|
|
195
|
+
id: result.id,
|
|
196
|
+
testCaseTitle: result.testCaseTitle,
|
|
197
|
+
outcome: result.outcome,
|
|
198
|
+
errorMessage: result.errorMessage,
|
|
199
|
+
stackTrace: result.stackTrace,
|
|
200
|
+
automatedTestName: result.automatedTestName,
|
|
201
|
+
automatedTestStorage: result.automatedTestStorage,
|
|
202
|
+
durationInMs: result.durationInMs,
|
|
203
|
+
runId,
|
|
167
204
|
}));
|
|
168
205
|
return {
|
|
169
206
|
content: [{ type: "text", text: JSON.stringify(formattedResults, null, 2) }],
|
package/dist/tools/wiki.js
CHANGED
|
@@ -19,7 +19,7 @@ function configureWikiTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
19
19
|
.string()
|
|
20
20
|
.optional()
|
|
21
21
|
.describe("The full URL of the wiki page. Used for get_page_content. If provided, wikiIdentifier, project, and path are ignored. Supported patterns: https://dev.azure.com/{org}/{project}/_wiki/wikis/{wikiIdentifier}?pagePath=%2FMy%20Page and https://dev.azure.com/{org}/{project}/_wiki/wikis/{wikiIdentifier}/{pageId}/Page-Title"),
|
|
22
|
-
top: z.coerce.number().default(20).describe("
|
|
22
|
+
top: z.coerce.number().default(20).describe("Maximum number of pages to return for list_pages (up to 100; defaults to 20)."),
|
|
23
23
|
continuationToken: z.string().optional().describe("Token for pagination to retrieve the next set of pages. Used for list_pages."),
|
|
24
24
|
pageViewsForDays: z.coerce.number().optional().describe("Number of days to retrieve page views for. Used for list_pages. If not specified, page views are not included."),
|
|
25
25
|
recursionLevel: z
|
|
@@ -178,7 +178,7 @@ function configureWikiTools(server, tokenProvider, connectionProvider, userAgent
|
|
|
178
178
|
catch { }
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
|
-
if (
|
|
181
|
+
if (pageContent === undefined) {
|
|
182
182
|
if (!resolvedPath) {
|
|
183
183
|
resolvedPath = "/";
|
|
184
184
|
}
|
|
@@ -346,18 +346,18 @@ function parseWikiUrl(url) {
|
|
|
346
346
|
if (idx < 1 || segments[idx + 1] !== "wikis") {
|
|
347
347
|
return { error: "URL does not match expected wiki pattern (missing /_wiki/wikis/ segment)." };
|
|
348
348
|
}
|
|
349
|
-
const
|
|
350
|
-
const
|
|
351
|
-
if (!
|
|
349
|
+
const encodedProject = segments[idx - 1];
|
|
350
|
+
const encodedWikiIdentifier = segments[idx + 2];
|
|
351
|
+
if (!encodedProject || !encodedWikiIdentifier) {
|
|
352
352
|
return { error: "Could not extract project or wikiIdentifier from URL." };
|
|
353
353
|
}
|
|
354
|
+
const project = decodeURIComponent(encodedProject);
|
|
355
|
+
const wikiIdentifier = decodeURIComponent(encodedWikiIdentifier);
|
|
354
356
|
// Query form with pagePath
|
|
355
357
|
const pagePathParam = u.searchParams.get("pagePath");
|
|
356
358
|
if (pagePathParam) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
decoded = "/" + decoded;
|
|
360
|
-
return { project, wikiIdentifier, pagePath: decoded };
|
|
359
|
+
const pagePath = pagePathParam.startsWith("/") ? pagePathParam : `/${pagePathParam}`;
|
|
360
|
+
return { project, wikiIdentifier, pagePath };
|
|
361
361
|
}
|
|
362
362
|
// Path ID form: .../wikis/{wikiIdentifier}/{pageId}/...
|
|
363
363
|
const afterWiki = segments.slice(idx + 3); // elements after wikiIdentifier
|