@sonyjv/azure-devops-mcp 2.9.0-onprem.1

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.
@@ -0,0 +1,941 @@
1
+ // Copyright (c) Microsoft Corporation.
2
+ // Licensed under the MIT License.
3
+ import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus, GitPullRequestMergeStrategy, VersionControlRecursionType, } from "azure-devops-node-api/interfaces/GitInterfaces.js";
4
+ import { z } from "zod";
5
+ import { getCurrentUserDetails, getUserIdFromEmail } from "./auth.js";
6
+ import { extractAdoStreamError, getEnumKeys, streamToString, apiVersion } from "../utils.js";
7
+ import { orgName } from "../index.js";
8
+ import { createExternalContentResponse } from "../shared/content-safety.js";
9
+ const REPO_TOOLS = {
10
+ repo_repository: "repo_repository",
11
+ repo_pull_request: "repo_pull_request",
12
+ repo_pull_request_thread: "repo_pull_request_thread",
13
+ repo_branch: "repo_branch",
14
+ repo_file: "repo_file",
15
+ repo_search_commits: "repo_search_commits",
16
+ repo_pull_request_write: "repo_pull_request_write",
17
+ repo_pull_request_thread_write: "repo_pull_request_thread_write",
18
+ repo_create_branch: "repo_create_branch",
19
+ };
20
+ function branchesFilterOutIrrelevantProperties(branches, top) {
21
+ return branches
22
+ ?.flatMap((branch) => (branch.name ? [branch.name] : []))
23
+ ?.filter((branch) => branch.startsWith("refs/heads/"))
24
+ .map((branch) => branch.replace("refs/heads/", ""))
25
+ .sort((a, b) => b.localeCompare(a))
26
+ .slice(0, top);
27
+ }
28
+ function trimPullRequestThread(thread) {
29
+ return {
30
+ id: thread.id,
31
+ publishedDate: thread.publishedDate,
32
+ lastUpdatedDate: thread.lastUpdatedDate,
33
+ status: thread.status,
34
+ comments: trimComments(thread.comments),
35
+ threadContext: thread.threadContext,
36
+ pullRequestThreadContext: thread.pullRequestThreadContext,
37
+ };
38
+ }
39
+ function trimComments(comments) {
40
+ return comments
41
+ ?.filter((comment) => !comment.isDeleted)
42
+ ?.map((comment) => ({
43
+ id: comment.id,
44
+ author: {
45
+ displayName: comment.author?.displayName,
46
+ uniqueName: comment.author?.uniqueName,
47
+ },
48
+ content: comment.content,
49
+ publishedDate: comment.publishedDate,
50
+ lastUpdatedDate: comment.lastUpdatedDate,
51
+ lastContentUpdatedDate: comment.lastContentUpdatedDate,
52
+ }));
53
+ }
54
+ function pullRequestStatusStringToInt(status) {
55
+ switch (status) {
56
+ case "Abandoned":
57
+ return PullRequestStatus.Abandoned.valueOf();
58
+ case "Active":
59
+ return PullRequestStatus.Active.valueOf();
60
+ case "All":
61
+ return PullRequestStatus.All.valueOf();
62
+ case "Completed":
63
+ return PullRequestStatus.Completed.valueOf();
64
+ case "NotSet":
65
+ return PullRequestStatus.NotSet.valueOf();
66
+ default:
67
+ throw new Error(`Unknown pull request status: ${status}`);
68
+ }
69
+ }
70
+ function filterReposByName(repositories, repoNameFilter) {
71
+ const lowerCaseFilter = repoNameFilter.toLowerCase();
72
+ return repositories?.filter((repo) => repo.name?.toLowerCase().includes(lowerCaseFilter));
73
+ }
74
+ function trimPullRequest(pr, includeDescription = false) {
75
+ if (!pr) {
76
+ return null;
77
+ }
78
+ const statusName = typeof pr.status === "number" ? (PullRequestStatus[pr.status] ?? "Unknown") : "Unknown";
79
+ return {
80
+ pullRequestId: pr.pullRequestId,
81
+ codeReviewId: pr.codeReviewId,
82
+ repository: pr.repository?.name,
83
+ status: pr.status,
84
+ statusName,
85
+ createdBy: {
86
+ displayName: pr.createdBy?.displayName,
87
+ uniqueName: pr.createdBy?.uniqueName,
88
+ },
89
+ creationDate: pr.creationDate,
90
+ closedDate: pr.closedDate,
91
+ title: pr.title,
92
+ ...(includeDescription ? { description: pr.description ?? "" } : {}),
93
+ isDraft: pr.isDraft,
94
+ sourceRefName: pr.sourceRefName,
95
+ targetRefName: pr.targetRefName,
96
+ project: pr.repository?.project?.name,
97
+ };
98
+ }
99
+ function buildVersionDescriptor(version, versionType) {
100
+ if (!version)
101
+ return undefined;
102
+ const versionTypeMap = {
103
+ Branch: GitVersionType.Branch,
104
+ Commit: GitVersionType.Commit,
105
+ Tag: GitVersionType.Tag,
106
+ };
107
+ return {
108
+ version,
109
+ versionType: versionTypeMap[versionType || "Branch"] ?? GitVersionType.Branch,
110
+ };
111
+ }
112
+ function configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider) {
113
+ // --- repo_repository -------------------------------------------------------
114
+ server.tool(REPO_TOOLS.repo_repository, "Retrieve repository data for an organization or project. Use the action parameter to specify the operation.", {
115
+ 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)."),
116
+ project: z.string().optional().describe("The name or ID of the Azure DevOps project. Required for get and list."),
117
+ repositoryNameOrId: z.string().optional().describe("Repository name or ID. Required for get."),
118
+ top: z.coerce.number().default(100).describe("The maximum number of repositories to return. Used for list. Defaults to 100."),
119
+ skip: z.coerce.number().default(0).describe("The number of repositories to skip. Used for list. Defaults to 0."),
120
+ repoNameFilter: z.string().optional().describe("Optional filter to search for repositories by name. Used for list."),
121
+ }, async ({ action, project, repositoryNameOrId, top, skip, repoNameFilter }) => {
122
+ try {
123
+ const connection = await connectionProvider();
124
+ const gitApi = await connection.getGitApi();
125
+ if (action === "get") {
126
+ if (!project)
127
+ return { content: [{ type: "text", text: "project is required for get" }], isError: true };
128
+ if (!repositoryNameOrId)
129
+ return { content: [{ type: "text", text: "repositoryNameOrId is required for get" }], isError: true };
130
+ const repositories = await gitApi.getRepositories(project);
131
+ const repository = repositories?.find((repo) => repo.name === repositoryNameOrId || repo.id === repositoryNameOrId);
132
+ if (!repository) {
133
+ return { content: [{ type: "text", text: `Repository ${repositoryNameOrId} not found in project ${project}` }], isError: true };
134
+ }
135
+ return { content: [{ type: "text", text: JSON.stringify(repository, null, 2) }] };
136
+ }
137
+ if (action === "list") {
138
+ if (!project)
139
+ return { content: [{ type: "text", text: "project is required for list" }], isError: true };
140
+ const repositories = await gitApi.getRepositories(project, false, false, false);
141
+ const filteredRepositories = repoNameFilter ? filterReposByName(repositories, repoNameFilter) : repositories;
142
+ const paginatedRepositories = filteredRepositories?.sort((a, b) => a.name?.localeCompare(b.name ?? "") ?? 0).slice(skip, skip + top);
143
+ const trimmedRepositories = paginatedRepositories?.map((repo) => ({
144
+ id: repo.id,
145
+ name: repo.name,
146
+ isDisabled: repo.isDisabled,
147
+ isFork: repo.isFork,
148
+ isInMaintenance: repo.isInMaintenance,
149
+ webUrl: repo.webUrl,
150
+ size: repo.size,
151
+ }));
152
+ return { content: [{ type: "text", text: JSON.stringify(trimmedRepositories, null, 2) }] };
153
+ }
154
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
155
+ }
156
+ catch (error) {
157
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
158
+ return { content: [{ type: "text", text: `Error with repository operation: ${errorMessage}` }], isError: true };
159
+ }
160
+ });
161
+ // --- repo_pull_request -----------------------------------------------------
162
+ server.tool(REPO_TOOLS.repo_pull_request, "Retrieve pull request data. Use the action parameter to specify the operation.", {
163
+ action: z
164
+ .enum(["get", "list", "list_by_commits"])
165
+ .describe("The action to perform. Options: get (get a pull request by ID), list (list pull requests in a repository or project), list_by_commits (find pull requests that contain specific commit IDs)."),
166
+ repositoryId: z.string().optional().describe("The ID or name of the repository. Required for get. Optional for list. When using a name instead of a GUID, project must also be provided."),
167
+ pullRequestId: z.coerce.number().min(1).optional().describe("The ID of the pull request. Required for get."),
168
+ project: z.string().optional().describe("Project ID or project name. Required for list_by_commits. Optional for get and list."),
169
+ includeWorkItemRefs: z.boolean().optional().default(false).describe("Whether to include work item references. Used for get."),
170
+ includeLabels: z.boolean().optional().default(false).describe("Whether to include labels. Used for get."),
171
+ includeChangedFiles: z.boolean().optional().default(false).describe("Whether to include the list of changed files. Used for get."),
172
+ top: z.coerce.number().default(100).describe("The maximum number of pull requests to return. Used for list. Defaults to 100."),
173
+ skip: z.coerce.number().default(0).describe("The number of pull requests to skip. Used for list. Defaults to 0."),
174
+ created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user. Used for list."),
175
+ created_by_user: z.string().optional().describe("Filter pull requests created by a specific user email. Used for list."),
176
+ i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer. Used for list."),
177
+ user_is_reviewer: z.string().optional().describe("Filter pull requests where a specific user is a reviewer (email). Used for list."),
178
+ status: z
179
+ .enum(getEnumKeys(PullRequestStatus))
180
+ .default("Active")
181
+ .describe("Filter pull requests by status. Used for list. Defaults to 'Active'."),
182
+ sourceRefName: z.string().optional().describe("Filter by source branch. Used for list."),
183
+ targetRefName: z.string().optional().describe("Filter by target branch. Used for list and create."),
184
+ repository: z.string().optional().describe("Repository name or ID. Required for list_by_commits."),
185
+ commits: z.array(z.string()).optional().describe("Array of commit IDs to query. Required for list_by_commits."),
186
+ queryType: z
187
+ .enum(Object.values(GitPullRequestQueryType).filter((v) => typeof v === "string"))
188
+ .optional()
189
+ .default(GitPullRequestQueryType[GitPullRequestQueryType.LastMergeCommit])
190
+ .describe("Type of commit query. Used for list_by_commits."),
191
+ }, async ({ action, repositoryId, pullRequestId, project, includeWorkItemRefs, includeLabels, includeChangedFiles, top, skip, created_by_me, created_by_user, i_am_reviewer, user_is_reviewer, status, sourceRefName, targetRefName, repository, commits, queryType, }) => {
192
+ try {
193
+ const connection = await connectionProvider();
194
+ const gitApi = await connection.getGitApi();
195
+ if (action === "get") {
196
+ if (!repositoryId)
197
+ return { content: [{ type: "text", text: "repositoryId is required for get" }], isError: true };
198
+ if (!pullRequestId)
199
+ return { content: [{ type: "text", text: "pullRequestId is required for get" }], isError: true };
200
+ const pullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project, undefined, undefined, undefined, undefined, includeWorkItemRefs);
201
+ let enhancedResponse = { ...pullRequest };
202
+ if (includeLabels) {
203
+ try {
204
+ const projectId = pullRequest.repository?.project?.id;
205
+ const projectName = pullRequest.repository?.project?.name;
206
+ const labels = await gitApi.getPullRequestLabels(repositoryId, pullRequestId, projectName, projectId);
207
+ const labelNames = labels.map((label) => label.name).filter((name) => name !== undefined);
208
+ enhancedResponse = { ...enhancedResponse, labelSummary: { labels: labelNames, labelCount: labelNames.length } };
209
+ }
210
+ catch (error) {
211
+ console.warn(`Error fetching PR labels: ${error instanceof Error ? error.message : "Unknown error"}`);
212
+ enhancedResponse = { ...enhancedResponse, labelSummary: {} };
213
+ }
214
+ }
215
+ if (includeChangedFiles) {
216
+ try {
217
+ const iterations = await gitApi.getPullRequestIterations(repositoryId, pullRequestId, project);
218
+ if (iterations?.length) {
219
+ const latestIteration = iterations[iterations.length - 1];
220
+ if (latestIteration.id != null) {
221
+ const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, latestIteration.id, project);
222
+ enhancedResponse = {
223
+ ...enhancedResponse,
224
+ changedFilesSummary: {
225
+ changeEntries: changes?.changeEntries ?? [],
226
+ fileCount: changes?.changeEntries?.length ?? 0,
227
+ firstComparingIteration: Math.max(0, latestIteration.id - 1),
228
+ secondComparingIteration: latestIteration.id,
229
+ nextSkip: changes?.nextSkip,
230
+ nextTop: changes?.nextTop,
231
+ },
232
+ };
233
+ }
234
+ else {
235
+ enhancedResponse = { ...enhancedResponse, changedFilesSummary: { changeEntries: [], fileCount: 0 } };
236
+ }
237
+ }
238
+ else {
239
+ enhancedResponse = { ...enhancedResponse, changedFilesSummary: { changeEntries: [], fileCount: 0 } };
240
+ }
241
+ }
242
+ catch (error) {
243
+ console.warn(`Error fetching PR changed files: ${error instanceof Error ? error.message : "Unknown error"}`);
244
+ enhancedResponse = { ...enhancedResponse, changedFilesSummary: {} };
245
+ }
246
+ }
247
+ return createExternalContentResponse(enhancedResponse, "pull request");
248
+ }
249
+ if (action === "list") {
250
+ if (!repositoryId && !project) {
251
+ return { content: [{ type: "text", text: "Either repositoryId or project must be provided." }], isError: true };
252
+ }
253
+ const searchCriteria = { status: pullRequestStatusStringToInt(status) };
254
+ if (repositoryId)
255
+ searchCriteria.repositoryId = repositoryId;
256
+ if (sourceRefName)
257
+ searchCriteria.sourceRefName = sourceRefName;
258
+ if (targetRefName)
259
+ searchCriteria.targetRefName = targetRefName;
260
+ if (created_by_user) {
261
+ try {
262
+ const userId = await getUserIdFromEmail(created_by_user, tokenProvider, connectionProvider, userAgentProvider);
263
+ searchCriteria.creatorId = userId;
264
+ }
265
+ catch (error) {
266
+ return { content: [{ type: "text", text: `Error finding user with email ${created_by_user}: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
267
+ }
268
+ }
269
+ else if (created_by_me) {
270
+ const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
271
+ searchCriteria.creatorId = data.authenticatedUser.id;
272
+ }
273
+ if (user_is_reviewer) {
274
+ try {
275
+ const reviewerUserId = await getUserIdFromEmail(user_is_reviewer, tokenProvider, connectionProvider, userAgentProvider);
276
+ searchCriteria.reviewerId = reviewerUserId;
277
+ }
278
+ catch (error) {
279
+ return { content: [{ type: "text", text: `Error finding reviewer with email ${user_is_reviewer}: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
280
+ }
281
+ }
282
+ else if (i_am_reviewer) {
283
+ const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
284
+ searchCriteria.reviewerId = data.authenticatedUser.id;
285
+ }
286
+ let pullRequests;
287
+ /* istanbul ignore else */
288
+ if (repositoryId) {
289
+ pullRequests = await gitApi.getPullRequests(repositoryId, searchCriteria, project, undefined, skip, top);
290
+ }
291
+ else if (project) {
292
+ pullRequests = await gitApi.getPullRequestsByProject(project, searchCriteria, undefined, skip, top);
293
+ }
294
+ const filteredPullRequests = pullRequests?.map((pr) => trimPullRequest(pr));
295
+ return { content: [{ type: "text", text: JSON.stringify(filteredPullRequests, null, 2) }] };
296
+ }
297
+ if (action === "list_by_commits") {
298
+ if (!project)
299
+ return { content: [{ type: "text", text: "project is required for list_by_commits" }], isError: true };
300
+ if (!repository)
301
+ return { content: [{ type: "text", text: "repository is required for list_by_commits" }], isError: true };
302
+ if (!commits || commits.length === 0)
303
+ return { content: [{ type: "text", text: "commits is required for list_by_commits" }], isError: true };
304
+ const query = {
305
+ queries: [
306
+ {
307
+ items: commits,
308
+ type: GitPullRequestQueryType[queryType],
309
+ },
310
+ ],
311
+ };
312
+ const queryResult = await gitApi.getPullRequestQuery(query, repository, project);
313
+ return { content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }] };
314
+ }
315
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
316
+ }
317
+ catch (error) {
318
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
319
+ return { content: [{ type: "text", text: `Error with pull request operation: ${errorMessage}` }], isError: true };
320
+ }
321
+ });
322
+ // --- repo_pull_request_thread ----------------------------------------------
323
+ server.tool(REPO_TOOLS.repo_pull_request_thread, "Retrieve pull request thread and comment data. Use the action parameter to specify the operation.", {
324
+ 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)."),
325
+ repositoryId: z.string().describe("The ID or name of the repository. When using a name instead of a GUID, project must also be provided."),
326
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request."),
327
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name instead of a GUID."),
328
+ threadId: z.coerce.number().min(1).optional().describe("The ID of the thread. Required for list_comments."),
329
+ iteration: z.coerce.number().min(1).optional().describe("The iteration ID. Used for list."),
330
+ baseIteration: z.coerce.number().min(1).optional().describe("The base iteration ID. Used for list."),
331
+ top: z.coerce.number().default(100).describe("The maximum number of results to return. Defaults to 100."),
332
+ skip: z.coerce.number().default(0).describe("The number of results to skip. Defaults to 0."),
333
+ fullResponse: z.boolean().optional().default(false).describe("Return full JSON response instead of trimmed data."),
334
+ status: z
335
+ .enum(getEnumKeys(CommentThreadStatus))
336
+ .optional()
337
+ .describe("Filter threads by status. Used for list."),
338
+ authorEmail: z.string().optional().describe("Filter threads by the email of the thread author. Used for list."),
339
+ authorDisplayName: z.string().optional().describe("Filter threads by the display name of the thread author. Used for list."),
340
+ }, async ({ action, repositoryId, pullRequestId, project, threadId, iteration, baseIteration, top, skip, fullResponse, status, authorEmail, authorDisplayName }) => {
341
+ try {
342
+ const connection = await connectionProvider();
343
+ const gitApi = await connection.getGitApi();
344
+ if (action === "list") {
345
+ const threads = (await gitApi.getThreads(repositoryId, pullRequestId, project, iteration, baseIteration)) ?? [];
346
+ let filteredThreads = threads;
347
+ if (status !== undefined) {
348
+ const statusValue = CommentThreadStatus[status];
349
+ filteredThreads = filteredThreads.filter((thread) => thread.status === statusValue);
350
+ }
351
+ if (authorEmail !== undefined) {
352
+ filteredThreads = filteredThreads.filter((thread) => {
353
+ const firstComment = thread.comments?.[0];
354
+ return firstComment?.author?.uniqueName?.toLowerCase() === authorEmail.toLowerCase();
355
+ });
356
+ }
357
+ if (authorDisplayName !== undefined) {
358
+ const lowerAuthorName = authorDisplayName.toLowerCase();
359
+ filteredThreads = filteredThreads.filter((thread) => {
360
+ const firstComment = thread.comments?.[0];
361
+ return firstComment?.author?.displayName?.toLowerCase().includes(lowerAuthorName);
362
+ });
363
+ }
364
+ const paginatedThreads = filteredThreads.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top);
365
+ if (fullResponse) {
366
+ return { content: [{ type: "text", text: JSON.stringify(paginatedThreads, null, 2) }] };
367
+ }
368
+ const trimmedThreads = paginatedThreads.map((thread) => trimPullRequestThread(thread));
369
+ return { content: [{ type: "text", text: JSON.stringify(trimmedThreads, null, 2) }] };
370
+ }
371
+ if (action === "list_comments") {
372
+ if (!threadId)
373
+ return { content: [{ type: "text", text: "threadId is required for list_comments" }], isError: true };
374
+ const comments = await gitApi.getComments(repositoryId, pullRequestId, threadId, project);
375
+ const paginatedComments = comments?.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top);
376
+ if (fullResponse) {
377
+ return { content: [{ type: "text", text: JSON.stringify(paginatedComments, null, 2) }] };
378
+ }
379
+ const trimmedComments = trimComments(paginatedComments);
380
+ return { content: [{ type: "text", text: JSON.stringify(trimmedComments, null, 2) }] };
381
+ }
382
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
383
+ }
384
+ catch (error) {
385
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
386
+ return { content: [{ type: "text", text: `Error with pull request thread operation: ${errorMessage}` }], isError: true };
387
+ }
388
+ });
389
+ // --- repo_branch -----------------------------------------------------------
390
+ server.tool(REPO_TOOLS.repo_branch, "Retrieve branch data for a repository. Use the action parameter to specify the operation.", {
391
+ action: z
392
+ .enum(["get", "list", "list_mine"])
393
+ .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)."),
394
+ repositoryId: z.string().describe("The ID or name of the repository. When using a name instead of a GUID, project must also be provided."),
395
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name instead of a GUID."),
396
+ branchName: z.string().optional().describe("The name of the branch. Required for get."),
397
+ top: z.coerce.number().default(100).describe("The maximum number of branches to return. Used for list and list_mine. Defaults to 100."),
398
+ filterContains: z.string().optional().describe("Filter branches containing this string. Used for list and list_mine."),
399
+ }, async ({ action, repositoryId, project, branchName, top, filterContains }) => {
400
+ try {
401
+ const connection = await connectionProvider();
402
+ const gitApi = await connection.getGitApi();
403
+ if (action === "get") {
404
+ if (!branchName)
405
+ return { content: [{ type: "text", text: "branchName is required for get" }], isError: true };
406
+ const branches = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, branchName);
407
+ const branch = branches.find((branch) => branch.name === `refs/heads/${branchName}` || branch.name === branchName);
408
+ if (!branch) {
409
+ return { content: [{ type: "text", text: `Branch ${branchName} not found in repository ${repositoryId}` }], isError: true };
410
+ }
411
+ return { content: [{ type: "text", text: JSON.stringify(branch, null, 2) }] };
412
+ }
413
+ if (action === "list") {
414
+ const branches = await gitApi.getRefs(repositoryId, project, "heads/", undefined, undefined, undefined, undefined, undefined, filterContains);
415
+ const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top);
416
+ return { content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }] };
417
+ }
418
+ if (action === "list_mine") {
419
+ const branches = await gitApi.getRefs(repositoryId, project, undefined, undefined, undefined, true, undefined, undefined, filterContains);
420
+ const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top);
421
+ return { content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }] };
422
+ }
423
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
424
+ }
425
+ catch (error) {
426
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
427
+ return { content: [{ type: "text", text: `Error with branch operation: ${errorMessage}` }], isError: true };
428
+ }
429
+ });
430
+ // --- repo_file -------------------------------------------------------------
431
+ const fileVersionTypeStrings = getEnumKeys(GitVersionType);
432
+ server.tool(REPO_TOOLS.repo_file, "Retrieve file data from a repository. Use the action parameter to specify the operation.", {
433
+ action: z
434
+ .enum(["get_content", "list_directory"])
435
+ .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)."),
436
+ repositoryId: z.string().describe("The ID or name of the repository."),
437
+ path: z.string().optional().default("/").describe("The file or directory path. Required for get_content. Defaults to '/' for list_directory."),
438
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name."),
439
+ version: z.string().optional().describe("Version string: branch name, tag name, or commit SHA."),
440
+ versionType: z
441
+ .enum(fileVersionTypeStrings)
442
+ .optional()
443
+ .default("Commit")
444
+ .describe("How to interpret the version parameter. Used for get_content. Defaults to 'Commit'."),
445
+ recursive: z.boolean().optional().default(false).describe("Whether to list items recursively. Used for list_directory. Defaults to false."),
446
+ recursionDepth: z.coerce.number().min(1).optional().default(1).describe("Maximum depth for recursive listing. Used for list_directory when recursive is true. Defaults to 1."),
447
+ }, async ({ action, repositoryId, path, project, version, versionType, recursive, recursionDepth }) => {
448
+ try {
449
+ const connection = await connectionProvider();
450
+ const gitApi = await connection.getGitApi();
451
+ if (action === "get_content") {
452
+ if (!path)
453
+ return { content: [{ type: "text", text: "path is required for get_content" }], isError: true };
454
+ const versionDescriptor = version ? { version, versionType: GitVersionType[versionType] } : undefined;
455
+ const stream = await gitApi.getItemText(repositoryId, path, project, undefined, undefined, undefined, undefined, false, versionDescriptor, true);
456
+ const content = await streamToString(stream);
457
+ const streamError = extractAdoStreamError(content);
458
+ if (streamError) {
459
+ return { content: [{ type: "text", text: `Error getting file content for '${path}': ${streamError}` }], isError: true };
460
+ }
461
+ return createExternalContentResponse(content, "repository file");
462
+ }
463
+ if (action === "list_directory") {
464
+ const versionDescriptor = buildVersionDescriptor(version, versionType === "Commit" ? "Branch" : versionType);
465
+ const clampedDepth = Math.min(Math.max(recursionDepth || 1, 1), 10);
466
+ const recursionType = recursive ? VersionControlRecursionType.Full : VersionControlRecursionType.OneLevel;
467
+ const items = await gitApi.getItems(repositoryId, project, path, recursionType, true, false, false, false, versionDescriptor);
468
+ if (!items || items.length === 0) {
469
+ return { content: [{ type: "text", text: `No items found at path: ${path}. The path may not exist in the repository.` }], isError: true };
470
+ }
471
+ let filteredItems = items;
472
+ if (recursive && clampedDepth < 10) {
473
+ const basePath = path === "/" ? "" : path;
474
+ const baseDepth = basePath.split("/").filter((p) => p).length;
475
+ filteredItems = items.filter((item) => {
476
+ if (!item.path)
477
+ return false;
478
+ const itemDepth = item.path.split("/").filter((p) => p).length;
479
+ return itemDepth <= baseDepth + clampedDepth;
480
+ });
481
+ }
482
+ const formattedItems = filteredItems.map((item) => ({
483
+ path: item.path,
484
+ isFolder: item.isFolder,
485
+ gitObjectType: item.gitObjectType,
486
+ commitId: item.commitId,
487
+ contentMetadata: item.contentMetadata ? { contentType: item.contentMetadata.contentType, fileName: item.contentMetadata.fileName } : undefined,
488
+ }));
489
+ return {
490
+ content: [
491
+ {
492
+ type: "text",
493
+ text: JSON.stringify({ count: formattedItems.length, path, recursive, recursionDepth: recursive ? clampedDepth : undefined, items: formattedItems }, null, 2),
494
+ },
495
+ ],
496
+ };
497
+ }
498
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
499
+ }
500
+ catch (error) {
501
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
502
+ return { content: [{ type: "text", text: `Error with file operation: ${errorMessage}` }], isError: true };
503
+ }
504
+ });
505
+ // --- repo_search_commits ---------------------------------------------------
506
+ server.tool(REPO_TOOLS.repo_search_commits, "Search commits with filtering by text, author, date range, and more.", {
507
+ searchText: z.string().describe("Keywords to search for in commit messages"),
508
+ project: z
509
+ .union([z.string().transform(/* istanbul ignore next */ (value) => [value]), z.array(z.string())])
510
+ .optional()
511
+ .describe("The names of the projects to search within. If omitted, searches across all projects in the organization."),
512
+ repository: z.array(z.string()).optional().describe("The names of the repositories to search within."),
513
+ branch: z.array(z.string()).optional().describe("The names of the repository branches to search within."),
514
+ author: z.array(z.string()).optional().describe("The names of the commit authors to search for. Only full display names are supported."),
515
+ commitStartDate: z.string().optional().describe("Filter commits from this date (format: 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS')"),
516
+ commitEndDate: z.string().optional().describe("Filter commits up to this date (format: 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS')"),
517
+ orderBy: z.enum(["ASC", "DESC"]).optional().describe("Sort commits by date: 'ASC' for oldest-first, 'DESC' for newest-first."),
518
+ includeFacets: z.boolean().default(false).describe("Include facets in the search results"),
519
+ skip: z.coerce.number().default(0).describe("Number of results to skip"),
520
+ top: z.coerce.number().default(10).describe("Maximum number of results to return"),
521
+ }, async ({ searchText, project, repository, branch, author, commitStartDate, commitEndDate, orderBy, includeFacets, skip, top }) => {
522
+ const accessToken = await tokenProvider();
523
+ const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/commitSearchResults?api-version=${apiVersion}`;
524
+ const requestBody = { searchText, includeFacets, $skip: skip, $top: top };
525
+ const filters = {};
526
+ if (project && project.length > 0)
527
+ filters.projectName = project;
528
+ if (repository && repository.length > 0)
529
+ filters.repositoryName = repository;
530
+ if (branch && branch.length > 0)
531
+ filters.branchName = branch;
532
+ if (author && author.length > 0)
533
+ filters.authorName = author;
534
+ if (commitStartDate)
535
+ filters.commitStartDate = [commitStartDate];
536
+ if (commitEndDate)
537
+ filters.commitEndDate = [commitEndDate];
538
+ requestBody.filters = filters;
539
+ if (orderBy) {
540
+ requestBody.$orderBy = [{ field: "commitDate", sortOrder: orderBy }];
541
+ }
542
+ const response = await fetch(url, {
543
+ method: "POST",
544
+ headers: {
545
+ "Content-Type": "application/json",
546
+ "Authorization": `Bearer ${accessToken}`,
547
+ "User-Agent": userAgentProvider(),
548
+ },
549
+ body: JSON.stringify(requestBody),
550
+ });
551
+ if (!response.ok) {
552
+ throw new Error(`Azure DevOps Commit Search API error: ${response.status} ${response.statusText}`);
553
+ }
554
+ const result = await response.text();
555
+ return { content: [{ type: "text", text: result }] };
556
+ });
557
+ // --- repo_pull_request_write -----------------------------------------------
558
+ server.tool(REPO_TOOLS.repo_pull_request_write, "Write operations for pull requests. Use the action parameter to specify the operation.", {
559
+ action: z
560
+ .enum(["create", "update", "update_reviewers", "vote"])
561
+ .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)."),
562
+ 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."),
563
+ pullRequestId: z.coerce.number().min(1).optional().describe("The ID of the pull request. Required for update, update_reviewers, and vote."),
564
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name instead of a GUID."),
565
+ sourceRefName: z.string().optional().describe("The source branch name (e.g., 'refs/heads/feature-branch'). Required for create."),
566
+ targetRefName: z.string().optional().describe("The target branch name (e.g., 'refs/heads/main'). Required for create. Optional for update."),
567
+ title: z.string().optional().describe("The title of the pull request. Required for create. Optional for update."),
568
+ description: z.string().max(4000).optional().describe("The description of the pull request. Max 4000 characters. Used for create and update."),
569
+ isDraft: z.boolean().optional().default(false).describe("Whether the pull request is a draft. Used for create and update."),
570
+ workItems: z.string().optional().describe("Work item IDs to associate, space-separated. Used for create."),
571
+ forkSourceRepositoryId: z.string().optional().describe("The ID of the fork repository. Used for create."),
572
+ labels: z.array(z.string()).optional().describe("Array of label names. Used for create and update."),
573
+ status: z.enum(["Active", "Abandoned"]).optional().describe("The new status. Used for update."),
574
+ autoComplete: z.boolean().optional().describe("Set autocomplete when all requirements are met. Used for update."),
575
+ mergeStrategy: z
576
+ .enum(getEnumKeys(GitPullRequestMergeStrategy))
577
+ .optional()
578
+ .describe("The merge strategy for autocomplete. Used for update."),
579
+ mergeCommitMessage: z.string().optional().describe("Commit message for autocomplete. Used for update."),
580
+ deleteSourceBranch: z.boolean().optional().default(false).describe("Delete source branch on autocomplete. Used for update."),
581
+ transitionWorkItems: z.boolean().optional().default(true).describe("Transition work items on autocomplete. Used for update."),
582
+ bypassReason: z.string().optional().describe("Reason for bypassing branch policies. Used for update."),
583
+ reviewerIds: z.array(z.string()).optional().describe("List of reviewer IDs. Required for update_reviewers."),
584
+ reviewerAction: z.enum(["add", "remove"]).optional().describe("Whether to add or remove reviewers. Required for update_reviewers."),
585
+ vote: z.enum(["Approved", "ApprovedWithSuggestions", "NoVote", "WaitingForAuthor", "Rejected"]).optional().describe("The vote to cast. Required for vote."),
586
+ }, async ({ action, repositoryId, pullRequestId, project, sourceRefName, targetRefName, title, description, isDraft, workItems, forkSourceRepositoryId, labels, status, autoComplete, mergeStrategy, mergeCommitMessage, deleteSourceBranch, transitionWorkItems, bypassReason, reviewerIds, reviewerAction, vote, }) => {
587
+ try {
588
+ const connection = await connectionProvider();
589
+ const gitApi = await connection.getGitApi();
590
+ if (action === "create") {
591
+ if (!repositoryId)
592
+ return { content: [{ type: "text", text: "repositoryId is required for create" }], isError: true };
593
+ if (!sourceRefName)
594
+ return { content: [{ type: "text", text: "sourceRefName is required for create" }], isError: true };
595
+ if (!targetRefName)
596
+ return { content: [{ type: "text", text: "targetRefName is required for create" }], isError: true };
597
+ if (!title)
598
+ return { content: [{ type: "text", text: "title is required for create" }], isError: true };
599
+ const workItemRefs = workItems ? workItems.split(" ").map((id) => ({ id: id.trim() })) : [];
600
+ const noDataErrorMessage = `Pull request creation returned no data and no matching PR was found. This often means repositoryId="${repositoryId}" was not resolvable. ` +
601
+ "Try the repository GUID from repo_repository (list action) instead of the Project/RepoName slash format.";
602
+ const forkSource = forkSourceRepositoryId ? { repository: { id: forkSourceRepositoryId } } : undefined;
603
+ const labelDefinitions = labels ? labels.map((label) => ({ name: label })) : undefined;
604
+ let pullRequest = await gitApi.createPullRequest({ sourceRefName, targetRefName, title, description, isDraft, workItemRefs, forkSource, labels: labelDefinitions, supportsIterations: true }, repositoryId, project);
605
+ if (!pullRequest) {
606
+ const prs = await gitApi.getPullRequests(repositoryId, { sourceRefName, targetRefName, status: PullRequestStatus.Active }, project, undefined, 0, 1);
607
+ if (prs && prs.length > 0) {
608
+ pullRequest = prs[0];
609
+ }
610
+ else {
611
+ return { content: [{ type: "text", text: noDataErrorMessage }], isError: true };
612
+ }
613
+ }
614
+ const trimmedPullRequest = trimPullRequest(pullRequest, true);
615
+ return { content: [{ type: "text", text: JSON.stringify(trimmedPullRequest, null, 2) }] };
616
+ }
617
+ if (action === "update") {
618
+ if (!repositoryId)
619
+ return { content: [{ type: "text", text: "repositoryId is required for update" }], isError: true };
620
+ if (!pullRequestId)
621
+ return { content: [{ type: "text", text: "pullRequestId is required for update" }], isError: true };
622
+ const updateRequest = {};
623
+ if (title !== undefined)
624
+ updateRequest.title = title;
625
+ if (description !== undefined)
626
+ updateRequest.description = description;
627
+ if (isDraft !== undefined)
628
+ updateRequest.isDraft = isDraft;
629
+ if (targetRefName !== undefined)
630
+ updateRequest.targetRefName = targetRefName;
631
+ if (status !== undefined) {
632
+ updateRequest.status = status === "Active" ? PullRequestStatus.Active.valueOf() : PullRequestStatus.Abandoned.valueOf();
633
+ }
634
+ if (autoComplete !== undefined) {
635
+ if (autoComplete) {
636
+ const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
637
+ updateRequest.autoCompleteSetBy = { id: data.authenticatedUser.id };
638
+ const completionOptions = {
639
+ deleteSourceBranch: deleteSourceBranch || false,
640
+ transitionWorkItems: transitionWorkItems !== false,
641
+ bypassPolicy: !!bypassReason,
642
+ };
643
+ if (mergeStrategy)
644
+ completionOptions.mergeStrategy = GitPullRequestMergeStrategy[mergeStrategy];
645
+ if (mergeCommitMessage)
646
+ completionOptions.mergeCommitMessage = mergeCommitMessage;
647
+ if (bypassReason)
648
+ completionOptions.bypassReason = bypassReason;
649
+ updateRequest.completionOptions = completionOptions;
650
+ }
651
+ else {
652
+ updateRequest.autoCompleteSetBy = null;
653
+ updateRequest.completionOptions = null;
654
+ }
655
+ }
656
+ if (Object.keys(updateRequest).length === 0 && !labels) {
657
+ return {
658
+ content: [{ type: "text", text: "Error: At least one field (title, description, isDraft, targetRefName, status, autoComplete options, or labels) must be provided for update." }],
659
+ isError: true,
660
+ };
661
+ }
662
+ if (labels) {
663
+ const currentLabels = await gitApi.getPullRequestLabels(repositoryId, pullRequestId, project);
664
+ for (const currentLabel of currentLabels) {
665
+ if (currentLabel.id)
666
+ await gitApi.deletePullRequestLabels(repositoryId, pullRequestId, currentLabel.id, project);
667
+ }
668
+ for (const label of labels) {
669
+ await gitApi.createPullRequestLabel({ name: label }, repositoryId, pullRequestId, project);
670
+ }
671
+ }
672
+ let updatedPullRequest;
673
+ if (Object.keys(updateRequest).length > 0) {
674
+ updatedPullRequest = await gitApi.updatePullRequest(updateRequest, repositoryId, pullRequestId, project);
675
+ }
676
+ else {
677
+ updatedPullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project);
678
+ }
679
+ const trimmedUpdatedPullRequest = trimPullRequest(updatedPullRequest, true);
680
+ if (!trimmedUpdatedPullRequest) {
681
+ return { content: [{ type: "text", text: "Pull request updated but API returned no data." }] };
682
+ }
683
+ return { content: [{ type: "text", text: JSON.stringify(trimmedUpdatedPullRequest, null, 2) }] };
684
+ }
685
+ if (action === "update_reviewers") {
686
+ if (!repositoryId)
687
+ return { content: [{ type: "text", text: "repositoryId is required for update_reviewers" }], isError: true };
688
+ if (!pullRequestId)
689
+ return { content: [{ type: "text", text: "pullRequestId is required for update_reviewers" }], isError: true };
690
+ if (!reviewerIds || reviewerIds.length === 0)
691
+ return { content: [{ type: "text", text: "reviewerIds is required for update_reviewers" }], isError: true };
692
+ if (!reviewerAction)
693
+ return { content: [{ type: "text", text: "reviewerAction is required for update_reviewers" }], isError: true };
694
+ if (reviewerAction === "add") {
695
+ const updatedReviewers = await gitApi.createPullRequestReviewers(reviewerIds.map((id) => ({ id })), repositoryId, pullRequestId, project);
696
+ const trimmedResponse = updatedReviewers.map((item) => ({
697
+ displayName: item.displayName,
698
+ id: item.id,
699
+ uniqueName: item.uniqueName,
700
+ vote: item.vote,
701
+ hasDeclined: item.hasDeclined,
702
+ isFlagged: item.isFlagged,
703
+ }));
704
+ return { content: [{ type: "text", text: JSON.stringify(trimmedResponse, null, 2) }] };
705
+ }
706
+ else {
707
+ for (const reviewerId of reviewerIds) {
708
+ await gitApi.deletePullRequestReviewer(repositoryId, pullRequestId, reviewerId, project);
709
+ }
710
+ return { content: [{ type: "text", text: `Reviewers with IDs ${reviewerIds.join(", ")} removed from pull request ${pullRequestId}.` }] };
711
+ }
712
+ }
713
+ if (action === "vote") {
714
+ if (!repositoryId)
715
+ return { content: [{ type: "text", text: "repositoryId is required for vote" }], isError: true };
716
+ if (!pullRequestId)
717
+ return { content: [{ type: "text", text: "pullRequestId is required for vote" }], isError: true };
718
+ if (!vote)
719
+ return { content: [{ type: "text", text: "vote is required for vote action" }], isError: true };
720
+ const userDetails = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
721
+ const userId = userDetails.authenticatedUser.id;
722
+ if (!userId)
723
+ throw new Error("Could not determine authenticated user ID.");
724
+ const voteMap = {
725
+ Approved: 10,
726
+ ApprovedWithSuggestions: 5,
727
+ NoVote: 0,
728
+ WaitingForAuthor: -5,
729
+ Rejected: -10,
730
+ };
731
+ const existingReviewer = await gitApi.getPullRequestReviewer(repositoryId, pullRequestId, userId, project).catch((error) => {
732
+ if (!(error instanceof Error) || !/not found|reviewer does not exist/i.test(error.message))
733
+ throw error;
734
+ return undefined;
735
+ });
736
+ const reviewerPayload = {
737
+ vote: voteMap[vote],
738
+ id: userId,
739
+ ...(existingReviewer?.isRequired !== undefined ? { isRequired: existingReviewer.isRequired } : {}),
740
+ };
741
+ await gitApi.createPullRequestReviewer(reviewerPayload, repositoryId, pullRequestId, userId, project);
742
+ return { content: [{ type: "text", text: `Successfully cast vote '${vote}' on PR #${pullRequestId}.` }] };
743
+ }
744
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
745
+ }
746
+ catch (error) {
747
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
748
+ return { content: [{ type: "text", text: `Error with pull request write operation: ${errorMessage}` }], isError: true };
749
+ }
750
+ });
751
+ // --- repo_pull_request_thread_write ----------------------------------------
752
+ server.tool(REPO_TOOLS.repo_pull_request_thread_write, "Write operations for pull request comment threads. Use the action parameter to specify the operation.", {
753
+ action: z
754
+ .enum(["create", "reply", "update", "update_status"])
755
+ .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 (update an existing comment), update_status (update the status of a comment thread)."),
756
+ repositoryId: z.string().describe("The ID or name of the repository. When using a name instead of a GUID, project must also be provided."),
757
+ pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request."),
758
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name instead of a GUID."),
759
+ threadId: z.coerce.number().min(1).optional().describe("The ID of the thread. Required for reply, update, and update_status."),
760
+ commentId: z.coerce.number().min(1).optional().describe("The ID of the comment to update. Required for update."),
761
+ content: z.string().optional().describe("The content of the comment. Required for create, reply, and update."),
762
+ status: z
763
+ .enum(getEnumKeys(CommentThreadStatus))
764
+ .optional()
765
+ .default(CommentThreadStatus[CommentThreadStatus.Active])
766
+ .describe("The thread status. Used for create (defaults to 'Active') and required for update_status."),
767
+ filePath: z.string().optional().describe("The file path for the comment thread. Used for create."),
768
+ fullResponse: z.boolean().optional().default(false).describe("Return full JSON response. Used for reply and update."),
769
+ rightFileStartLine: z.coerce.number().min(1).optional().describe("Start line in the right file. Used for create."),
770
+ rightFileStartOffset: z.number().optional().describe("Start character offset in the right file. Used for create."),
771
+ rightFileEndLine: z.number().optional().describe("End line in the right file. Used for create."),
772
+ rightFileEndOffset: z.number().optional().describe("End character offset in the right file. Used for create."),
773
+ changeTrackingId: z.coerce.number().int().min(1).optional().describe("The file change tracking ID from the pull request iteration changes. Used for create."),
774
+ firstComparingIteration: z.coerce.number().int().min(0).optional().describe("The iteration on the left side of the diff. Used for create."),
775
+ secondComparingIteration: z.coerce.number().int().min(1).optional().describe("The iteration on the right side of the diff. Used for create."),
776
+ }, async ({ action, repositoryId, pullRequestId, project, threadId, commentId, content, status, filePath, fullResponse, rightFileStartLine, rightFileStartOffset, rightFileEndLine, rightFileEndOffset, changeTrackingId, firstComparingIteration, secondComparingIteration, }) => {
777
+ try {
778
+ const connection = await connectionProvider();
779
+ const gitApi = await connection.getGitApi();
780
+ if (action === "create") {
781
+ if (!content)
782
+ return { content: [{ type: "text", text: "content is required for create" }], isError: true };
783
+ const normalizedFilePath = filePath && !filePath.startsWith("/") ? `/${filePath}` : filePath;
784
+ const threadContext = { filePath: normalizedFilePath };
785
+ if (rightFileStartLine !== undefined) {
786
+ if (rightFileStartLine < 1)
787
+ return { content: [{ type: "text", text: "rightFileStartLine must be greater than or equal to 1." }], isError: true };
788
+ threadContext.rightFileStart = { line: rightFileStartLine };
789
+ if (rightFileStartOffset !== undefined) {
790
+ if (rightFileStartOffset < 1)
791
+ return { content: [{ type: "text", text: "rightFileStartOffset must be greater than or equal to 1." }], isError: true };
792
+ threadContext.rightFileStart.offset = rightFileStartOffset;
793
+ }
794
+ }
795
+ if (rightFileEndLine !== undefined) {
796
+ if (rightFileStartLine === undefined)
797
+ return { content: [{ type: "text", text: "rightFileEndLine must only be specified if rightFileStartLine is also specified." }], isError: true };
798
+ if (rightFileEndLine < 1)
799
+ return { content: [{ type: "text", text: "rightFileEndLine must be greater than or equal to 1." }], isError: true };
800
+ if (rightFileEndOffset === undefined)
801
+ return { content: [{ type: "text", text: "rightFileEndOffset must be specified if rightFileEndLine is specified." }], isError: true };
802
+ threadContext.rightFileEnd = { line: rightFileEndLine };
803
+ /* istanbul ignore else */
804
+ if (rightFileEndOffset !== undefined) {
805
+ if (rightFileEndOffset < 1)
806
+ return { content: [{ type: "text", text: "rightFileEndOffset must be greater than or equal to 1." }], isError: true };
807
+ threadContext.rightFileEnd.offset = rightFileEndOffset;
808
+ }
809
+ }
810
+ if (rightFileEndOffset !== undefined && rightFileEndLine === undefined) {
811
+ return { content: [{ type: "text", text: "rightFileEndLine must be specified if rightFileEndOffset is specified." }], isError: true };
812
+ }
813
+ if (rightFileStartLine !== undefined && rightFileStartOffset !== undefined) {
814
+ if (rightFileEndLine === undefined || rightFileEndOffset === undefined) {
815
+ return {
816
+ content: [{ type: "text", text: "rightFileEndLine and rightFileEndOffset must both be specified when rightFileStartLine and rightFileStartOffset are both specified." }],
817
+ isError: true,
818
+ };
819
+ }
820
+ }
821
+ if (rightFileStartLine !== undefined && rightFileEndLine !== undefined && rightFileStartLine === rightFileEndLine) {
822
+ if (rightFileEndOffset !== undefined && rightFileStartOffset !== undefined && rightFileEndOffset < rightFileStartOffset) {
823
+ return { content: [{ type: "text", text: "rightFileEndOffset must be greater than or equal to rightFileStartOffset when both are on the same line." }], isError: true };
824
+ }
825
+ }
826
+ const iterationContextValues = [changeTrackingId, firstComparingIteration, secondComparingIteration];
827
+ if (iterationContextValues.some((value) => value !== undefined) && iterationContextValues.some((value) => value === undefined)) {
828
+ return {
829
+ content: [{ type: "text", text: "changeTrackingId, firstComparingIteration, and secondComparingIteration must all be specified together." }],
830
+ isError: true,
831
+ };
832
+ }
833
+ const pullRequestThreadContext = changeTrackingId !== undefined && firstComparingIteration !== undefined && secondComparingIteration !== undefined
834
+ ? { changeTrackingId, iterationContext: { firstComparingIteration, secondComparingIteration } }
835
+ : undefined;
836
+ const thread = await gitApi.createThread({ comments: [{ content, commentType: 1 }], threadContext, pullRequestThreadContext, status: CommentThreadStatus[status] }, repositoryId, pullRequestId, project);
837
+ return { content: [{ type: "text", text: JSON.stringify(trimPullRequestThread(thread), null, 2) }] };
838
+ }
839
+ if (action === "reply") {
840
+ if (!threadId)
841
+ return { content: [{ type: "text", text: "threadId is required for reply" }], isError: true };
842
+ if (!content)
843
+ return { content: [{ type: "text", text: "content is required for reply" }], isError: true };
844
+ const comment = await gitApi.createComment({ content, commentType: 1 }, repositoryId, pullRequestId, threadId, project);
845
+ if (!comment) {
846
+ return { content: [{ type: "text", text: `Error: Failed to add comment to thread ${threadId}. The comment was not created successfully.` }], isError: true };
847
+ }
848
+ if (fullResponse)
849
+ return { content: [{ type: "text", text: JSON.stringify(comment, null, 2) }] };
850
+ return { content: [{ type: "text", text: `Comment successfully added to thread ${threadId}.` }] };
851
+ }
852
+ if (action === "update") {
853
+ if (!threadId)
854
+ return { content: [{ type: "text", text: "threadId is required for update" }], isError: true };
855
+ if (!commentId)
856
+ return { content: [{ type: "text", text: "commentId is required for update" }], isError: true };
857
+ if (!content)
858
+ return { content: [{ type: "text", text: "content is required for update" }], isError: true };
859
+ const comment = await gitApi.updateComment({ content }, repositoryId, pullRequestId, threadId, commentId, project);
860
+ if (!comment) {
861
+ return { content: [{ type: "text", text: `Error: Failed to update comment ${commentId} in thread ${threadId}. The comment was not updated successfully.` }], isError: true };
862
+ }
863
+ if (fullResponse)
864
+ return { content: [{ type: "text", text: JSON.stringify(comment, null, 2) }] };
865
+ return { content: [{ type: "text", text: `Comment ${commentId} successfully updated in thread ${threadId}.` }] };
866
+ }
867
+ if (action === "update_status") {
868
+ if (!threadId)
869
+ return { content: [{ type: "text", text: "threadId is required for update_status" }], isError: true };
870
+ if (!status)
871
+ return { content: [{ type: "text", text: "status is required for update_status" }], isError: true };
872
+ const updateRequest = {
873
+ status: CommentThreadStatus[status],
874
+ };
875
+ const thread = await gitApi.updateThread(updateRequest, repositoryId, pullRequestId, threadId, project);
876
+ if (!thread) {
877
+ return { content: [{ type: "text", text: `Error: Failed to update thread ${threadId}. The thread was not updated successfully.` }], isError: true };
878
+ }
879
+ return { content: [{ type: "text", text: JSON.stringify(trimPullRequestThread(thread), null, 2) }] };
880
+ }
881
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
882
+ }
883
+ catch (error) {
884
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
885
+ return { content: [{ type: "text", text: `Error with pull request thread write operation: ${errorMessage}` }], isError: true };
886
+ }
887
+ });
888
+ // --- repo_create_branch ----------------------------------------------------
889
+ server.tool(REPO_TOOLS.repo_create_branch, "Create a new branch in the repository.", {
890
+ repositoryId: z
891
+ .string()
892
+ .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."),
893
+ branchName: z.string().describe("The name of the new branch to create, e.g., 'feature-branch'."),
894
+ sourceBranchName: z.string().optional().default("main").describe("The name of the source branch to create the new branch from. Defaults to 'main'."),
895
+ sourceCommitId: z.string().optional().describe("The commit ID to create the branch from. If not provided, uses the latest commit of the source branch."),
896
+ project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
897
+ }, async ({ repositoryId, branchName, sourceBranchName, sourceCommitId, project }) => {
898
+ try {
899
+ const connection = await connectionProvider();
900
+ const gitApi = await connection.getGitApi();
901
+ let commitId = sourceCommitId;
902
+ if (!commitId) {
903
+ const sourceRefName = `refs/heads/${sourceBranchName}`;
904
+ try {
905
+ const sourceBranch = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, sourceBranchName);
906
+ const branch = sourceBranch.find((b) => b.name === sourceRefName);
907
+ if (!branch || !branch.objectId) {
908
+ return { content: [{ type: "text", text: `Error: Source branch '${sourceBranchName}' not found in repository ${repositoryId}` }], isError: true };
909
+ }
910
+ commitId = branch.objectId;
911
+ }
912
+ catch (error) {
913
+ return { content: [{ type: "text", text: `Error retrieving source branch '${sourceBranchName}': ${error instanceof Error ? error.message : String(error)}` }], isError: true };
914
+ }
915
+ }
916
+ const refUpdate = {
917
+ name: `refs/heads/${branchName}`,
918
+ newObjectId: commitId,
919
+ oldObjectId: "0000000000000000000000000000000000000000",
920
+ };
921
+ try {
922
+ const result = await gitApi.updateRefs([refUpdate], repositoryId, project);
923
+ if (result && result.length > 0 && result[0].success) {
924
+ return { content: [{ type: "text", text: `Branch '${branchName}' created successfully from '${sourceBranchName}' (${commitId})` }] };
925
+ }
926
+ else {
927
+ const errorMessage = result && result.length > 0 && result[0].customMessage ? result[0].customMessage : "Unknown error occurred during branch creation";
928
+ return { content: [{ type: "text", text: `Error creating branch '${branchName}': ${errorMessage}` }], isError: true };
929
+ }
930
+ }
931
+ catch (error) {
932
+ return { content: [{ type: "text", text: `Error creating branch '${branchName}': ${error instanceof Error ? error.message : String(error)}` }], isError: true };
933
+ }
934
+ }
935
+ catch (error) {
936
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
937
+ return { content: [{ type: "text", text: `Error creating branch: ${errorMessage}` }], isError: true };
938
+ }
939
+ });
940
+ }
941
+ export { REPO_TOOLS, configureRepoTools };