@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.
@@ -1,33 +1,20 @@
1
1
  // Copyright (c) Microsoft Corporation.
2
2
  // Licensed under the MIT License.
3
- import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus, GitPullRequestMergeStrategy, VersionControlChangeType, VersionControlRecursionType, } from "azure-devops-node-api/interfaces/GitInterfaces.js";
3
+ import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus, GitPullRequestMergeStrategy, 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";
7
7
  import { orgName } from "../index.js";
8
8
  const REPO_TOOLS = {
9
- list_repos_by_project: "repo_list_repos_by_project",
10
- list_pull_requests_by_repo_or_project: "repo_list_pull_requests_by_repo_or_project",
11
- list_branches_by_repo: "repo_list_branches_by_repo",
12
- list_my_branches_by_repo: "repo_list_my_branches_by_repo",
13
- list_pull_request_threads: "repo_list_pull_request_threads",
14
- list_pull_request_thread_comments: "repo_list_pull_request_thread_comments",
15
- get_repo_by_name_or_id: "repo_get_repo_by_name_or_id",
16
- get_branch_by_name: "repo_get_branch_by_name",
17
- get_pull_request_by_id: "repo_get_pull_request_by_id",
18
- get_pull_request_changes: "repo_get_pull_request_changes",
19
- create_pull_request: "repo_create_pull_request",
20
- create_branch: "repo_create_branch",
21
- update_pull_request: "repo_update_pull_request",
22
- update_pull_request_reviewers: "repo_update_pull_request_reviewers",
23
- reply_to_comment: "repo_reply_to_comment",
24
- create_pull_request_thread: "repo_create_pull_request_thread",
25
- update_pull_request_thread: "repo_update_pull_request_thread",
26
- search_commits: "repo_search_commits",
27
- list_pull_requests_by_commits: "repo_list_pull_requests_by_commits",
28
- vote_pull_request: "repo_vote_pull_request",
29
- list_directory: "repo_list_directory",
30
- get_file_content: "repo_get_file_content",
9
+ repo_repository: "repo_repository",
10
+ repo_pull_request: "repo_pull_request",
11
+ repo_pull_request_thread: "repo_pull_request_thread",
12
+ repo_branch: "repo_branch",
13
+ repo_file: "repo_file",
14
+ repo_search_commits: "repo_search_commits",
15
+ repo_pull_request_write: "repo_pull_request_write",
16
+ repo_pull_request_thread_write: "repo_pull_request_thread_write",
17
+ repo_create_branch: "repo_create_branch",
31
18
  };
32
19
  function branchesFilterOutIrrelevantProperties(branches, top) {
33
20
  return branches
@@ -47,14 +34,9 @@ function trimPullRequestThread(thread) {
47
34
  threadContext: thread.threadContext,
48
35
  };
49
36
  }
50
- /**
51
- * Trims comment data to essential properties, filtering out deleted comments
52
- * @param comments Array of comments to trim (can be undefined/null)
53
- * @returns Array of trimmed comment objects with essential properties only
54
- */
55
37
  function trimComments(comments) {
56
38
  return comments
57
- ?.filter((comment) => !comment.isDeleted) // Exclude deleted comments
39
+ ?.filter((comment) => !comment.isDeleted)
58
40
  ?.map((comment) => ({
59
41
  id: comment.id,
60
42
  author: {
@@ -85,8 +67,7 @@ function pullRequestStatusStringToInt(status) {
85
67
  }
86
68
  function filterReposByName(repositories, repoNameFilter) {
87
69
  const lowerCaseFilter = repoNameFilter.toLowerCase();
88
- const filteredByName = repositories?.filter((repo) => repo.name?.toLowerCase().includes(lowerCaseFilter));
89
- return filteredByName;
70
+ return repositories?.filter((repo) => repo.name?.toLowerCase().includes(lowerCaseFilter));
90
71
  }
91
72
  function trimPullRequest(pr, includeDescription = false) {
92
73
  if (!pr) {
@@ -113,1349 +94,430 @@ function trimPullRequest(pr, includeDescription = false) {
113
94
  project: pr.repository?.project?.name,
114
95
  };
115
96
  }
116
- // Helper function to build a version descriptor from branch or commit
117
97
  function buildVersionDescriptor(version, versionType) {
118
- if (!version) {
98
+ if (!version)
119
99
  return undefined;
120
- }
121
- const versionTypeMap = {
122
- Branch: GitVersionType.Branch,
123
- Commit: GitVersionType.Commit,
124
- Tag: GitVersionType.Tag,
125
- };
126
- return {
127
- version: version,
128
- versionType: versionTypeMap[versionType || "Branch"] ?? GitVersionType.Branch,
129
- };
130
- }
131
- function configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider) {
132
- server.tool(REPO_TOOLS.create_pull_request, "Create a new pull request.", {
133
- repositoryId: z
134
- .string()
135
- .describe("The ID or name of the repository where the pull request will be created. When using a repository name instead of a GUID, the project parameter must also be provided."),
136
- sourceRefName: z.string().describe("The source branch name for the pull request, e.g., 'refs/heads/feature-branch'."),
137
- targetRefName: z.string().describe("The target branch name for the pull request, e.g., 'refs/heads/main'."),
138
- title: z.string().describe("The title of the pull request."),
139
- description: z.string().max(4000).optional().describe("The description of the pull request. Must not be longer than 4000 characters. Optional."),
140
- isDraft: z.boolean().optional().default(false).describe("Indicates whether the pull request is a draft. Defaults to false."),
141
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
142
- workItems: z.string().optional().describe("Work item IDs to associate with the pull request, space-separated."),
143
- 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."),
144
- labels: z.array(z.string()).optional().describe("Array of label names to add to the pull request after creation."),
145
- }, async ({ repositoryId, sourceRefName, targetRefName, title, description, isDraft, project, workItems, forkSourceRepositoryId, labels }) => {
146
- try {
147
- const connection = await connectionProvider();
148
- const gitApi = await connection.getGitApi();
149
- const workItemRefs = workItems ? workItems.split(" ").map((id) => ({ id: id.trim() })) : [];
150
- const noDataErrorMessage = `Pull request creation returned no data and no matching PR was found. This often means repositoryId=\"${repositoryId}\" was not resolvable. ` +
151
- "Try the repository GUID from repo_list_repos_by_project instead of the Project/RepoName slash format.";
152
- const forkSource = forkSourceRepositoryId
153
- ? {
154
- repository: {
155
- id: forkSourceRepositoryId,
156
- },
157
- }
158
- : undefined;
159
- const labelDefinitions = labels ? labels.map((label) => ({ name: label })) : undefined;
160
- let pullRequest = await gitApi.createPullRequest({
161
- sourceRefName,
162
- targetRefName,
163
- title,
164
- description,
165
- isDraft,
166
- workItemRefs: workItemRefs,
167
- forkSource,
168
- labels: labelDefinitions,
169
- supportsIterations: true,
170
- }, repositoryId, project);
171
- if (!pullRequest) {
172
- const prs = await gitApi.getPullRequests(repositoryId, { sourceRefName, targetRefName, status: PullRequestStatus.Active }, project, undefined, 0, 1);
173
- if (prs && prs.length > 0) {
174
- pullRequest = prs[0];
175
- }
176
- else {
177
- return {
178
- content: [{ type: "text", text: noDataErrorMessage }],
179
- isError: true,
180
- };
181
- }
182
- }
183
- const trimmedPullRequest = trimPullRequest(pullRequest, true);
184
- if (!trimmedPullRequest) {
185
- return {
186
- content: [{ type: "text", text: noDataErrorMessage }],
187
- isError: true,
188
- };
189
- }
190
- return {
191
- content: [{ type: "text", text: JSON.stringify(trimmedPullRequest, null, 2) }],
192
- };
193
- }
194
- catch (error) {
195
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
196
- return {
197
- content: [{ type: "text", text: `Error creating pull request: ${errorMessage}` }],
198
- isError: true,
199
- };
200
- }
201
- });
202
- server.tool(REPO_TOOLS.create_branch, "Create a new branch in the repository.", {
203
- repositoryId: z
204
- .string()
205
- .describe("The ID or name of the repository where the branch will be created. When using a repository name instead of a GUID, the project parameter must also be provided."),
206
- branchName: z.string().describe("The name of the new branch to create, e.g., 'feature-branch'."),
207
- sourceBranchName: z.string().optional().default("main").describe("The name of the source branch to create the new branch from. Defaults to 'main'."),
208
- sourceCommitId: z.string().optional().describe("The commit ID to create the branch from. If not provided, uses the latest commit of the source branch."),
209
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
210
- }, async ({ repositoryId, branchName, sourceBranchName, sourceCommitId, project }) => {
211
- try {
212
- const connection = await connectionProvider();
213
- const gitApi = await connection.getGitApi();
214
- let commitId = sourceCommitId;
215
- // If no commit ID is provided, get the latest commit from the source branch
216
- if (!commitId) {
217
- const sourceRefName = `refs/heads/${sourceBranchName}`;
218
- try {
219
- const sourceBranch = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, sourceBranchName);
220
- const branch = sourceBranch.find((b) => b.name === sourceRefName);
221
- if (!branch || !branch.objectId) {
222
- return {
223
- content: [
224
- {
225
- type: "text",
226
- text: `Error: Source branch '${sourceBranchName}' not found in repository ${repositoryId}`,
227
- },
228
- ],
229
- isError: true,
230
- };
231
- }
232
- commitId = branch.objectId;
233
- }
234
- catch (error) {
235
- return {
236
- content: [
237
- {
238
- type: "text",
239
- text: `Error retrieving source branch '${sourceBranchName}': ${error instanceof Error ? error.message : String(error)}`,
240
- },
241
- ],
242
- isError: true,
243
- };
244
- }
245
- }
246
- // Create the new branch using updateRefs
247
- const newRefName = `refs/heads/${branchName}`;
248
- const refUpdate = {
249
- name: newRefName,
250
- newObjectId: commitId,
251
- oldObjectId: "0000000000000000000000000000000000000000", // All zeros indicates creating a new ref
252
- };
253
- try {
254
- const result = await gitApi.updateRefs([refUpdate], repositoryId, project);
255
- // Check if the branch creation was successful
256
- if (result && result.length > 0 && result[0].success) {
257
- return {
258
- content: [
259
- {
260
- type: "text",
261
- text: `Branch '${branchName}' created successfully from '${sourceBranchName}' (${commitId})`,
262
- },
263
- ],
264
- };
265
- }
266
- else {
267
- const errorMessage = result && result.length > 0 && result[0].customMessage ? result[0].customMessage : "Unknown error occurred during branch creation";
268
- return {
269
- content: [
270
- {
271
- type: "text",
272
- text: `Error creating branch '${branchName}': ${errorMessage}`,
273
- },
274
- ],
275
- isError: true,
276
- };
277
- }
278
- }
279
- catch (error) {
280
- return {
281
- content: [
282
- {
283
- type: "text",
284
- text: `Error creating branch '${branchName}': ${error instanceof Error ? error.message : String(error)}`,
285
- },
286
- ],
287
- isError: true,
288
- };
289
- }
290
- }
291
- catch (error) {
292
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
293
- return {
294
- content: [{ type: "text", text: `Error creating branch: ${errorMessage}` }],
295
- isError: true,
296
- };
297
- }
298
- });
299
- server.tool(REPO_TOOLS.update_pull_request, "Update a Pull Request by ID with specified fields, including setting autocomplete with various completion options.", {
300
- repositoryId: z.string().describe("The ID or name of the repository where the pull request exists. When using a repository name instead of a GUID, the project parameter must also be provided."),
301
- pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request to update."),
302
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
303
- title: z.string().optional().describe("The new title for the pull request."),
304
- description: z.string().max(4000).optional().describe("The new description for the pull request. Must not be longer than 4000 characters."),
305
- isDraft: z.boolean().optional().describe("Whether the pull request should be a draft."),
306
- targetRefName: z.string().optional().describe("The new target branch name (e.g., 'refs/heads/main')."),
307
- status: z.enum(["Active", "Abandoned"]).optional().describe("The new status of the pull request. Can be 'Active' or 'Abandoned'."),
308
- autoComplete: z.boolean().optional().describe("Set the pull request to autocomplete when all requirements are met."),
309
- mergeStrategy: z
310
- .enum(getEnumKeys(GitPullRequestMergeStrategy))
311
- .optional()
312
- .describe("The merge strategy to use when the pull request autocompletes. Defaults to 'NoFastForward'."),
313
- mergeCommitMessage: z.string().optional().describe("Commit message to use when the pull request is completed."),
314
- deleteSourceBranch: z.boolean().optional().default(false).describe("Whether to delete the source branch when the pull request autocompletes. Defaults to false."),
315
- transitionWorkItems: z.boolean().optional().default(true).describe("Whether to transition associated work items to the next state when the pull request autocompletes. Defaults to true."),
316
- bypassReason: z.string().optional().describe("Reason for bypassing branch policies. When provided, branch policies will be automatically bypassed during autocompletion."),
317
- labels: z.array(z.string()).optional().describe("Array of label names to replace existing labels on the pull request. This will remove all current labels and add the specified ones."),
318
- }, async ({ repositoryId, pullRequestId, project, title, description, isDraft, targetRefName, status, autoComplete, mergeStrategy, mergeCommitMessage, deleteSourceBranch, transitionWorkItems, bypassReason, labels, }) => {
319
- try {
320
- const connection = await connectionProvider();
321
- const gitApi = await connection.getGitApi();
322
- // Build update object with only provided fields
323
- const updateRequest = {};
324
- if (title !== undefined)
325
- updateRequest.title = title;
326
- if (description !== undefined)
327
- updateRequest.description = description;
328
- if (isDraft !== undefined)
329
- updateRequest.isDraft = isDraft;
330
- if (targetRefName !== undefined)
331
- updateRequest.targetRefName = targetRefName;
332
- if (status !== undefined) {
333
- updateRequest.status = status === "Active" ? PullRequestStatus.Active.valueOf() : PullRequestStatus.Abandoned.valueOf();
334
- }
335
- if (autoComplete !== undefined) {
336
- if (autoComplete) {
337
- const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
338
- const autoCompleteUserId = data.authenticatedUser.id;
339
- updateRequest.autoCompleteSetBy = { id: autoCompleteUserId };
340
- const completionOptions = {
341
- deleteSourceBranch: deleteSourceBranch || false,
342
- transitionWorkItems: transitionWorkItems !== false, // Default to true unless explicitly set to false
343
- bypassPolicy: !!bypassReason, // Automatically set to true if bypassReason is provided
344
- };
345
- if (mergeStrategy) {
346
- completionOptions.mergeStrategy = GitPullRequestMergeStrategy[mergeStrategy];
347
- }
348
- if (mergeCommitMessage) {
349
- completionOptions.mergeCommitMessage = mergeCommitMessage;
350
- }
351
- if (bypassReason) {
352
- completionOptions.bypassReason = bypassReason;
353
- }
354
- updateRequest.completionOptions = completionOptions;
355
- }
356
- else {
357
- updateRequest.autoCompleteSetBy = null;
358
- updateRequest.completionOptions = null;
359
- }
360
- }
361
- // Validate that at least one field is provided for update
362
- if (Object.keys(updateRequest).length === 0 && !labels) {
363
- return {
364
- content: [{ type: "text", text: "Error: At least one field (title, description, isDraft, targetRefName, status, autoComplete options, or labels) must be provided for update." }],
365
- isError: true,
366
- };
367
- }
368
- // Update labels if provided
369
- if (labels) {
370
- const currentLabels = await gitApi.getPullRequestLabels(repositoryId, pullRequestId, project);
371
- for (const currentLabel of currentLabels) {
372
- if (currentLabel.id) {
373
- await gitApi.deletePullRequestLabels(repositoryId, pullRequestId, currentLabel.id, project);
374
- }
375
- }
376
- for (const label of labels) {
377
- await gitApi.createPullRequestLabel({ name: label }, repositoryId, pullRequestId, project);
378
- }
379
- }
380
- let updatedPullRequest;
381
- if (Object.keys(updateRequest).length > 0) {
382
- updatedPullRequest = await gitApi.updatePullRequest(updateRequest, repositoryId, pullRequestId, project);
383
- }
384
- else {
385
- // If only labels were updated, get the current pull request
386
- updatedPullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project);
387
- }
388
- const trimmedUpdatedPullRequest = trimPullRequest(updatedPullRequest, true);
389
- if (!trimmedUpdatedPullRequest) {
390
- return {
391
- content: [{ type: "text", text: "Pull request updated but API returned no data." }],
392
- };
393
- }
394
- return {
395
- content: [{ type: "text", text: JSON.stringify(trimmedUpdatedPullRequest, null, 2) }],
396
- };
397
- }
398
- catch (error) {
399
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
400
- return {
401
- content: [{ type: "text", text: `Error updating pull request: ${errorMessage}` }],
402
- isError: true,
403
- };
404
- }
405
- });
406
- server.tool(REPO_TOOLS.update_pull_request_reviewers, "Add or remove reviewers for an existing pull request.", {
407
- repositoryId: z.string().describe("The ID or name of the repository where the pull request exists. When using a repository name instead of a GUID, the project parameter must also be provided."),
408
- pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request to update."),
409
- reviewerIds: z.array(z.string()).describe("List of reviewer ids to add or remove from the pull request."),
410
- action: z.enum(["add", "remove"]).describe("Action to perform on the reviewers. Can be 'add' or 'remove'."),
411
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
412
- }, async ({ repositoryId, pullRequestId, reviewerIds, action, project }) => {
413
- try {
414
- const connection = await connectionProvider();
415
- const gitApi = await connection.getGitApi();
416
- let updatedPullRequest;
417
- if (action === "add") {
418
- updatedPullRequest = await gitApi.createPullRequestReviewers(reviewerIds.map((id) => ({ id: id })), repositoryId, pullRequestId, project);
419
- const trimmedResponse = updatedPullRequest.map((item) => ({
420
- displayName: item.displayName,
421
- id: item.id,
422
- uniqueName: item.uniqueName,
423
- vote: item.vote,
424
- hasDeclined: item.hasDeclined,
425
- isFlagged: item.isFlagged,
426
- }));
427
- return {
428
- content: [{ type: "text", text: JSON.stringify(trimmedResponse, null, 2) }],
429
- };
430
- }
431
- else {
432
- for (const reviewerId of reviewerIds) {
433
- await gitApi.deletePullRequestReviewer(repositoryId, pullRequestId, reviewerId, project);
434
- }
435
- return {
436
- content: [{ type: "text", text: `Reviewers with IDs ${reviewerIds.join(", ")} removed from pull request ${pullRequestId}.` }],
437
- };
438
- }
439
- }
440
- catch (error) {
441
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
442
- return {
443
- content: [{ type: "text", text: `Error updating pull request reviewers: ${errorMessage}` }],
444
- isError: true,
445
- };
446
- }
447
- });
448
- server.tool(REPO_TOOLS.list_repos_by_project, "Retrieve a list of repositories for a given project", {
449
- project: z.string().describe("The name or ID of the Azure DevOps project."),
450
- top: z.coerce.number().default(100).describe("The maximum number of repositories to return."),
451
- skip: z.coerce.number().default(0).describe("The number of repositories to skip. Defaults to 0."),
452
- repoNameFilter: z.string().optional().describe("Optional filter to search for repositories by name. If provided, only repositories with names containing this string will be returned."),
453
- }, async ({ project, top, skip, repoNameFilter }) => {
454
- try {
455
- const connection = await connectionProvider();
456
- const gitApi = await connection.getGitApi();
457
- const repositories = await gitApi.getRepositories(project, false, false, false);
458
- const filteredRepositories = repoNameFilter ? filterReposByName(repositories, repoNameFilter) : repositories;
459
- const paginatedRepositories = filteredRepositories?.sort((a, b) => a.name?.localeCompare(b.name ?? "") ?? 0).slice(skip, skip + top);
460
- // Filter out the irrelevant properties
461
- const trimmedRepositories = paginatedRepositories?.map((repo) => ({
462
- id: repo.id,
463
- name: repo.name,
464
- isDisabled: repo.isDisabled,
465
- isFork: repo.isFork,
466
- isInMaintenance: repo.isInMaintenance,
467
- webUrl: repo.webUrl,
468
- size: repo.size,
469
- }));
470
- return {
471
- content: [{ type: "text", text: JSON.stringify(trimmedRepositories, null, 2) }],
472
- };
473
- }
474
- catch (error) {
475
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
476
- return {
477
- content: [{ type: "text", text: `Error listing repositories: ${errorMessage}` }],
478
- isError: true,
479
- };
480
- }
481
- });
482
- server.tool(REPO_TOOLS.list_pull_requests_by_repo_or_project, "Retrieve a list of pull requests for a given repository. Either repositoryId or project must be provided.", {
483
- repositoryId: z
484
- .string()
485
- .optional()
486
- .describe("The ID or name of the repository where the pull requests are located. When using a repository name instead of a GUID, the project parameter must also be provided."),
487
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID, or to scope the search to a specific project."),
488
- top: z.coerce.number().default(100).describe("The maximum number of pull requests to return."),
489
- skip: z.coerce.number().default(0).describe("The number of pull requests to skip."),
490
- created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user."),
491
- created_by_user: z.string().optional().describe("Filter pull requests created by a specific user (provide email or unique name). Takes precedence over created_by_me if both are provided."),
492
- i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer."),
493
- user_is_reviewer: z
494
- .string()
495
- .optional()
496
- .describe("Filter pull requests where a specific user is a reviewer (provide email or unique name). Takes precedence over i_am_reviewer if both are provided."),
497
- status: z
498
- .enum(getEnumKeys(PullRequestStatus))
499
- .default("Active")
500
- .describe("Filter pull requests by status. Defaults to 'Active'."),
501
- sourceRefName: z.string().optional().describe("Filter pull requests from this source branch (e.g., 'refs/heads/feature-branch')."),
502
- targetRefName: z.string().optional().describe("Filter pull requests into this target branch (e.g., 'refs/heads/main')."),
503
- }, async ({ repositoryId, project, top, skip, created_by_me, created_by_user, i_am_reviewer, user_is_reviewer, status, sourceRefName, targetRefName }) => {
504
- try {
505
- const connection = await connectionProvider();
506
- const gitApi = await connection.getGitApi();
507
- // Build the search criteria
508
- const searchCriteria = {
509
- status: pullRequestStatusStringToInt(status),
510
- };
511
- if (!repositoryId && !project) {
512
- return {
513
- content: [
514
- {
515
- type: "text",
516
- text: "Either repositoryId or project must be provided.",
517
- },
518
- ],
519
- isError: true,
520
- };
521
- }
522
- if (repositoryId) {
523
- searchCriteria.repositoryId = repositoryId;
524
- }
525
- if (sourceRefName) {
526
- searchCriteria.sourceRefName = sourceRefName;
527
- }
528
- if (targetRefName) {
529
- searchCriteria.targetRefName = targetRefName;
530
- }
531
- if (created_by_user) {
532
- try {
533
- const userId = await getUserIdFromEmail(created_by_user, tokenProvider, connectionProvider, userAgentProvider);
534
- searchCriteria.creatorId = userId;
535
- }
536
- catch (error) {
537
- return {
538
- content: [
539
- {
540
- type: "text",
541
- text: `Error finding user with email ${created_by_user}: ${error instanceof Error ? error.message : String(error)}`,
542
- },
543
- ],
544
- isError: true,
545
- };
546
- }
547
- }
548
- else if (created_by_me) {
549
- const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
550
- const userId = data.authenticatedUser.id;
551
- searchCriteria.creatorId = userId;
552
- }
553
- if (user_is_reviewer) {
554
- try {
555
- const reviewerUserId = await getUserIdFromEmail(user_is_reviewer, tokenProvider, connectionProvider, userAgentProvider);
556
- searchCriteria.reviewerId = reviewerUserId;
557
- }
558
- catch (error) {
559
- return {
560
- content: [
561
- {
562
- type: "text",
563
- text: `Error finding reviewer with email ${user_is_reviewer}: ${error instanceof Error ? error.message : String(error)}`,
564
- },
565
- ],
566
- isError: true,
567
- };
568
- }
569
- }
570
- else if (i_am_reviewer) {
571
- const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
572
- const userId = data.authenticatedUser.id;
573
- searchCriteria.reviewerId = userId;
574
- }
575
- let pullRequests;
576
- if (repositoryId) {
577
- pullRequests = await gitApi.getPullRequests(repositoryId, searchCriteria, project, // project
578
- undefined, // maxCommentLength
579
- skip, top);
580
- }
581
- else if (project) {
582
- // If only project is provided, use getPullRequestsByProject
583
- pullRequests = await gitApi.getPullRequestsByProject(project, searchCriteria, undefined, // maxCommentLength
584
- skip, top);
585
- }
586
- else {
587
- // This case should not occur due to earlier validation, but added for completeness
588
- return {
589
- content: [
590
- {
591
- type: "text",
592
- text: "Either repositoryId or project must be provided.",
593
- },
594
- ],
595
- isError: true,
596
- };
597
- }
598
- const filteredPullRequests = pullRequests?.map((pr) => trimPullRequest(pr));
599
- return {
600
- content: [{ type: "text", text: JSON.stringify(filteredPullRequests, null, 2) }],
601
- };
602
- }
603
- catch (error) {
604
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
605
- return {
606
- content: [{ type: "text", text: `Error listing pull requests: ${errorMessage}` }],
607
- isError: true,
608
- };
609
- }
610
- });
611
- server.tool(REPO_TOOLS.list_pull_request_threads, "Retrieve a list of comment threads for a pull request.", {
612
- repositoryId: z
613
- .string()
614
- .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
615
- pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request for which to retrieve threads."),
616
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
617
- iteration: z.coerce.number().min(1).optional().describe("The iteration ID for which to retrieve threads. Optional, defaults to the latest iteration."),
618
- baseIteration: z.coerce.number().min(1).optional().describe("The base iteration ID for which to retrieve threads. Optional, defaults to the latest base iteration."),
619
- top: z.coerce.number().default(100).describe("The maximum number of threads to return after filtering."),
620
- skip: z.coerce.number().default(0).describe("The number of threads to skip after filtering."),
621
- fullResponse: z.boolean().optional().default(false).describe("Return full thread JSON response instead of trimmed data."),
622
- status: z
623
- .enum(getEnumKeys(CommentThreadStatus))
624
- .optional()
625
- .describe("Filter threads by status. If not specified, returns threads of all statuses."),
626
- authorEmail: z.string().optional().describe("Filter threads by the email of the thread author (first comment author)."),
627
- authorDisplayName: z.string().optional().describe("Filter threads by the display name of the thread author (first comment author). Case-insensitive partial matching."),
628
- }, async ({ repositoryId, pullRequestId, project, iteration, baseIteration, top, skip, fullResponse, status, authorEmail, authorDisplayName }) => {
629
- try {
630
- const connection = await connectionProvider();
631
- const gitApi = await connection.getGitApi();
632
- const threads = (await gitApi.getThreads(repositoryId, pullRequestId, project, iteration, baseIteration)) ?? [];
633
- let filteredThreads = threads;
634
- if (status !== undefined) {
635
- const statusValue = CommentThreadStatus[status];
636
- filteredThreads = filteredThreads.filter((thread) => thread.status === statusValue);
637
- }
638
- if (authorEmail !== undefined) {
639
- filteredThreads = filteredThreads.filter((thread) => {
640
- const firstComment = thread.comments?.[0];
641
- return firstComment?.author?.uniqueName?.toLowerCase() === authorEmail.toLowerCase();
642
- });
643
- }
644
- if (authorDisplayName !== undefined) {
645
- const lowerAuthorName = authorDisplayName.toLowerCase();
646
- filteredThreads = filteredThreads.filter((thread) => {
647
- const firstComment = thread.comments?.[0];
648
- return firstComment?.author?.displayName?.toLowerCase().includes(lowerAuthorName);
649
- });
650
- }
651
- const paginatedThreads = filteredThreads.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top);
652
- if (fullResponse) {
653
- return {
654
- content: [{ type: "text", text: JSON.stringify(paginatedThreads, null, 2) }],
655
- };
656
- }
657
- // Return trimmed thread data focusing on essential information
658
- const trimmedThreads = paginatedThreads.map((thread) => trimPullRequestThread(thread));
659
- return {
660
- content: [{ type: "text", text: JSON.stringify(trimmedThreads, null, 2) }],
661
- };
662
- }
663
- catch (error) {
664
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
665
- return {
666
- content: [{ type: "text", text: `Error listing pull request threads: ${errorMessage}` }],
667
- isError: true,
668
- };
669
- }
670
- });
671
- server.tool(REPO_TOOLS.list_pull_request_thread_comments, "Retrieve a list of comments in a pull request thread.", {
672
- repositoryId: z
673
- .string()
674
- .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
675
- pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request for which to retrieve thread comments."),
676
- threadId: z.coerce.number().min(1).describe("The ID of the thread for which to retrieve comments."),
677
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
678
- top: z.coerce.number().default(100).describe("The maximum number of comments to return."),
679
- skip: z.coerce.number().default(0).describe("The number of comments to skip."),
680
- fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of trimmed data."),
681
- }, async ({ repositoryId, pullRequestId, threadId, project, top, skip, fullResponse }) => {
682
- try {
683
- const connection = await connectionProvider();
684
- const gitApi = await connection.getGitApi();
685
- // Get thread comments - GitApi uses getComments for retrieving comments from a specific thread
686
- const comments = await gitApi.getComments(repositoryId, pullRequestId, threadId, project);
687
- const paginatedComments = comments?.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top);
688
- if (fullResponse) {
689
- return {
690
- content: [{ type: "text", text: JSON.stringify(paginatedComments, null, 2) }],
691
- };
692
- }
693
- // Return trimmed comment data focusing on essential information
694
- const trimmedComments = trimComments(paginatedComments);
695
- return {
696
- content: [{ type: "text", text: JSON.stringify(trimmedComments, null, 2) }],
697
- };
698
- }
699
- catch (error) {
700
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
701
- return {
702
- content: [{ type: "text", text: `Error listing pull request thread comments: ${errorMessage}` }],
703
- isError: true,
704
- };
705
- }
706
- });
707
- server.tool(REPO_TOOLS.list_branches_by_repo, "Retrieve a list of branch names for a given repository. Returns an array of branch name strings, not full branch objects. Use repo_get_branch_by_name to get full details for a specific branch.", {
708
- repositoryId: z
709
- .string()
710
- .describe("The ID or name of the repository where the branches are located. When using a repository name instead of a GUID, the project parameter must also be provided."),
711
- top: z.coerce.number().default(100).describe("The maximum number of branches to return. Defaults to 100."),
712
- filterContains: z.string().optional().describe("Filter to find branches that contain this string in their name."),
713
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
714
- }, async ({ repositoryId, top, filterContains, project }) => {
715
- try {
716
- const connection = await connectionProvider();
717
- const gitApi = await connection.getGitApi();
718
- const branches = await gitApi.getRefs(repositoryId, project, "heads/", undefined, undefined, undefined, undefined, undefined, filterContains);
719
- const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top);
720
- return {
721
- content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }],
722
- };
723
- }
724
- catch (error) {
725
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
726
- return {
727
- content: [{ type: "text", text: `Error listing branches: ${errorMessage}` }],
728
- isError: true,
729
- };
730
- }
731
- });
732
- server.tool(REPO_TOOLS.list_my_branches_by_repo, "Retrieve a list of my branch names for a given repository Id. Returns an array of branch name strings, not full branch objects. Use repo_get_branch_by_name to get full details for a specific branch.", {
733
- repositoryId: z
734
- .string()
735
- .describe("The ID or name of the repository where the branches are located. When using a repository name instead of a GUID, the project parameter must also be provided."),
736
- top: z.coerce.number().default(100).describe("The maximum number of branches to return."),
737
- filterContains: z.string().optional().describe("Filter to find branches that contain this string in their name."),
738
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
739
- }, async ({ repositoryId, top, filterContains, project }) => {
740
- try {
741
- const connection = await connectionProvider();
742
- const gitApi = await connection.getGitApi();
743
- const branches = await gitApi.getRefs(repositoryId, project, "heads/", undefined, undefined, true, undefined, undefined, filterContains);
744
- const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top);
745
- return {
746
- content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }],
747
- };
748
- }
749
- catch (error) {
750
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
751
- return {
752
- content: [{ type: "text", text: `Error listing my branches: ${errorMessage}` }],
753
- isError: true,
754
- };
755
- }
756
- });
757
- server.tool(REPO_TOOLS.get_repo_by_name_or_id, "Get the repository by project and repository name or ID.", {
758
- project: z.string().describe("Project name or ID where the repository is located."),
759
- repositoryNameOrId: z.string().describe("Repository name or ID."),
760
- }, async ({ project, repositoryNameOrId }) => {
761
- try {
762
- const connection = await connectionProvider();
763
- const gitApi = await connection.getGitApi();
764
- const repositories = await gitApi.getRepositories(project);
765
- const repository = repositories?.find((repo) => repo.name === repositoryNameOrId || repo.id === repositoryNameOrId);
766
- if (!repository) {
767
- return {
768
- content: [{ type: "text", text: `Repository ${repositoryNameOrId} not found in project ${project}` }],
769
- isError: true,
770
- };
771
- }
772
- return {
773
- content: [{ type: "text", text: JSON.stringify(repository, null, 2) }],
774
- };
775
- }
776
- catch (error) {
777
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
778
- return {
779
- content: [{ type: "text", text: `Error getting repository: ${errorMessage}` }],
780
- isError: true,
781
- };
782
- }
783
- });
784
- server.tool(REPO_TOOLS.get_branch_by_name, "Get a branch by its name. Returns isError: true if the branch is not found.", {
785
- repositoryId: z.string().describe("The ID or name of the repository where the branch is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
786
- branchName: z.string().describe("The name of the branch to retrieve, e.g., 'main' or 'feature-branch'."),
787
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
788
- }, async ({ repositoryId, branchName, project }) => {
789
- try {
790
- const connection = await connectionProvider();
791
- const gitApi = await connection.getGitApi();
792
- const branches = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, branchName);
793
- const branch = branches.find((branch) => branch.name === `refs/heads/${branchName}` || branch.name === branchName);
794
- if (!branch) {
795
- return {
796
- content: [
797
- {
798
- type: "text",
799
- text: `Branch ${branchName} not found in repository ${repositoryId}`,
800
- },
801
- ],
802
- isError: true,
803
- };
804
- }
805
- return {
806
- content: [{ type: "text", text: JSON.stringify(branch, null, 2) }],
807
- };
808
- }
809
- catch (error) {
810
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
811
- return {
812
- content: [{ type: "text", text: `Error getting branch: ${errorMessage}` }],
813
- isError: true,
814
- };
815
- }
816
- });
817
- server.tool(REPO_TOOLS.get_pull_request_by_id, "Get a pull request by its ID.", {
818
- repositoryId: z
819
- .string()
820
- .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
821
- pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request to retrieve."),
822
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
823
- includeWorkItemRefs: z.boolean().optional().default(false).describe("Whether to reference work items associated with the pull request."),
824
- includeLabels: z.boolean().optional().default(false).describe("Whether to include a summary of labels in the response."),
825
- includeChangedFiles: z.boolean().optional().default(false).describe("Whether to include the list of files changed in the pull request."),
826
- }, async ({ repositoryId, pullRequestId, project, includeWorkItemRefs, includeLabels, includeChangedFiles }) => {
827
- try {
828
- const connection = await connectionProvider();
829
- const gitApi = await connection.getGitApi();
830
- const pullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project, undefined, undefined, undefined, undefined, includeWorkItemRefs);
831
- let enhancedResponse = { ...pullRequest };
832
- if (includeLabels) {
833
- try {
834
- const projectId = pullRequest.repository?.project?.id;
835
- const projectName = pullRequest.repository?.project?.name;
836
- const labels = await gitApi.getPullRequestLabels(repositoryId, pullRequestId, projectName, projectId);
837
- const labelNames = labels.map((label) => label.name).filter((name) => name !== undefined);
838
- enhancedResponse = {
839
- ...enhancedResponse,
840
- labelSummary: {
841
- labels: labelNames,
842
- labelCount: labelNames.length,
843
- },
844
- };
845
- }
846
- catch (error) {
847
- console.warn(`Error fetching PR labels: ${error instanceof Error ? error.message : "Unknown error"}`);
848
- enhancedResponse = {
849
- ...enhancedResponse,
850
- labelSummary: {},
851
- };
852
- }
853
- }
854
- if (includeChangedFiles) {
855
- try {
856
- const iterations = await gitApi.getPullRequestIterations(repositoryId, pullRequestId, project);
857
- if (iterations?.length) {
858
- const latestIteration = iterations[iterations.length - 1];
859
- if (latestIteration.id != null) {
860
- const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, latestIteration.id, project);
861
- enhancedResponse = {
862
- ...enhancedResponse,
863
- changedFilesSummary: {
864
- changeEntries: changes?.changeEntries ?? [],
865
- fileCount: changes?.changeEntries?.length ?? 0,
866
- nextSkip: changes?.nextSkip,
867
- nextTop: changes?.nextTop,
868
- },
869
- };
870
- }
871
- else {
872
- enhancedResponse = {
873
- ...enhancedResponse,
874
- changedFilesSummary: { changeEntries: [], fileCount: 0 },
875
- };
876
- }
877
- }
878
- else {
879
- enhancedResponse = {
880
- ...enhancedResponse,
881
- changedFilesSummary: { changeEntries: [], fileCount: 0 },
882
- };
883
- }
884
- }
885
- catch (error) {
886
- console.warn(`Error fetching PR changed files: ${error instanceof Error ? error.message : "Unknown error"}`);
887
- enhancedResponse = {
888
- ...enhancedResponse,
889
- changedFilesSummary: {},
890
- };
891
- }
892
- }
893
- return {
894
- content: [{ type: "text", text: JSON.stringify(enhancedResponse, null, 2) }],
895
- };
896
- }
897
- catch (error) {
898
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
899
- return {
900
- content: [{ type: "text", text: `Error getting pull request: ${errorMessage}` }],
901
- isError: true,
902
- };
903
- }
904
- });
905
- server.tool(REPO_TOOLS.get_pull_request_changes, "Get the file changes (diff) for a pull request iteration with actual code diff content. Returns the code changes including line-by-line diffs made in the pull request.", {
906
- repositoryId: z.string().describe("The ID of the repository where the pull request is located."),
907
- pullRequestId: z.number().describe("The ID of the pull request to retrieve changes for."),
908
- iterationId: z.number().optional().describe("The iteration ID to get changes for. If not specified, gets changes for the latest iteration."),
909
- project: z.string().optional().describe("Project ID or project name (optional)"),
910
- top: z.number().optional().describe("Maximum number of files to include diffs for. Default is 100."),
911
- skip: z.number().optional().describe("Number of changes to skip for pagination."),
912
- compareTo: z.number().optional().describe("Iteration ID to compare against. If specified, returns changes between two iterations."),
913
- includeDiffs: z.boolean().optional().describe("Whether to include actual line-by-line diff content. Default is true. Set to false to get only file metadata."),
914
- includeLineContent: z
915
- .boolean()
916
- .optional()
917
- .describe("Whether to include the actual line content from the changed files. Default is true. When true, fetches file content and includes the actual code lines that were added/removed/modified."),
918
- }, async ({ repositoryId, pullRequestId, iterationId, project, top, skip, compareTo, includeDiffs = true, includeLineContent = true }) => {
919
- try {
920
- const connection = await connectionProvider();
921
- const gitApi = await connection.getGitApi();
922
- // If repositoryId is a name (not a GUID), we need a project to resolve it.
923
- // GUID pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
924
- const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(repositoryId);
925
- if (!isGuid && !project) {
926
- return {
927
- content: [
928
- {
929
- type: "text",
930
- text: "Error: When using a repository name instead of a GUID for repositoryId, the 'project' parameter is required. Please either provide the project name/ID, or use repo_get_repo_by_name_or_id to resolve the repository GUID first.",
931
- },
932
- ],
933
- isError: true,
934
- };
935
- }
936
- // If no iteration ID provided, get the latest iteration
937
- let targetIterationId = iterationId;
938
- let targetIteration;
939
- if (targetIterationId == null) {
940
- const iterations = await gitApi.getPullRequestIterations(repositoryId, pullRequestId, project);
941
- if (!iterations || iterations.length === 0) {
942
- return {
943
- content: [{ type: "text", text: "No iterations found for this pull request." }],
944
- isError: true,
945
- };
946
- }
947
- // Get the latest iteration
948
- targetIteration = iterations[iterations.length - 1];
949
- targetIterationId = targetIteration.id;
950
- }
951
- else {
952
- // Get the specific iteration
953
- targetIteration = await gitApi.getPullRequestIteration(repositoryId, pullRequestId, targetIterationId, project);
954
- }
955
- // Get the file change metadata
956
- const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, targetIterationId ?? 1, project, top, skip, compareTo);
957
- // If includeDiffs is false, just return the metadata
958
- if (!includeDiffs) {
959
- return {
960
- content: [{ type: "text", text: JSON.stringify(changes, null, 2) }],
961
- };
962
- }
963
- // Get actual diff content using getFileDiffs
964
- if (changes.changeEntries && changes.changeEntries.length > 0 && targetIteration) {
965
- // Determine base and target commits
966
- const baseCommitId = compareTo
967
- ? (await gitApi.getPullRequestIteration(repositoryId, pullRequestId, compareTo, project)).sourceRefCommit?.commitId
968
- : targetIteration.commonRefCommit?.commitId;
969
- const targetCommitId = targetIteration.sourceRefCommit?.commitId;
970
- if (baseCommitId && targetCommitId) {
971
- // Build FileDiffsCriteria with paths from changeEntries
972
- // Exclude added and deleted files as they don't have both versions to diff
973
- // changeType is a flags enum so use bitwise AND to check
974
- const fileDiffParams = changes.changeEntries
975
- .filter((entry) => {
976
- const ct = entry.changeType ?? 0;
977
- return entry.item?.path && !(ct & VersionControlChangeType.Add) && !(ct & VersionControlChangeType.Delete);
978
- })
979
- .map((entry) => {
980
- // Remove leading slash if present - Azure DevOps API expects relative paths
981
- const itemPath = entry.item?.path ?? "";
982
- const path = itemPath.startsWith("/") ? itemPath.substring(1) : itemPath;
983
- // For renamed/moved files, use the original path from the change entry
984
- const origPath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : path;
985
- return {
986
- path: path,
987
- originalPath: origPath,
988
- };
989
- });
990
- try {
991
- // Fetch diffs for modified files. Add/Delete files are excluded from getFileDiffs
992
- // because they don't have two versions to compare; their content is fetched
993
- // separately below via getItemText when includeLineContent is true.
994
- let fileDiffs = [];
995
- if (fileDiffParams.length > 0) {
996
- // Azure DevOps getFileDiffs API accepts max 10 files per request
997
- const FILE_DIFF_BATCH_SIZE = 10;
998
- for (let i = 0; i < fileDiffParams.length; i += FILE_DIFF_BATCH_SIZE) {
999
- const batch = fileDiffParams.slice(i, i + FILE_DIFF_BATCH_SIZE);
1000
- const batchDiffs = await gitApi.getFileDiffs({
1001
- baseVersionCommit: baseCommitId,
1002
- targetVersionCommit: targetCommitId,
1003
- fileDiffParams: batch,
1004
- }, project || "", repositoryId);
1005
- fileDiffs = fileDiffs.concat(batchDiffs);
1006
- }
1007
- }
1008
- // Merge diff content with change metadata.
1009
- // Added/deleted entries get diff: null here and are enriched below.
1010
- const enrichedChanges = {
1011
- ...changes,
1012
- changeEntries: changes.changeEntries.map((entry) => {
1013
- // Normalize path for comparison (remove leading slash)
1014
- const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path;
1015
- const matchingDiff = fileDiffs.find((diff) => diff.path === entryPath);
1016
- return {
1017
- ...entry,
1018
- diff: matchingDiff || null,
1019
- };
1020
- }),
1021
- };
1022
- // If includeLineContent is true, fetch actual file content with concurrency limit
1023
- if (includeLineContent && enrichedChanges.changeEntries) {
1024
- const CONCURRENCY_LIMIT = 10;
1025
- const entriesWithContent = [...enrichedChanges.changeEntries];
1026
- for (let i = 0; i < entriesWithContent.length; i += CONCURRENCY_LIMIT) {
1027
- const batch = entriesWithContent.slice(i, i + CONCURRENCY_LIMIT);
1028
- const batchResults = await Promise.all(batch.map(async (entry) => {
1029
- const ct = entry.changeType ?? 0;
1030
- const isAdd = !!(ct & VersionControlChangeType.Add);
1031
- const isDelete = !!(ct & VersionControlChangeType.Delete);
1032
- const entryPath = entry.item?.path ? (entry.item.path.startsWith("/") ? entry.item.path.substring(1) : entry.item.path) : undefined;
1033
- // For deleted files ADO sets item.path to null and puts the path in originalPath only.
1034
- // Normalise originalPath once and use it as the fallback throughout.
1035
- const normalizedOriginalPath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : undefined;
1036
- // effectivePath is what we use as the "current" path for API calls / early-exit guard.
1037
- // For additions/modifications it's item.path; for deletions it's originalPath.
1038
- const effectivePath = entryPath ?? normalizedOriginalPath;
1039
- if (!effectivePath) {
1040
- return entry;
1041
- }
1042
- // Handle added files: fetch full content at target commit and create synthetic diff
1043
- if (isAdd && !entry.diff) {
1044
- try {
1045
- const targetStream = await gitApi
1046
- .getItemText(repositoryId, effectivePath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit })
1047
- .catch(() => null);
1048
- if (targetStream) {
1049
- const targetText = await streamToString(targetStream);
1050
- const targetLines = targetText.split(/\r?\n/);
1051
- return {
1052
- ...entry,
1053
- diff: {
1054
- path: effectivePath,
1055
- originalPath: null,
1056
- lineDiffBlocks: [
1057
- {
1058
- changeType: 1, // Add
1059
- originalLineNumberStart: 0,
1060
- originalLinesCount: 0,
1061
- modifiedLineNumberStart: 1,
1062
- modifiedLinesCount: targetLines.length,
1063
- modifiedLines: targetLines,
1064
- },
1065
- ],
1066
- },
1067
- };
1068
- }
1069
- }
1070
- catch (addError) {
1071
- return {
1072
- ...entry,
1073
- _contentFetchError: `Failed to fetch added file content: ${addError instanceof Error ? addError.message : "Unknown error"}`,
1074
- };
1075
- }
1076
- return entry;
1077
- }
1078
- // Handle deleted files: fetch full content at base commit and create synthetic diff.
1079
- // basePath prefers originalPath (the pre-deletion path); falls back to effectivePath.
1080
- if (isDelete && !entry.diff) {
1081
- try {
1082
- const basePath = normalizedOriginalPath ?? effectivePath;
1083
- const baseStream = await gitApi
1084
- .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit })
1085
- .catch(() => null);
1086
- if (baseStream) {
1087
- const baseText = await streamToString(baseStream);
1088
- const baseLines = baseText.split(/\r?\n/);
1089
- return {
1090
- ...entry,
1091
- diff: {
1092
- path: null,
1093
- originalPath: basePath,
1094
- lineDiffBlocks: [
1095
- {
1096
- changeType: 2, // Delete
1097
- originalLineNumberStart: 1,
1098
- originalLinesCount: baseLines.length,
1099
- modifiedLineNumberStart: 0,
1100
- modifiedLinesCount: 0,
1101
- originalLines: baseLines,
1102
- },
1103
- ],
1104
- },
1105
- };
1106
- }
1107
- }
1108
- catch (delError) {
1109
- return {
1110
- ...entry,
1111
- _contentFetchError: `Failed to fetch deleted file content: ${delError instanceof Error ? delError.message : "Unknown error"}`,
1112
- };
1113
- }
1114
- return entry;
1115
- }
1116
- // For modified/renamed files, skip if no diff blocks
1117
- if (!entry.diff?.lineDiffBlocks || entry.diff.lineDiffBlocks.length === 0) {
1118
- return entry;
1119
- }
1120
- // For renamed/moved files, the base version is at the original path
1121
- const basePath = normalizedOriginalPath ?? effectivePath;
1122
- try {
1123
- // Fetch file content at both commits
1124
- const [baseContent, targetContent] = await Promise.all([
1125
- // Base version (original) - use basePath for renamed files
1126
- gitApi
1127
- .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit })
1128
- .catch(() => null),
1129
- // Target version (modified)
1130
- gitApi
1131
- .getItemText(repositoryId, effectivePath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit })
1132
- .catch(() => null),
1133
- ]);
1134
- // Convert streams to text
1135
- const baseText = baseContent ? await streamToString(baseContent) : "";
1136
- const targetText = targetContent ? await streamToString(targetContent) : "";
1137
- // Check if response is an Azure DevOps error (returned as JSON in the stream)
1138
- const checkForApiError = (text, label) => {
1139
- if (text.startsWith("{")) {
1140
- try {
1141
- const parsed = JSON.parse(text);
1142
- if (parsed.$id && parsed.innerException !== undefined) {
1143
- throw new Error(`Failed to fetch ${label} file content: ${parsed.message || text}`);
1144
- }
1145
- }
1146
- catch (e) {
1147
- if (e instanceof Error && e.message.startsWith("Failed to fetch"))
1148
- throw e;
1149
- // Not valid JSON or not an error response — treat as legitimate content
1150
- }
1151
- }
1152
- };
1153
- checkForApiError(baseText, "base");
1154
- checkForApiError(targetText, "target");
1155
- // Split into lines
1156
- const baseLines = baseText.split(/\r?\n/);
1157
- const targetLines = targetText.split(/\r?\n/);
1158
- // Enrich each lineDiffBlock with actual line content
1159
- const enrichedDiff = {
1160
- ...entry.diff,
1161
- lineDiffBlocks: entry.diff.lineDiffBlocks?.map((block) => {
1162
- const enrichedBlock = { ...block };
1163
- // Add original (base) lines if they exist
1164
- if (block.originalLineNumberStart && block.originalLinesCount) {
1165
- const startIdx = block.originalLineNumberStart - 1;
1166
- const endIdx = startIdx + block.originalLinesCount;
1167
- enrichedBlock.originalLines = baseLines.slice(startIdx, endIdx);
1168
- }
1169
- // Add modified (target) lines if they exist
1170
- if (block.modifiedLineNumberStart && block.modifiedLinesCount) {
1171
- const startIdx = block.modifiedLineNumberStart - 1;
1172
- const endIdx = startIdx + block.modifiedLinesCount;
1173
- enrichedBlock.modifiedLines = targetLines.slice(startIdx, endIdx);
1174
- }
1175
- return enrichedBlock;
1176
- }),
1177
- };
1178
- return {
1179
- ...entry,
1180
- diff: enrichedDiff,
1181
- };
1182
- }
1183
- catch (contentError) {
1184
- // If content fetch fails, return entry with error
1185
- return {
1186
- ...entry,
1187
- _contentFetchError: `Failed to fetch line content: ${contentError instanceof Error ? contentError.message : "Unknown error"}`,
1188
- };
1189
- }
1190
- }));
1191
- // Write batch results back into the array
1192
- for (let j = 0; j < batchResults.length; j++) {
1193
- entriesWithContent[i + j] = batchResults[j];
1194
- }
1195
- }
1196
- enrichedChanges.changeEntries = entriesWithContent;
1197
- }
1198
- return {
1199
- content: [{ type: "text", text: JSON.stringify(enrichedChanges, null, 2) }],
1200
- };
1201
- }
1202
- catch (diffError) {
1203
- // If diff fetching fails, return metadata with error info
1204
- return {
1205
- content: [
1206
- {
1207
- type: "text",
1208
- text: JSON.stringify({
1209
- ...changes,
1210
- _diffError: `Failed to fetch diff content: ${diffError instanceof Error ? diffError.message : "Unknown error"}`,
1211
- _note: "Returned metadata only",
1212
- }, null, 2),
1213
- },
1214
- ],
1215
- };
1216
- }
100
+ const versionTypeMap = {
101
+ Branch: GitVersionType.Branch,
102
+ Commit: GitVersionType.Commit,
103
+ Tag: GitVersionType.Tag,
104
+ };
105
+ return {
106
+ version,
107
+ versionType: versionTypeMap[versionType || "Branch"] ?? GitVersionType.Branch,
108
+ };
109
+ }
110
+ function configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider) {
111
+ // --- repo_repository -------------------------------------------------------
112
+ server.tool(REPO_TOOLS.repo_repository, "Retrieve repository data for an organization or project. Use the action parameter to specify the operation.", {
113
+ action: z.enum(["get", "list"]).describe("The action to perform. Options: get (get a repository by name or ID), list (list repositories in a project)."),
114
+ project: z.string().optional().describe("The name or ID of the Azure DevOps project. Required for get and list."),
115
+ repositoryNameOrId: z.string().optional().describe("Repository name or ID. Required for get."),
116
+ top: z.coerce.number().default(100).describe("The maximum number of repositories to return. Used for list. Defaults to 100."),
117
+ skip: z.coerce.number().default(0).describe("The number of repositories to skip. Used for list. Defaults to 0."),
118
+ repoNameFilter: z.string().optional().describe("Optional filter to search for repositories by name. Used for list."),
119
+ }, async ({ action, project, repositoryNameOrId, top, skip, repoNameFilter }) => {
120
+ try {
121
+ const connection = await connectionProvider();
122
+ const gitApi = await connection.getGitApi();
123
+ if (action === "get") {
124
+ if (!project)
125
+ return { content: [{ type: "text", text: "project is required for get" }], isError: true };
126
+ if (!repositoryNameOrId)
127
+ return { content: [{ type: "text", text: "repositoryNameOrId is required for get" }], isError: true };
128
+ const repositories = await gitApi.getRepositories(project);
129
+ const repository = repositories?.find((repo) => repo.name === repositoryNameOrId || repo.id === repositoryNameOrId);
130
+ if (!repository) {
131
+ return { content: [{ type: "text", text: `Repository ${repositoryNameOrId} not found in project ${project}` }], isError: true };
1217
132
  }
133
+ return { content: [{ type: "text", text: JSON.stringify(repository, null, 2) }] };
134
+ }
135
+ if (action === "list") {
136
+ if (!project)
137
+ return { content: [{ type: "text", text: "project is required for list" }], isError: true };
138
+ const repositories = await gitApi.getRepositories(project, false, false, false);
139
+ const filteredRepositories = repoNameFilter ? filterReposByName(repositories, repoNameFilter) : repositories;
140
+ const paginatedRepositories = filteredRepositories?.sort((a, b) => a.name?.localeCompare(b.name ?? "") ?? 0).slice(skip, skip + top);
141
+ const trimmedRepositories = paginatedRepositories?.map((repo) => ({
142
+ id: repo.id,
143
+ name: repo.name,
144
+ isDisabled: repo.isDisabled,
145
+ isFork: repo.isFork,
146
+ isInMaintenance: repo.isInMaintenance,
147
+ webUrl: repo.webUrl,
148
+ size: repo.size,
149
+ }));
150
+ return { content: [{ type: "text", text: JSON.stringify(trimmedRepositories, null, 2) }] };
1218
151
  }
1219
- // Fallback: return metadata if we couldn't get diffs
1220
- return {
1221
- content: [{ type: "text", text: JSON.stringify(changes, null, 2) }],
1222
- };
152
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
1223
153
  }
1224
154
  catch (error) {
1225
155
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1226
- return {
1227
- content: [{ type: "text", text: `Error getting pull request changes: ${errorMessage}` }],
1228
- isError: true,
1229
- };
156
+ return { content: [{ type: "text", text: `Error with repository operation: ${errorMessage}` }], isError: true };
1230
157
  }
1231
158
  });
1232
- server.tool(REPO_TOOLS.reply_to_comment, "Replies to a specific comment on a pull request.", {
1233
- repositoryId: z
1234
- .string()
1235
- .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
1236
- pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request where the comment thread exists."),
1237
- threadId: z.coerce.number().min(1).describe("The ID of the thread to which the comment will be added."),
1238
- content: z.string().describe("The content of the comment to be added."),
1239
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
1240
- fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of a simple confirmation message."),
1241
- }, async ({ repositoryId, pullRequestId, threadId, content, project, fullResponse }) => {
159
+ // --- repo_pull_request -----------------------------------------------------
160
+ server.tool(REPO_TOOLS.repo_pull_request, "Retrieve pull request data. Use the action parameter to specify the operation.", {
161
+ action: z
162
+ .enum(["get", "list", "list_by_commits"])
163
+ .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)."),
164
+ 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."),
165
+ pullRequestId: z.coerce.number().min(1).optional().describe("The ID of the pull request. Required for get."),
166
+ project: z.string().optional().describe("Project ID or project name. Required for list_by_commits. Optional for get and list."),
167
+ includeWorkItemRefs: z.boolean().optional().default(false).describe("Whether to include work item references. Used for get."),
168
+ includeLabels: z.boolean().optional().default(false).describe("Whether to include labels. Used for get."),
169
+ includeChangedFiles: z.boolean().optional().default(false).describe("Whether to include the list of changed files. Used for get."),
170
+ top: z.coerce.number().default(100).describe("The maximum number of pull requests to return. Used for list. Defaults to 100."),
171
+ skip: z.coerce.number().default(0).describe("The number of pull requests to skip. Used for list. Defaults to 0."),
172
+ created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user. Used for list."),
173
+ created_by_user: z.string().optional().describe("Filter pull requests created by a specific user email. Used for list."),
174
+ i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer. Used for list."),
175
+ user_is_reviewer: z.string().optional().describe("Filter pull requests where a specific user is a reviewer (email). Used for list."),
176
+ status: z
177
+ .enum(getEnumKeys(PullRequestStatus))
178
+ .default("Active")
179
+ .describe("Filter pull requests by status. Used for list. Defaults to 'Active'."),
180
+ sourceRefName: z.string().optional().describe("Filter by source branch. Used for list."),
181
+ targetRefName: z.string().optional().describe("Filter by target branch. Used for list and create."),
182
+ repository: z.string().optional().describe("Repository name or ID. Required for list_by_commits."),
183
+ commits: z.array(z.string()).optional().describe("Array of commit IDs to query. Required for list_by_commits."),
184
+ queryType: z
185
+ .enum(Object.values(GitPullRequestQueryType).filter((v) => typeof v === "string"))
186
+ .optional()
187
+ .default(GitPullRequestQueryType[GitPullRequestQueryType.LastMergeCommit])
188
+ .describe("Type of commit query. Used for list_by_commits."),
189
+ }, 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, }) => {
1242
190
  try {
1243
191
  const connection = await connectionProvider();
1244
192
  const gitApi = await connection.getGitApi();
1245
- const comment = await gitApi.createComment({ content, commentType: 1 }, repositoryId, pullRequestId, threadId, project);
1246
- // Check if the comment was successfully created
1247
- if (!comment) {
1248
- return {
1249
- content: [{ type: "text", text: `Error: Failed to add comment to thread ${threadId}. The comment was not created successfully.` }],
1250
- isError: true,
1251
- };
193
+ if (action === "get") {
194
+ if (!repositoryId)
195
+ return { content: [{ type: "text", text: "repositoryId is required for get" }], isError: true };
196
+ if (!pullRequestId)
197
+ return { content: [{ type: "text", text: "pullRequestId is required for get" }], isError: true };
198
+ const pullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project, undefined, undefined, undefined, undefined, includeWorkItemRefs);
199
+ let enhancedResponse = { ...pullRequest };
200
+ if (includeLabels) {
201
+ try {
202
+ const projectId = pullRequest.repository?.project?.id;
203
+ const projectName = pullRequest.repository?.project?.name;
204
+ const labels = await gitApi.getPullRequestLabels(repositoryId, pullRequestId, projectName, projectId);
205
+ const labelNames = labels.map((label) => label.name).filter((name) => name !== undefined);
206
+ enhancedResponse = { ...enhancedResponse, labelSummary: { labels: labelNames, labelCount: labelNames.length } };
207
+ }
208
+ catch (error) {
209
+ console.warn(`Error fetching PR labels: ${error instanceof Error ? error.message : "Unknown error"}`);
210
+ enhancedResponse = { ...enhancedResponse, labelSummary: {} };
211
+ }
212
+ }
213
+ if (includeChangedFiles) {
214
+ try {
215
+ const iterations = await gitApi.getPullRequestIterations(repositoryId, pullRequestId, project);
216
+ if (iterations?.length) {
217
+ const latestIteration = iterations[iterations.length - 1];
218
+ if (latestIteration.id != null) {
219
+ const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, latestIteration.id, project);
220
+ enhancedResponse = {
221
+ ...enhancedResponse,
222
+ changedFilesSummary: {
223
+ changeEntries: changes?.changeEntries ?? [],
224
+ fileCount: changes?.changeEntries?.length ?? 0,
225
+ nextSkip: changes?.nextSkip,
226
+ nextTop: changes?.nextTop,
227
+ },
228
+ };
229
+ }
230
+ else {
231
+ enhancedResponse = { ...enhancedResponse, changedFilesSummary: { changeEntries: [], fileCount: 0 } };
232
+ }
233
+ }
234
+ else {
235
+ enhancedResponse = { ...enhancedResponse, changedFilesSummary: { changeEntries: [], fileCount: 0 } };
236
+ }
237
+ }
238
+ catch (error) {
239
+ console.warn(`Error fetching PR changed files: ${error instanceof Error ? error.message : "Unknown error"}`);
240
+ enhancedResponse = { ...enhancedResponse, changedFilesSummary: {} };
241
+ }
242
+ }
243
+ return { content: [{ type: "text", text: JSON.stringify(enhancedResponse, null, 2) }] };
1252
244
  }
1253
- if (fullResponse) {
1254
- return {
1255
- content: [{ type: "text", text: JSON.stringify(comment, null, 2) }],
245
+ if (action === "list") {
246
+ if (!repositoryId && !project) {
247
+ return { content: [{ type: "text", text: "Either repositoryId or project must be provided." }], isError: true };
248
+ }
249
+ const searchCriteria = { status: pullRequestStatusStringToInt(status) };
250
+ if (repositoryId)
251
+ searchCriteria.repositoryId = repositoryId;
252
+ if (sourceRefName)
253
+ searchCriteria.sourceRefName = sourceRefName;
254
+ if (targetRefName)
255
+ searchCriteria.targetRefName = targetRefName;
256
+ if (created_by_user) {
257
+ try {
258
+ const userId = await getUserIdFromEmail(created_by_user, tokenProvider, connectionProvider, userAgentProvider);
259
+ searchCriteria.creatorId = userId;
260
+ }
261
+ catch (error) {
262
+ return { content: [{ type: "text", text: `Error finding user with email ${created_by_user}: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
263
+ }
264
+ }
265
+ else if (created_by_me) {
266
+ const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
267
+ searchCriteria.creatorId = data.authenticatedUser.id;
268
+ }
269
+ if (user_is_reviewer) {
270
+ try {
271
+ const reviewerUserId = await getUserIdFromEmail(user_is_reviewer, tokenProvider, connectionProvider, userAgentProvider);
272
+ searchCriteria.reviewerId = reviewerUserId;
273
+ }
274
+ catch (error) {
275
+ return { content: [{ type: "text", text: `Error finding reviewer with email ${user_is_reviewer}: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
276
+ }
277
+ }
278
+ else if (i_am_reviewer) {
279
+ const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
280
+ searchCriteria.reviewerId = data.authenticatedUser.id;
281
+ }
282
+ let pullRequests;
283
+ /* istanbul ignore else */
284
+ if (repositoryId) {
285
+ pullRequests = await gitApi.getPullRequests(repositoryId, searchCriteria, project, undefined, skip, top);
286
+ }
287
+ else if (project) {
288
+ pullRequests = await gitApi.getPullRequestsByProject(project, searchCriteria, undefined, skip, top);
289
+ }
290
+ const filteredPullRequests = pullRequests?.map((pr) => trimPullRequest(pr));
291
+ return { content: [{ type: "text", text: JSON.stringify(filteredPullRequests, null, 2) }] };
292
+ }
293
+ if (action === "list_by_commits") {
294
+ if (!project)
295
+ return { content: [{ type: "text", text: "project is required for list_by_commits" }], isError: true };
296
+ if (!repository)
297
+ return { content: [{ type: "text", text: "repository is required for list_by_commits" }], isError: true };
298
+ if (!commits || commits.length === 0)
299
+ return { content: [{ type: "text", text: "commits is required for list_by_commits" }], isError: true };
300
+ const query = {
301
+ queries: [
302
+ {
303
+ items: commits,
304
+ type: GitPullRequestQueryType[queryType],
305
+ },
306
+ ],
1256
307
  };
308
+ const queryResult = await gitApi.getPullRequestQuery(query, repository, project);
309
+ return { content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }] };
1257
310
  }
1258
- return {
1259
- content: [{ type: "text", text: `Comment successfully added to thread ${threadId}.` }],
1260
- };
311
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
1261
312
  }
1262
313
  catch (error) {
1263
314
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1264
- return {
1265
- content: [{ type: "text", text: `Error replying to comment: ${errorMessage}` }],
1266
- isError: true,
1267
- };
315
+ return { content: [{ type: "text", text: `Error with pull request operation: ${errorMessage}` }], isError: true };
1268
316
  }
1269
317
  });
1270
- server.tool(REPO_TOOLS.create_pull_request_thread, "Creates a new comment thread on a pull request.", {
1271
- repositoryId: z
1272
- .string()
1273
- .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
1274
- pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request where the comment thread exists."),
1275
- content: z.string().describe("The content of the comment to be added."),
1276
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
1277
- filePath: z.string().optional().describe("The path of the file where the comment thread will be created. (optional)"),
318
+ // --- repo_pull_request_thread ----------------------------------------------
319
+ server.tool(REPO_TOOLS.repo_pull_request_thread, "Retrieve pull request thread and comment data. Use the action parameter to specify the operation.", {
320
+ 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)."),
321
+ repositoryId: z.string().describe("The ID or name of the repository. When using a name instead of a GUID, project must also be provided."),
322
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request."),
323
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name instead of a GUID."),
324
+ threadId: z.coerce.number().min(1).optional().describe("The ID of the thread. Required for list_comments."),
325
+ iteration: z.coerce.number().min(1).optional().describe("The iteration ID. Used for list."),
326
+ baseIteration: z.coerce.number().min(1).optional().describe("The base iteration ID. Used for list."),
327
+ top: z.coerce.number().default(100).describe("The maximum number of results to return. Defaults to 100."),
328
+ skip: z.coerce.number().default(0).describe("The number of results to skip. Defaults to 0."),
329
+ fullResponse: z.boolean().optional().default(false).describe("Return full JSON response instead of trimmed data."),
1278
330
  status: z
1279
331
  .enum(getEnumKeys(CommentThreadStatus))
1280
332
  .optional()
1281
- .default(CommentThreadStatus[CommentThreadStatus.Active])
1282
- .describe("The status of the comment thread. Defaults to 'Active'."),
1283
- rightFileStartLine: z.coerce
1284
- .number()
1285
- .min(1)
1286
- .optional()
1287
- .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)"),
1288
- rightFileStartOffset: z
1289
- .number()
1290
- .optional()
1291
- .describe("Start character offset of the thread's span within the line in the right file. The character offset of a thread's position inside of a line. Starts at 1. Must be set if rightFileStartLine is also specified. (optional)"),
1292
- rightFileEndLine: z
1293
- .number()
1294
- .optional()
1295
- .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 be set if rightFileStartLine is also specified. (optional)"),
1296
- rightFileEndOffset: z
1297
- .number()
1298
- .optional()
1299
- .describe("Exclusive end character offset of the thread's span within the line in the right file. This value is exclusive: to cover the entire line, set it to (length of the original line text) + 1. When posting a suggestion, always calculate this from the existing file content being replaced, not from the suggestion or replacement text. Must be set if rightFileEndLine is also specified. (optional)"),
1300
- }, async ({ repositoryId, pullRequestId, content, project, filePath, status, rightFileStartLine, rightFileStartOffset, rightFileEndLine, rightFileEndOffset }) => {
333
+ .describe("Filter threads by status. Used for list."),
334
+ authorEmail: z.string().optional().describe("Filter threads by the email of the thread author. Used for list."),
335
+ authorDisplayName: z.string().optional().describe("Filter threads by the display name of the thread author. Used for list."),
336
+ }, async ({ action, repositoryId, pullRequestId, project, threadId, iteration, baseIteration, top, skip, fullResponse, status, authorEmail, authorDisplayName }) => {
1301
337
  try {
1302
338
  const connection = await connectionProvider();
1303
339
  const gitApi = await connection.getGitApi();
1304
- const normalizedFilePath = filePath && !filePath.startsWith("/") ? `/${filePath}` : filePath;
1305
- const threadContext = { filePath: normalizedFilePath };
1306
- if (rightFileStartLine !== undefined) {
1307
- if (rightFileStartLine < 1) {
1308
- return {
1309
- content: [{ type: "text", text: "rightFileStartLine must be greater than or equal to 1." }],
1310
- isError: true,
1311
- };
1312
- }
1313
- threadContext.rightFileStart = { line: rightFileStartLine };
1314
- if (rightFileStartOffset !== undefined) {
1315
- if (rightFileStartOffset < 1) {
1316
- return {
1317
- content: [{ type: "text", text: "rightFileStartOffset must be greater than or equal to 1." }],
1318
- isError: true,
1319
- };
1320
- }
1321
- threadContext.rightFileStart.offset = rightFileStartOffset;
340
+ if (action === "list") {
341
+ const threads = (await gitApi.getThreads(repositoryId, pullRequestId, project, iteration, baseIteration)) ?? [];
342
+ let filteredThreads = threads;
343
+ if (status !== undefined) {
344
+ const statusValue = CommentThreadStatus[status];
345
+ filteredThreads = filteredThreads.filter((thread) => thread.status === statusValue);
1322
346
  }
1323
- }
1324
- if (rightFileEndLine !== undefined) {
1325
- if (rightFileStartLine === undefined) {
1326
- return {
1327
- content: [{ type: "text", text: "rightFileEndLine must only be specified if rightFileStartLine is also specified." }],
1328
- isError: true,
1329
- };
347
+ if (authorEmail !== undefined) {
348
+ filteredThreads = filteredThreads.filter((thread) => {
349
+ const firstComment = thread.comments?.[0];
350
+ return firstComment?.author?.uniqueName?.toLowerCase() === authorEmail.toLowerCase();
351
+ });
1330
352
  }
1331
- if (rightFileEndLine < 1) {
1332
- return {
1333
- content: [{ type: "text", text: "rightFileEndLine must be greater than or equal to 1." }],
1334
- isError: true,
1335
- };
353
+ if (authorDisplayName !== undefined) {
354
+ const lowerAuthorName = authorDisplayName.toLowerCase();
355
+ filteredThreads = filteredThreads.filter((thread) => {
356
+ const firstComment = thread.comments?.[0];
357
+ return firstComment?.author?.displayName?.toLowerCase().includes(lowerAuthorName);
358
+ });
1336
359
  }
1337
- if (rightFileEndOffset === undefined) {
1338
- return {
1339
- content: [{ type: "text", text: "rightFileEndOffset must be specified if rightFileEndLine is specified." }],
1340
- isError: true,
1341
- };
360
+ const paginatedThreads = filteredThreads.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top);
361
+ if (fullResponse) {
362
+ return { content: [{ type: "text", text: JSON.stringify(paginatedThreads, null, 2) }] };
1342
363
  }
1343
- threadContext.rightFileEnd = { line: rightFileEndLine };
1344
- if (rightFileEndOffset !== undefined) {
1345
- if (rightFileEndOffset < 1) {
1346
- return {
1347
- content: [{ type: "text", text: "rightFileEndOffset must be greater than or equal to 1." }],
1348
- isError: true,
1349
- };
1350
- }
1351
- threadContext.rightFileEnd.offset = rightFileEndOffset;
364
+ const trimmedThreads = paginatedThreads.map((thread) => trimPullRequestThread(thread));
365
+ return { content: [{ type: "text", text: JSON.stringify(trimmedThreads, null, 2) }] };
366
+ }
367
+ if (action === "list_comments") {
368
+ if (!threadId)
369
+ return { content: [{ type: "text", text: "threadId is required for list_comments" }], isError: true };
370
+ const comments = await gitApi.getComments(repositoryId, pullRequestId, threadId, project);
371
+ const paginatedComments = comments?.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top);
372
+ if (fullResponse) {
373
+ return { content: [{ type: "text", text: JSON.stringify(paginatedComments, null, 2) }] };
1352
374
  }
375
+ const trimmedComments = trimComments(paginatedComments);
376
+ return { content: [{ type: "text", text: JSON.stringify(trimmedComments, null, 2) }] };
1353
377
  }
1354
- if (rightFileEndOffset !== undefined && rightFileEndLine === undefined) {
1355
- return {
1356
- content: [{ type: "text", text: "rightFileEndLine must be specified if rightFileEndOffset is specified." }],
1357
- isError: true,
1358
- };
1359
- }
1360
- if (rightFileStartLine !== undefined && rightFileStartOffset !== undefined) {
1361
- if (rightFileEndLine === undefined || rightFileEndOffset === undefined) {
1362
- return {
1363
- content: [{ type: "text", text: "rightFileEndLine and rightFileEndOffset must both be specified when rightFileStartLine and rightFileStartOffset are both specified." }],
1364
- isError: true,
1365
- };
378
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
379
+ }
380
+ catch (error) {
381
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
382
+ return { content: [{ type: "text", text: `Error with pull request thread operation: ${errorMessage}` }], isError: true };
383
+ }
384
+ });
385
+ // --- repo_branch -----------------------------------------------------------
386
+ server.tool(REPO_TOOLS.repo_branch, "Retrieve branch data for a repository. Use the action parameter to specify the operation.", {
387
+ action: z
388
+ .enum(["get", "list", "list_mine"])
389
+ .describe("The action to perform. Options: get (get a branch by name), list (list branches in a repository), list_mine (list branches the current user has pushed to)."),
390
+ repositoryId: z.string().describe("The ID or name of the repository. When using a name instead of a GUID, project must also be provided."),
391
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name instead of a GUID."),
392
+ branchName: z.string().optional().describe("The name of the branch. Required for get."),
393
+ top: z.coerce.number().default(100).describe("The maximum number of branches to return. Used for list and list_mine. Defaults to 100."),
394
+ filterContains: z.string().optional().describe("Filter branches containing this string. Used for list and list_mine."),
395
+ }, async ({ action, repositoryId, project, branchName, top, filterContains }) => {
396
+ try {
397
+ const connection = await connectionProvider();
398
+ const gitApi = await connection.getGitApi();
399
+ if (action === "get") {
400
+ if (!branchName)
401
+ return { content: [{ type: "text", text: "branchName is required for get" }], isError: true };
402
+ const branches = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, branchName);
403
+ const branch = branches.find((branch) => branch.name === `refs/heads/${branchName}` || branch.name === branchName);
404
+ if (!branch) {
405
+ return { content: [{ type: "text", text: `Branch ${branchName} not found in repository ${repositoryId}` }], isError: true };
1366
406
  }
407
+ return { content: [{ type: "text", text: JSON.stringify(branch, null, 2) }] };
1367
408
  }
1368
- if (rightFileStartLine !== undefined && rightFileEndLine !== undefined && rightFileStartLine === rightFileEndLine) {
1369
- if (rightFileEndOffset !== undefined && rightFileStartOffset !== undefined && rightFileEndOffset < rightFileStartOffset) {
1370
- return {
1371
- content: [{ type: "text", text: "rightFileEndOffset must be greater than or equal to rightFileStartOffset when both are on the same line." }],
1372
- isError: true,
1373
- };
1374
- }
409
+ if (action === "list") {
410
+ const branches = await gitApi.getRefs(repositoryId, project, "heads/", undefined, undefined, undefined, undefined, undefined, filterContains);
411
+ const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top);
412
+ return { content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }] };
1375
413
  }
1376
- const thread = await gitApi.createThread({ comments: [{ content: content, commentType: 1 }], threadContext: threadContext, status: CommentThreadStatus[status] }, repositoryId, pullRequestId, project);
1377
- const trimmedThread = trimPullRequestThread(thread);
1378
- return {
1379
- content: [{ type: "text", text: JSON.stringify(trimmedThread, null, 2) }],
1380
- };
414
+ if (action === "list_mine") {
415
+ const branches = await gitApi.getRefs(repositoryId, project, undefined, undefined, undefined, true, undefined, undefined, filterContains);
416
+ const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top);
417
+ return { content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }] };
418
+ }
419
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
1381
420
  }
1382
421
  catch (error) {
1383
422
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1384
- return {
1385
- content: [{ type: "text", text: `Error creating pull request thread: ${errorMessage}` }],
1386
- isError: true,
1387
- };
423
+ return { content: [{ type: "text", text: `Error with branch operation: ${errorMessage}` }], isError: true };
1388
424
  }
1389
425
  });
1390
- server.tool(REPO_TOOLS.update_pull_request_thread, "Updates an existing comment thread on a pull request.", {
1391
- repositoryId: z
1392
- .string()
1393
- .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."),
1394
- pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request where the comment thread exists."),
1395
- threadId: z.coerce.number().min(1).describe("The ID of the thread to update."),
1396
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
1397
- status: z
1398
- .enum(getEnumKeys(CommentThreadStatus))
426
+ // --- repo_file -------------------------------------------------------------
427
+ const fileVersionTypeStrings = getEnumKeys(GitVersionType);
428
+ server.tool(REPO_TOOLS.repo_file, "Retrieve file data from a repository. Use the action parameter to specify the operation.", {
429
+ action: z
430
+ .enum(["get_content", "list_directory"])
431
+ .describe("The action to perform. Options: get_content (get the text content of a file at a specific branch, tag, or commit), list_directory (list files and folders in a directory)."),
432
+ repositoryId: z.string().describe("The ID or name of the repository."),
433
+ path: z.string().optional().default("/").describe("The file or directory path. Required for get_content. Defaults to '/' for list_directory."),
434
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name."),
435
+ version: z.string().optional().describe("Version string: branch name, tag name, or commit SHA."),
436
+ versionType: z
437
+ .enum(fileVersionTypeStrings)
1399
438
  .optional()
1400
- .describe("The new status for the comment thread."),
1401
- }, async ({ repositoryId, pullRequestId, threadId, project, status }) => {
439
+ .default("Commit")
440
+ .describe("How to interpret the version parameter. Used for get_content. Defaults to 'Commit'."),
441
+ recursive: z.boolean().optional().default(false).describe("Whether to list items recursively. Used for list_directory. Defaults to false."),
442
+ 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."),
443
+ }, async ({ action, repositoryId, path, project, version, versionType, recursive, recursionDepth }) => {
1402
444
  try {
1403
445
  const connection = await connectionProvider();
1404
446
  const gitApi = await connection.getGitApi();
1405
- const updateRequest = {};
1406
- if (status !== undefined) {
1407
- updateRequest.status = CommentThreadStatus[status];
1408
- }
1409
- if (Object.keys(updateRequest).length === 0) {
1410
- return {
1411
- content: [{ type: "text", text: "Error: At least one field (status) must be provided for update." }],
1412
- isError: true,
1413
- };
1414
- }
1415
- const thread = await gitApi.updateThread(updateRequest, repositoryId, pullRequestId, threadId, project);
1416
- if (!thread) {
447
+ if (action === "get_content") {
448
+ if (!path)
449
+ return { content: [{ type: "text", text: "path is required for get_content" }], isError: true };
450
+ const versionDescriptor = version ? { version, versionType: GitVersionType[versionType] } : undefined;
451
+ const stream = await gitApi.getItemText(repositoryId, path, project, undefined, undefined, undefined, undefined, false, versionDescriptor, true);
452
+ const content = await streamToString(stream);
453
+ const streamError = extractAdoStreamError(content);
454
+ if (streamError) {
455
+ return { content: [{ type: "text", text: `Error getting file content for '${path}': ${streamError}` }], isError: true };
456
+ }
457
+ return { content: [{ type: "text", text: content }] };
458
+ }
459
+ if (action === "list_directory") {
460
+ const versionDescriptor = buildVersionDescriptor(version, versionType === "Commit" ? "Branch" : versionType);
461
+ const clampedDepth = Math.min(Math.max(recursionDepth || 1, 1), 10);
462
+ const recursionType = recursive ? VersionControlRecursionType.Full : VersionControlRecursionType.OneLevel;
463
+ const items = await gitApi.getItems(repositoryId, project, path, recursionType, true, false, false, false, versionDescriptor);
464
+ if (!items || items.length === 0) {
465
+ return { content: [{ type: "text", text: `No items found at path: ${path}. The path may not exist in the repository.` }], isError: true };
466
+ }
467
+ let filteredItems = items;
468
+ if (recursive && clampedDepth < 10) {
469
+ const basePath = path === "/" ? "" : path;
470
+ const baseDepth = basePath.split("/").filter((p) => p).length;
471
+ filteredItems = items.filter((item) => {
472
+ if (!item.path)
473
+ return false;
474
+ const itemDepth = item.path.split("/").filter((p) => p).length;
475
+ return itemDepth <= baseDepth + clampedDepth;
476
+ });
477
+ }
478
+ const formattedItems = filteredItems.map((item) => ({
479
+ path: item.path,
480
+ isFolder: item.isFolder,
481
+ gitObjectType: item.gitObjectType,
482
+ commitId: item.commitId,
483
+ contentMetadata: item.contentMetadata ? { contentType: item.contentMetadata.contentType, fileName: item.contentMetadata.fileName } : undefined,
484
+ }));
1417
485
  return {
1418
- content: [{ type: "text", text: `Error: Failed to update thread ${threadId}. The thread was not updated successfully.` }],
1419
- isError: true,
486
+ content: [
487
+ {
488
+ type: "text",
489
+ text: JSON.stringify({ count: formattedItems.length, path, recursive, recursionDepth: recursive ? clampedDepth : undefined, items: formattedItems }, null, 2),
490
+ },
491
+ ],
1420
492
  };
1421
493
  }
1422
- const trimmedThread = trimPullRequestThread(thread);
1423
- return {
1424
- content: [{ type: "text", text: JSON.stringify(trimmedThread, null, 2) }],
1425
- };
494
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
1426
495
  }
1427
496
  catch (error) {
1428
497
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1429
- return {
1430
- content: [{ type: "text", text: `Error updating pull request thread: ${errorMessage}` }],
1431
- isError: true,
1432
- };
498
+ return { content: [{ type: "text", text: `Error with file operation: ${errorMessage}` }], isError: true };
1433
499
  }
1434
500
  });
1435
- server.tool(REPO_TOOLS.search_commits, "Search for commits in a repository with comprehensive filtering capabilities. Supports searching by description/comment text, time range, author and more.", {
501
+ // --- repo_search_commits ---------------------------------------------------
502
+ server.tool(REPO_TOOLS.repo_search_commits, "Search commits with filtering by text, author, date range, and more.", {
1436
503
  searchText: z.string().describe("Keywords to search for in commit messages"),
1437
504
  project: z
1438
- .union([z.string().transform((value) => [value]), z.array(z.string())])
505
+ .union([z.string().transform(/* istanbul ignore next */ (value) => [value]), z.array(z.string())])
1439
506
  .optional()
1440
507
  .describe("The names of the projects to search within. If omitted, searches across all projects in the organization."),
1441
- repository: z.array(z.string()).optional().describe("The names of the repositories to search within. If omitted, searches across all repositories in the specified projects."),
1442
- branch: z.array(z.string()).optional().describe("The names of the repository branches to search within. If omitted, searches across all branches in the specified repositories."),
508
+ repository: z.array(z.string()).optional().describe("The names of the repositories to search within."),
509
+ branch: z.array(z.string()).optional().describe("The names of the repository branches to search within."),
1443
510
  author: z.array(z.string()).optional().describe("The names of the commit authors to search for. Only full display names are supported."),
1444
511
  commitStartDate: z.string().optional().describe("Filter commits from this date (format: 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS')"),
1445
- commitEndDate: z.string().optional().describe("Filter commits up to this date (format: 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS', e.g. '2025-06-19T23:59:59' for full day)"),
1446
- orderBy: z.enum(["ASC", "DESC"]).optional().describe("Sort commits by date: 'ASC' for oldest-first, 'DESC' for newest-first. Defaults to relevance if omitted."),
512
+ commitEndDate: z.string().optional().describe("Filter commits up to this date (format: 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS')"),
513
+ orderBy: z.enum(["ASC", "DESC"]).optional().describe("Sort commits by date: 'ASC' for oldest-first, 'DESC' for newest-first."),
1447
514
  includeFacets: z.boolean().default(false).describe("Include facets in the search results"),
1448
515
  skip: z.coerce.number().default(0).describe("Number of results to skip"),
1449
516
  top: z.coerce.number().default(10).describe("Maximum number of results to return"),
1450
517
  }, async ({ searchText, project, repository, branch, author, commitStartDate, commitEndDate, orderBy, includeFacets, skip, top }) => {
1451
518
  const accessToken = await tokenProvider();
1452
519
  const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/commitSearchResults?api-version=${apiVersion}`;
1453
- const requestBody = {
1454
- searchText,
1455
- includeFacets,
1456
- $skip: skip,
1457
- $top: top,
1458
- };
520
+ const requestBody = { searchText, includeFacets, $skip: skip, $top: top };
1459
521
  const filters = {};
1460
522
  if (project && project.length > 0)
1461
523
  filters.projectName = project;
@@ -1486,212 +548,360 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1486
548
  throw new Error(`Azure DevOps Commit Search API error: ${response.status} ${response.statusText}`);
1487
549
  }
1488
550
  const result = await response.text();
1489
- return {
1490
- content: [{ type: "text", text: result }],
1491
- };
551
+ return { content: [{ type: "text", text: result }] };
1492
552
  });
1493
- const pullRequestQueryTypesStrings = Object.values(GitPullRequestQueryType).filter((value) => typeof value === "string");
1494
- server.tool(REPO_TOOLS.list_pull_requests_by_commits, "Lists pull requests by commit IDs to find which pull requests contain specific commits", {
1495
- project: z.string().describe("Project name or ID"),
1496
- repository: z.string().describe("Repository name or ID"),
1497
- commits: z.array(z.string()).describe("Array of commit IDs to query for"),
1498
- queryType: z
1499
- .enum(pullRequestQueryTypesStrings)
553
+ // --- repo_pull_request_write -----------------------------------------------
554
+ server.tool(REPO_TOOLS.repo_pull_request_write, "Write operations for pull requests. Use the action parameter to specify the operation.", {
555
+ action: z
556
+ .enum(["create", "update", "update_reviewers", "vote"])
557
+ .describe("The action to perform. Options: create (create a pull request), update (update a pull request, including setting autocomplete), update_reviewers (add or remove pull request reviewers), vote (cast a vote on a pull request)."),
558
+ repositoryId: z.string().optional().describe("The ID or name of the repository. Required for all actions. When using a name instead of a GUID, project must also be provided."),
559
+ pullRequestId: z.coerce.number().min(1).optional().describe("The ID of the pull request. Required for update, update_reviewers, and vote."),
560
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name instead of a GUID."),
561
+ sourceRefName: z.string().optional().describe("The source branch name (e.g., 'refs/heads/feature-branch'). Required for create."),
562
+ targetRefName: z.string().optional().describe("The target branch name (e.g., 'refs/heads/main'). Required for create. Optional for update."),
563
+ title: z.string().optional().describe("The title of the pull request. Required for create. Optional for update."),
564
+ description: z.string().max(4000).optional().describe("The description of the pull request. Max 4000 characters. Used for create and update."),
565
+ isDraft: z.boolean().optional().default(false).describe("Whether the pull request is a draft. Used for create and update."),
566
+ workItems: z.string().optional().describe("Work item IDs to associate, space-separated. Used for create."),
567
+ forkSourceRepositoryId: z.string().optional().describe("The ID of the fork repository. Used for create."),
568
+ labels: z.array(z.string()).optional().describe("Array of label names. Used for create and update."),
569
+ status: z.enum(["Active", "Abandoned"]).optional().describe("The new status. Used for update."),
570
+ autoComplete: z.boolean().optional().describe("Set autocomplete when all requirements are met. Used for update."),
571
+ mergeStrategy: z
572
+ .enum(getEnumKeys(GitPullRequestMergeStrategy))
1500
573
  .optional()
1501
- .default(GitPullRequestQueryType[GitPullRequestQueryType.LastMergeCommit])
1502
- .describe("Type of query to perform"),
1503
- }, async ({ project, repository, commits, queryType }) => {
574
+ .describe("The merge strategy for autocomplete. Used for update."),
575
+ mergeCommitMessage: z.string().optional().describe("Commit message for autocomplete. Used for update."),
576
+ deleteSourceBranch: z.boolean().optional().default(false).describe("Delete source branch on autocomplete. Used for update."),
577
+ transitionWorkItems: z.boolean().optional().default(true).describe("Transition work items on autocomplete. Used for update."),
578
+ bypassReason: z.string().optional().describe("Reason for bypassing branch policies. Used for update."),
579
+ reviewerIds: z.array(z.string()).optional().describe("List of reviewer IDs. Required for update_reviewers."),
580
+ reviewerAction: z.enum(["add", "remove"]).optional().describe("Whether to add or remove reviewers. Required for update_reviewers."),
581
+ vote: z.enum(["Approved", "ApprovedWithSuggestions", "NoVote", "WaitingForAuthor", "Rejected"]).optional().describe("The vote to cast. Required for vote."),
582
+ }, async ({ action, repositoryId, pullRequestId, project, sourceRefName, targetRefName, title, description, isDraft, workItems, forkSourceRepositoryId, labels, status, autoComplete, mergeStrategy, mergeCommitMessage, deleteSourceBranch, transitionWorkItems, bypassReason, reviewerIds, reviewerAction, vote, }) => {
1504
583
  try {
1505
584
  const connection = await connectionProvider();
1506
585
  const gitApi = await connection.getGitApi();
1507
- const query = {
1508
- queries: [
1509
- {
1510
- items: commits,
1511
- type: GitPullRequestQueryType[queryType],
1512
- },
1513
- ],
1514
- };
1515
- const queryResult = await gitApi.getPullRequestQuery(query, repository, project);
1516
- return {
1517
- content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }],
1518
- };
586
+ if (action === "create") {
587
+ if (!repositoryId)
588
+ return { content: [{ type: "text", text: "repositoryId is required for create" }], isError: true };
589
+ if (!sourceRefName)
590
+ return { content: [{ type: "text", text: "sourceRefName is required for create" }], isError: true };
591
+ if (!targetRefName)
592
+ return { content: [{ type: "text", text: "targetRefName is required for create" }], isError: true };
593
+ if (!title)
594
+ return { content: [{ type: "text", text: "title is required for create" }], isError: true };
595
+ const workItemRefs = workItems ? workItems.split(" ").map((id) => ({ id: id.trim() })) : [];
596
+ const noDataErrorMessage = `Pull request creation returned no data and no matching PR was found. This often means repositoryId="${repositoryId}" was not resolvable. ` +
597
+ "Try the repository GUID from repo_repository (list action) instead of the Project/RepoName slash format.";
598
+ const forkSource = forkSourceRepositoryId ? { repository: { id: forkSourceRepositoryId } } : undefined;
599
+ const labelDefinitions = labels ? labels.map((label) => ({ name: label })) : undefined;
600
+ let pullRequest = await gitApi.createPullRequest({ sourceRefName, targetRefName, title, description, isDraft, workItemRefs, forkSource, labels: labelDefinitions, supportsIterations: true }, repositoryId, project);
601
+ if (!pullRequest) {
602
+ const prs = await gitApi.getPullRequests(repositoryId, { sourceRefName, targetRefName, status: PullRequestStatus.Active }, project, undefined, 0, 1);
603
+ if (prs && prs.length > 0) {
604
+ pullRequest = prs[0];
605
+ }
606
+ else {
607
+ return { content: [{ type: "text", text: noDataErrorMessage }], isError: true };
608
+ }
609
+ }
610
+ const trimmedPullRequest = trimPullRequest(pullRequest, true);
611
+ return { content: [{ type: "text", text: JSON.stringify(trimmedPullRequest, null, 2) }] };
612
+ }
613
+ if (action === "update") {
614
+ if (!repositoryId)
615
+ return { content: [{ type: "text", text: "repositoryId is required for update" }], isError: true };
616
+ if (!pullRequestId)
617
+ return { content: [{ type: "text", text: "pullRequestId is required for update" }], isError: true };
618
+ const updateRequest = {};
619
+ if (title !== undefined)
620
+ updateRequest.title = title;
621
+ if (description !== undefined)
622
+ updateRequest.description = description;
623
+ if (isDraft !== undefined)
624
+ updateRequest.isDraft = isDraft;
625
+ if (targetRefName !== undefined)
626
+ updateRequest.targetRefName = targetRefName;
627
+ if (status !== undefined) {
628
+ updateRequest.status = status === "Active" ? PullRequestStatus.Active.valueOf() : PullRequestStatus.Abandoned.valueOf();
629
+ }
630
+ if (autoComplete !== undefined) {
631
+ if (autoComplete) {
632
+ const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
633
+ updateRequest.autoCompleteSetBy = { id: data.authenticatedUser.id };
634
+ const completionOptions = {
635
+ deleteSourceBranch: deleteSourceBranch || false,
636
+ transitionWorkItems: transitionWorkItems !== false,
637
+ bypassPolicy: !!bypassReason,
638
+ };
639
+ if (mergeStrategy)
640
+ completionOptions.mergeStrategy = GitPullRequestMergeStrategy[mergeStrategy];
641
+ if (mergeCommitMessage)
642
+ completionOptions.mergeCommitMessage = mergeCommitMessage;
643
+ if (bypassReason)
644
+ completionOptions.bypassReason = bypassReason;
645
+ updateRequest.completionOptions = completionOptions;
646
+ }
647
+ else {
648
+ updateRequest.autoCompleteSetBy = null;
649
+ updateRequest.completionOptions = null;
650
+ }
651
+ }
652
+ if (Object.keys(updateRequest).length === 0 && !labels) {
653
+ return {
654
+ content: [{ type: "text", text: "Error: At least one field (title, description, isDraft, targetRefName, status, autoComplete options, or labels) must be provided for update." }],
655
+ isError: true,
656
+ };
657
+ }
658
+ if (labels) {
659
+ const currentLabels = await gitApi.getPullRequestLabels(repositoryId, pullRequestId, project);
660
+ for (const currentLabel of currentLabels) {
661
+ if (currentLabel.id)
662
+ await gitApi.deletePullRequestLabels(repositoryId, pullRequestId, currentLabel.id, project);
663
+ }
664
+ for (const label of labels) {
665
+ await gitApi.createPullRequestLabel({ name: label }, repositoryId, pullRequestId, project);
666
+ }
667
+ }
668
+ let updatedPullRequest;
669
+ if (Object.keys(updateRequest).length > 0) {
670
+ updatedPullRequest = await gitApi.updatePullRequest(updateRequest, repositoryId, pullRequestId, project);
671
+ }
672
+ else {
673
+ updatedPullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project);
674
+ }
675
+ const trimmedUpdatedPullRequest = trimPullRequest(updatedPullRequest, true);
676
+ if (!trimmedUpdatedPullRequest) {
677
+ return { content: [{ type: "text", text: "Pull request updated but API returned no data." }] };
678
+ }
679
+ return { content: [{ type: "text", text: JSON.stringify(trimmedUpdatedPullRequest, null, 2) }] };
680
+ }
681
+ if (action === "update_reviewers") {
682
+ if (!repositoryId)
683
+ return { content: [{ type: "text", text: "repositoryId is required for update_reviewers" }], isError: true };
684
+ if (!pullRequestId)
685
+ return { content: [{ type: "text", text: "pullRequestId is required for update_reviewers" }], isError: true };
686
+ if (!reviewerIds || reviewerIds.length === 0)
687
+ return { content: [{ type: "text", text: "reviewerIds is required for update_reviewers" }], isError: true };
688
+ if (!reviewerAction)
689
+ return { content: [{ type: "text", text: "reviewerAction is required for update_reviewers" }], isError: true };
690
+ if (reviewerAction === "add") {
691
+ const updatedReviewers = await gitApi.createPullRequestReviewers(reviewerIds.map((id) => ({ id })), repositoryId, pullRequestId, project);
692
+ const trimmedResponse = updatedReviewers.map((item) => ({
693
+ displayName: item.displayName,
694
+ id: item.id,
695
+ uniqueName: item.uniqueName,
696
+ vote: item.vote,
697
+ hasDeclined: item.hasDeclined,
698
+ isFlagged: item.isFlagged,
699
+ }));
700
+ return { content: [{ type: "text", text: JSON.stringify(trimmedResponse, null, 2) }] };
701
+ }
702
+ else {
703
+ for (const reviewerId of reviewerIds) {
704
+ await gitApi.deletePullRequestReviewer(repositoryId, pullRequestId, reviewerId, project);
705
+ }
706
+ return { content: [{ type: "text", text: `Reviewers with IDs ${reviewerIds.join(", ")} removed from pull request ${pullRequestId}.` }] };
707
+ }
708
+ }
709
+ if (action === "vote") {
710
+ if (!repositoryId)
711
+ return { content: [{ type: "text", text: "repositoryId is required for vote" }], isError: true };
712
+ if (!pullRequestId)
713
+ return { content: [{ type: "text", text: "pullRequestId is required for vote" }], isError: true };
714
+ if (!vote)
715
+ return { content: [{ type: "text", text: "vote is required for vote action" }], isError: true };
716
+ const userDetails = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
717
+ const userId = userDetails.authenticatedUser.id;
718
+ if (!userId)
719
+ throw new Error("Could not determine authenticated user ID.");
720
+ const voteMap = {
721
+ Approved: 10,
722
+ ApprovedWithSuggestions: 5,
723
+ NoVote: 0,
724
+ WaitingForAuthor: -5,
725
+ Rejected: -10,
726
+ };
727
+ const existingReviewer = await gitApi.getPullRequestReviewer(repositoryId, pullRequestId, userId, project).catch((error) => {
728
+ if (!(error instanceof Error) || !/not found|reviewer does not exist/i.test(error.message))
729
+ throw error;
730
+ return undefined;
731
+ });
732
+ const reviewerPayload = {
733
+ vote: voteMap[vote],
734
+ id: userId,
735
+ ...(existingReviewer?.isRequired !== undefined ? { isRequired: existingReviewer.isRequired } : {}),
736
+ };
737
+ await gitApi.createPullRequestReviewer(reviewerPayload, repositoryId, pullRequestId, userId, project);
738
+ return { content: [{ type: "text", text: `Successfully cast vote '${vote}' on PR #${pullRequestId}.` }] };
739
+ }
740
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
1519
741
  }
1520
742
  catch (error) {
1521
743
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1522
- return {
1523
- content: [{ type: "text", text: `Error querying pull requests by commits: ${errorMessage}` }],
1524
- isError: true,
1525
- };
744
+ return { content: [{ type: "text", text: `Error with pull request write operation: ${errorMessage}` }], isError: true };
1526
745
  }
1527
746
  });
1528
- server.tool(REPO_TOOLS.vote_pull_request, "Cast a vote on a pull request. Automatically adds the current user as a reviewer if they are not already one.", {
1529
- repositoryId: z.string().describe("The ID or name of the repository. When using a repository name instead of a GUID, the project parameter must also be provided."),
747
+ // --- repo_pull_request_thread_write ----------------------------------------
748
+ server.tool(REPO_TOOLS.repo_pull_request_thread_write, "Write operations for pull request comment threads. Use the action parameter to specify the operation.", {
749
+ action: z
750
+ .enum(["create", "reply", "update_status"])
751
+ .describe("The action to perform. Options: create (create a new comment thread on a pull request), reply (reply to a comment in a thread), update_status (update the status of a comment thread)."),
752
+ repositoryId: z.string().describe("The ID or name of the repository. When using a name instead of a GUID, project must also be provided."),
1530
753
  pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request."),
1531
- vote: z.enum(["Approved", "ApprovedWithSuggestions", "NoVote", "WaitingForAuthor", "Rejected"]).describe("The vote to cast: Approved(10), Suggestions(5), None(0), Waiting(-5), Rejected(-10)."),
1532
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
1533
- }, async ({ repositoryId, pullRequestId, vote, project }) => {
1534
- const connection = await connectionProvider();
1535
- const gitApi = await connection.getGitApi();
1536
- const userDetails = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
1537
- const userId = userDetails.authenticatedUser.id;
1538
- if (!userId) {
1539
- throw new Error("Could not determine authenticated user ID.");
1540
- }
1541
- const voteMap = {
1542
- Approved: 10,
1543
- ApprovedWithSuggestions: 5,
1544
- NoVote: 0,
1545
- WaitingForAuthor: -5,
1546
- Rejected: -10,
1547
- };
1548
- const existingReviewer = await gitApi.getPullRequestReviewer(repositoryId, pullRequestId, userId, project).catch((error) => {
1549
- if (!(error instanceof Error) || !/not found|reviewer does not exist/i.test(error.message)) {
1550
- throw error;
1551
- }
1552
- return undefined;
1553
- });
1554
- const reviewerPayload = {
1555
- vote: voteMap[vote],
1556
- id: userId,
1557
- ...(existingReviewer?.isRequired !== undefined ? { isRequired: existingReviewer.isRequired } : {}),
1558
- };
1559
- await gitApi.createPullRequestReviewer(reviewerPayload, repositoryId, pullRequestId, userId, project);
1560
- return {
1561
- content: [
1562
- {
1563
- type: "text",
1564
- text: `Successfully cast vote '${vote}' on PR #${pullRequestId}.`,
1565
- },
1566
- ],
1567
- };
1568
- });
1569
- server.tool(REPO_TOOLS.list_directory, "List files and folders in a directory within a repository. Useful for exploring the structure of a codebase or finding related files. Returns isError: true if the path is not found.", {
1570
- repositoryId: z.string().describe("The ID or name of the repository."),
1571
- path: z.string().optional().default("/").describe("The directory path to list (e.g., '/src' or '/src/components'). Defaults to repository root."),
1572
- project: z.string().optional().describe("Project ID or name. Required if repositoryId is a name rather than a GUID."),
1573
- version: z.string().optional().describe("The version identifier - branch name (e.g., 'main'), tag name, or commit SHA. Defaults to the repository's default branch."),
1574
- versionType: z.enum(["Branch", "Commit", "Tag"]).optional().default("Branch").describe("The type of version identifier: 'Branch', 'Commit', or 'Tag'. Defaults to 'Branch'."),
1575
- recursive: z.boolean().optional().default(false).describe("Whether to list items recursively. Defaults to false."),
1576
- recursionDepth: z.coerce.number().min(1).optional().default(1).describe("Maximum depth for recursive listing (1-10). Only applies when recursive is true. Defaults to 1."),
1577
- }, async ({ repositoryId, path, project, version, versionType, recursive, recursionDepth }) => {
754
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name instead of a GUID."),
755
+ threadId: z.coerce.number().min(1).optional().describe("The ID of the thread. Required for reply and update_status."),
756
+ content: z.string().optional().describe("The content of the comment. Required for create and reply."),
757
+ status: z
758
+ .enum(getEnumKeys(CommentThreadStatus))
759
+ .optional()
760
+ .default(CommentThreadStatus[CommentThreadStatus.Active])
761
+ .describe("The thread status. Used for create (defaults to 'Active') and required for update_status."),
762
+ filePath: z.string().optional().describe("The file path for the comment thread. Used for create."),
763
+ fullResponse: z.boolean().optional().default(false).describe("Return full JSON response. Used for reply."),
764
+ rightFileStartLine: z.coerce.number().min(1).optional().describe("Start line in the right file. Used for create."),
765
+ rightFileStartOffset: z.number().optional().describe("Start character offset in the right file. Used for create."),
766
+ rightFileEndLine: z.number().optional().describe("End line in the right file. Used for create."),
767
+ rightFileEndOffset: z.number().optional().describe("End character offset in the right file. Used for create."),
768
+ }, async ({ action, repositoryId, pullRequestId, project, threadId, content, status, filePath, fullResponse, rightFileStartLine, rightFileStartOffset, rightFileEndLine, rightFileEndOffset }) => {
1578
769
  try {
1579
770
  const connection = await connectionProvider();
1580
771
  const gitApi = await connection.getGitApi();
1581
- const versionDescriptor = buildVersionDescriptor(version, versionType);
1582
- const clampedDepth = Math.min(Math.max(recursionDepth || 1, 1), 10);
1583
- let recursionType = VersionControlRecursionType.OneLevel;
1584
- if (recursive) {
1585
- recursionType = VersionControlRecursionType.Full;
1586
- }
1587
- const items = await gitApi.getItems(repositoryId, project, path, recursionType, true, false, false, false, versionDescriptor);
1588
- if (!items || items.length === 0) {
1589
- return {
1590
- content: [{ type: "text", text: `No items found at path: ${path}. The path may not exist in the repository.` }],
1591
- isError: true,
772
+ if (action === "create") {
773
+ if (!content)
774
+ return { content: [{ type: "text", text: "content is required for create" }], isError: true };
775
+ const normalizedFilePath = filePath && !filePath.startsWith("/") ? `/${filePath}` : filePath;
776
+ const threadContext = { filePath: normalizedFilePath };
777
+ if (rightFileStartLine !== undefined) {
778
+ if (rightFileStartLine < 1)
779
+ return { content: [{ type: "text", text: "rightFileStartLine must be greater than or equal to 1." }], isError: true };
780
+ threadContext.rightFileStart = { line: rightFileStartLine };
781
+ if (rightFileStartOffset !== undefined) {
782
+ if (rightFileStartOffset < 1)
783
+ return { content: [{ type: "text", text: "rightFileStartOffset must be greater than or equal to 1." }], isError: true };
784
+ threadContext.rightFileStart.offset = rightFileStartOffset;
785
+ }
786
+ }
787
+ if (rightFileEndLine !== undefined) {
788
+ if (rightFileStartLine === undefined)
789
+ return { content: [{ type: "text", text: "rightFileEndLine must only be specified if rightFileStartLine is also specified." }], isError: true };
790
+ if (rightFileEndLine < 1)
791
+ return { content: [{ type: "text", text: "rightFileEndLine must be greater than or equal to 1." }], isError: true };
792
+ if (rightFileEndOffset === undefined)
793
+ return { content: [{ type: "text", text: "rightFileEndOffset must be specified if rightFileEndLine is specified." }], isError: true };
794
+ threadContext.rightFileEnd = { line: rightFileEndLine };
795
+ /* istanbul ignore else */
796
+ if (rightFileEndOffset !== undefined) {
797
+ if (rightFileEndOffset < 1)
798
+ return { content: [{ type: "text", text: "rightFileEndOffset must be greater than or equal to 1." }], isError: true };
799
+ threadContext.rightFileEnd.offset = rightFileEndOffset;
800
+ }
801
+ }
802
+ if (rightFileEndOffset !== undefined && rightFileEndLine === undefined) {
803
+ return { content: [{ type: "text", text: "rightFileEndLine must be specified if rightFileEndOffset is specified." }], isError: true };
804
+ }
805
+ if (rightFileStartLine !== undefined && rightFileStartOffset !== undefined) {
806
+ if (rightFileEndLine === undefined || rightFileEndOffset === undefined) {
807
+ return {
808
+ content: [{ type: "text", text: "rightFileEndLine and rightFileEndOffset must both be specified when rightFileStartLine and rightFileStartOffset are both specified." }],
809
+ isError: true,
810
+ };
811
+ }
812
+ }
813
+ if (rightFileStartLine !== undefined && rightFileEndLine !== undefined && rightFileStartLine === rightFileEndLine) {
814
+ if (rightFileEndOffset !== undefined && rightFileStartOffset !== undefined && rightFileEndOffset < rightFileStartOffset) {
815
+ return { content: [{ type: "text", text: "rightFileEndOffset must be greater than or equal to rightFileStartOffset when both are on the same line." }], isError: true };
816
+ }
817
+ }
818
+ const thread = await gitApi.createThread({ comments: [{ content, commentType: 1 }], threadContext, status: CommentThreadStatus[status] }, repositoryId, pullRequestId, project);
819
+ return { content: [{ type: "text", text: JSON.stringify(trimPullRequestThread(thread), null, 2) }] };
820
+ }
821
+ if (action === "reply") {
822
+ if (!threadId)
823
+ return { content: [{ type: "text", text: "threadId is required for reply" }], isError: true };
824
+ if (!content)
825
+ return { content: [{ type: "text", text: "content is required for reply" }], isError: true };
826
+ const comment = await gitApi.createComment({ content, commentType: 1 }, repositoryId, pullRequestId, threadId, project);
827
+ if (!comment) {
828
+ return { content: [{ type: "text", text: `Error: Failed to add comment to thread ${threadId}. The comment was not created successfully.` }], isError: true };
829
+ }
830
+ if (fullResponse)
831
+ return { content: [{ type: "text", text: JSON.stringify(comment, null, 2) }] };
832
+ return { content: [{ type: "text", text: `Comment successfully added to thread ${threadId}.` }] };
833
+ }
834
+ if (action === "update_status") {
835
+ if (!threadId)
836
+ return { content: [{ type: "text", text: "threadId is required for update_status" }], isError: true };
837
+ if (!status)
838
+ return { content: [{ type: "text", text: "status is required for update_status" }], isError: true };
839
+ const updateRequest = {
840
+ status: CommentThreadStatus[status],
1592
841
  };
842
+ const thread = await gitApi.updateThread(updateRequest, repositoryId, pullRequestId, threadId, project);
843
+ if (!thread) {
844
+ return { content: [{ type: "text", text: `Error: Failed to update thread ${threadId}. The thread was not updated successfully.` }], isError: true };
845
+ }
846
+ return { content: [{ type: "text", text: JSON.stringify(trimPullRequestThread(thread), null, 2) }] };
1593
847
  }
1594
- let filteredItems = items;
1595
- if (recursive && clampedDepth < 10) {
1596
- const basePath = path === "/" ? "" : path;
1597
- const baseDepth = basePath.split("/").filter((p) => p).length;
1598
- filteredItems = items.filter((item) => {
1599
- if (!item.path)
1600
- return false;
1601
- const itemDepth = item.path.split("/").filter((p) => p).length;
1602
- return itemDepth <= baseDepth + clampedDepth;
1603
- });
1604
- }
1605
- const formattedItems = filteredItems.map((item) => ({
1606
- path: item.path,
1607
- isFolder: item.isFolder,
1608
- gitObjectType: item.gitObjectType,
1609
- commitId: item.commitId,
1610
- contentMetadata: item.contentMetadata
1611
- ? {
1612
- contentType: item.contentMetadata.contentType,
1613
- fileName: item.contentMetadata.fileName,
1614
- }
1615
- : undefined,
1616
- }));
1617
- const response = {
1618
- count: formattedItems.length,
1619
- path: path,
1620
- recursive: recursive,
1621
- recursionDepth: recursive ? clampedDepth : undefined,
1622
- items: formattedItems,
1623
- };
1624
- return {
1625
- content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
1626
- };
848
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
1627
849
  }
1628
850
  catch (error) {
1629
851
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1630
- return {
1631
- content: [{ type: "text", text: `Error listing directory: ${errorMessage}` }],
1632
- isError: true,
1633
- };
852
+ return { content: [{ type: "text", text: `Error with pull request thread write operation: ${errorMessage}` }], isError: true };
1634
853
  }
1635
854
  });
1636
- // ── Get file content at a specific version (branch, tag, or commit) ──
1637
- const fileVersionTypeStrings = getEnumKeys(GitVersionType);
1638
- server.tool(REPO_TOOLS.get_file_content, "Get the content of a file from a Git repository at a specific version (branch, tag, or commit SHA). " +
1639
- "Useful for reading source files from PR branches, specific commits, or tags without having them checked out locally. " +
1640
- "Returns isError: true if the file is not found.", {
1641
- repositoryId: z.string().describe("The ID (GUID) or name of the repository."),
1642
- path: z.string().describe("The full path to the file in the repository, e.g., '/src/main.ts' or 'src/main.ts'."),
1643
- project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name."),
1644
- version: z
855
+ // --- repo_create_branch ----------------------------------------------------
856
+ server.tool(REPO_TOOLS.repo_create_branch, "Create a new branch in the repository.", {
857
+ repositoryId: z
1645
858
  .string()
1646
- .optional()
1647
- .describe("Version string: branch name (e.g. 'main'), tag name, or commit SHA. " + "Defaults to the repository's default branch if not specified."),
1648
- versionType: z
1649
- .enum(fileVersionTypeStrings)
1650
- .optional()
1651
- .default("Commit")
1652
- .describe("How to interpret the 'version' parameter. Defaults to 'Commit'."),
1653
- }, async ({ repositoryId, path, project, version, versionType }) => {
859
+ .describe("The ID or name of the repository where the branch will be created. When using a repository name instead of a GUID, the project parameter must also be provided."),
860
+ branchName: z.string().describe("The name of the new branch to create, e.g., 'feature-branch'."),
861
+ sourceBranchName: z.string().optional().default("main").describe("The name of the source branch to create the new branch from. Defaults to 'main'."),
862
+ sourceCommitId: z.string().optional().describe("The commit ID to create the branch from. If not provided, uses the latest commit of the source branch."),
863
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
864
+ }, async ({ repositoryId, branchName, sourceBranchName, sourceCommitId, project }) => {
1654
865
  try {
1655
866
  const connection = await connectionProvider();
1656
867
  const gitApi = await connection.getGitApi();
1657
- // Build the version descriptor if a version was specified
1658
- const versionDescriptor = version
1659
- ? {
1660
- version: version,
1661
- versionType: GitVersionType[versionType],
868
+ let commitId = sourceCommitId;
869
+ if (!commitId) {
870
+ const sourceRefName = `refs/heads/${sourceBranchName}`;
871
+ try {
872
+ const sourceBranch = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, sourceBranchName);
873
+ const branch = sourceBranch.find((b) => b.name === sourceRefName);
874
+ if (!branch || !branch.objectId) {
875
+ return { content: [{ type: "text", text: `Error: Source branch '${sourceBranchName}' not found in repository ${repositoryId}` }], isError: true };
876
+ }
877
+ commitId = branch.objectId;
878
+ }
879
+ catch (error) {
880
+ return { content: [{ type: "text", text: `Error retrieving source branch '${sourceBranchName}': ${error instanceof Error ? error.message : String(error)}` }], isError: true };
1662
881
  }
1663
- : undefined;
1664
- // getItemText returns a ReadableStream of the file content as text
1665
- const stream = await gitApi.getItemText(repositoryId, path, project, undefined, // scopePath
1666
- undefined, // recursionLevel
1667
- undefined, // includeContentMetadata
1668
- undefined, // latestProcessedChange
1669
- false, // download
1670
- versionDescriptor, true // includeContent
1671
- );
1672
- const content = await streamToString(stream);
1673
- const streamError = extractAdoStreamError(content);
1674
- if (streamError) {
1675
- return {
1676
- content: [{ type: "text", text: `Error getting file content for '${path}': ${streamError}` }],
1677
- isError: true,
1678
- };
1679
882
  }
1680
- return {
1681
- content: [{ type: "text", text: content }],
883
+ const refUpdate = {
884
+ name: `refs/heads/${branchName}`,
885
+ newObjectId: commitId,
886
+ oldObjectId: "0000000000000000000000000000000000000000",
1682
887
  };
888
+ try {
889
+ const result = await gitApi.updateRefs([refUpdate], repositoryId, project);
890
+ if (result && result.length > 0 && result[0].success) {
891
+ return { content: [{ type: "text", text: `Branch '${branchName}' created successfully from '${sourceBranchName}' (${commitId})` }] };
892
+ }
893
+ else {
894
+ const errorMessage = result && result.length > 0 && result[0].customMessage ? result[0].customMessage : "Unknown error occurred during branch creation";
895
+ return { content: [{ type: "text", text: `Error creating branch '${branchName}': ${errorMessage}` }], isError: true };
896
+ }
897
+ }
898
+ catch (error) {
899
+ return { content: [{ type: "text", text: `Error creating branch '${branchName}': ${error instanceof Error ? error.message : String(error)}` }], isError: true };
900
+ }
1683
901
  }
1684
902
  catch (error) {
1685
903
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
1686
- return {
1687
- content: [
1688
- {
1689
- type: "text",
1690
- text: `Error getting file content for '${path}': ${errorMessage}`,
1691
- },
1692
- ],
1693
- isError: true,
1694
- };
904
+ return { content: [{ type: "text", text: `Error creating branch: ${errorMessage}` }], isError: true };
1695
905
  }
1696
906
  });
1697
907
  }