@azure-devops/mcp 2.7.0 → 2.8.0-nightly.20260624

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.
@@ -3,7 +3,7 @@
3
3
  import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus, GitPullRequestMergeStrategy, VersionControlChangeType, VersionControlRecursionType, } from "azure-devops-node-api/interfaces/GitInterfaces.js";
4
4
  import { z } from "zod";
5
5
  import { getCurrentUserDetails, getUserIdFromEmail } from "./auth.js";
6
- import { getEnumKeys, streamToString } from "../utils.js";
6
+ import { extractAdoStreamError, getEnumKeys, streamToString } from "../utils.js";
7
7
  const REPO_TOOLS = {
8
8
  list_repos_by_project: "repo_list_repos_by_project",
9
9
  list_pull_requests_by_repo_or_project: "repo_list_pull_requests_by_repo_or_project",
@@ -91,11 +91,13 @@ function trimPullRequest(pr, includeDescription = false) {
91
91
  if (!pr) {
92
92
  return null;
93
93
  }
94
+ const statusName = typeof pr.status === "number" ? (PullRequestStatus[pr.status] ?? "Unknown") : "Unknown";
94
95
  return {
95
96
  pullRequestId: pr.pullRequestId,
96
97
  codeReviewId: pr.codeReviewId,
97
98
  repository: pr.repository?.name,
98
99
  status: pr.status,
100
+ statusName,
99
101
  createdBy: {
100
102
  displayName: pr.createdBy?.displayName,
101
103
  uniqueName: pr.createdBy?.uniqueName,
@@ -144,6 +146,8 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
144
146
  const connection = await connectionProvider();
145
147
  const gitApi = await connection.getGitApi();
146
148
  const workItemRefs = workItems ? workItems.split(" ").map((id) => ({ id: id.trim() })) : [];
149
+ const noDataErrorMessage = `Pull request creation returned no data and no matching PR was found. This often means repositoryId=\"${repositoryId}\" was not resolvable. ` +
150
+ "Try the repository GUID from repo_list_repos_by_project instead of the Project/RepoName slash format.";
147
151
  const forkSource = forkSourceRepositoryId
148
152
  ? {
149
153
  repository: {
@@ -170,14 +174,16 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
170
174
  }
171
175
  else {
172
176
  return {
173
- content: [{ type: "text", text: "Pull request created but API returned no data." }],
177
+ content: [{ type: "text", text: noDataErrorMessage }],
178
+ isError: true,
174
179
  };
175
180
  }
176
181
  }
177
182
  const trimmedPullRequest = trimPullRequest(pullRequest, true);
178
183
  if (!trimmedPullRequest) {
179
184
  return {
180
- content: [{ type: "text", text: "Pull request created but API returned no data." }],
185
+ content: [{ type: "text", text: noDataErrorMessage }],
186
+ isError: true,
181
187
  };
182
188
  }
183
189
  return {
@@ -303,11 +309,12 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
303
309
  .enum(getEnumKeys(GitPullRequestMergeStrategy))
304
310
  .optional()
305
311
  .describe("The merge strategy to use when the pull request autocompletes. Defaults to 'NoFastForward'."),
312
+ mergeCommitMessage: z.string().optional().describe("Commit message to use when the pull request is completed."),
306
313
  deleteSourceBranch: z.boolean().optional().default(false).describe("Whether to delete the source branch when the pull request autocompletes. Defaults to false."),
307
314
  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."),
308
315
  bypassReason: z.string().optional().describe("Reason for bypassing branch policies. When provided, branch policies will be automatically bypassed during autocompletion."),
309
316
  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."),
310
- }, async ({ repositoryId, pullRequestId, project, title, description, isDraft, targetRefName, status, autoComplete, mergeStrategy, deleteSourceBranch, transitionWorkItems, bypassReason, labels, }) => {
317
+ }, async ({ repositoryId, pullRequestId, project, title, description, isDraft, targetRefName, status, autoComplete, mergeStrategy, mergeCommitMessage, deleteSourceBranch, transitionWorkItems, bypassReason, labels, }) => {
311
318
  try {
312
319
  const connection = await connectionProvider();
313
320
  const gitApi = await connection.getGitApi();
@@ -337,6 +344,9 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
337
344
  if (mergeStrategy) {
338
345
  completionOptions.mergeStrategy = GitPullRequestMergeStrategy[mergeStrategy];
339
346
  }
347
+ if (mergeCommitMessage) {
348
+ completionOptions.mergeCommitMessage = mergeCommitMessage;
349
+ }
340
350
  if (bypassReason) {
341
351
  completionOptions.bypassReason = bypassReason;
342
352
  }
@@ -618,33 +628,33 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
618
628
  try {
619
629
  const connection = await connectionProvider();
620
630
  const gitApi = await connection.getGitApi();
621
- const threads = await gitApi.getThreads(repositoryId, pullRequestId, project, iteration, baseIteration);
631
+ const threads = (await gitApi.getThreads(repositoryId, pullRequestId, project, iteration, baseIteration)) ?? [];
622
632
  let filteredThreads = threads;
623
633
  if (status !== undefined) {
624
634
  const statusValue = CommentThreadStatus[status];
625
- filteredThreads = filteredThreads?.filter((thread) => thread.status === statusValue);
635
+ filteredThreads = filteredThreads.filter((thread) => thread.status === statusValue);
626
636
  }
627
637
  if (authorEmail !== undefined) {
628
- filteredThreads = filteredThreads?.filter((thread) => {
638
+ filteredThreads = filteredThreads.filter((thread) => {
629
639
  const firstComment = thread.comments?.[0];
630
640
  return firstComment?.author?.uniqueName?.toLowerCase() === authorEmail.toLowerCase();
631
641
  });
632
642
  }
633
643
  if (authorDisplayName !== undefined) {
634
644
  const lowerAuthorName = authorDisplayName.toLowerCase();
635
- filteredThreads = filteredThreads?.filter((thread) => {
645
+ filteredThreads = filteredThreads.filter((thread) => {
636
646
  const firstComment = thread.comments?.[0];
637
647
  return firstComment?.author?.displayName?.toLowerCase().includes(lowerAuthorName);
638
648
  });
639
649
  }
640
- const paginatedThreads = filteredThreads?.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top);
650
+ const paginatedThreads = filteredThreads.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top);
641
651
  if (fullResponse) {
642
652
  return {
643
653
  content: [{ type: "text", text: JSON.stringify(paginatedThreads, null, 2) }],
644
654
  };
645
655
  }
646
656
  // Return trimmed thread data focusing on essential information
647
- const trimmedThreads = paginatedThreads?.map((thread) => trimPullRequestThread(thread));
657
+ const trimmedThreads = paginatedThreads.map((thread) => trimPullRequestThread(thread));
648
658
  return {
649
659
  content: [{ type: "text", text: JSON.stringify(trimmedThreads, null, 2) }],
650
660
  };
@@ -693,7 +703,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
693
703
  };
694
704
  }
695
705
  });
696
- server.tool(REPO_TOOLS.list_branches_by_repo, "Retrieve a list of branches for a given repository.", {
706
+ 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.", {
697
707
  repositoryId: z
698
708
  .string()
699
709
  .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."),
@@ -718,7 +728,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
718
728
  };
719
729
  }
720
730
  });
721
- server.tool(REPO_TOOLS.list_my_branches_by_repo, "Retrieve a list of my branches for a given repository Id.", {
731
+ 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.", {
722
732
  repositoryId: z
723
733
  .string()
724
734
  .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."),
@@ -770,7 +780,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
770
780
  };
771
781
  }
772
782
  });
773
- server.tool(REPO_TOOLS.get_branch_by_name, "Get a branch by its name.", {
783
+ server.tool(REPO_TOOLS.get_branch_by_name, "Get a branch by its name. Returns isError: true if the branch is not found.", {
774
784
  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."),
775
785
  branchName: z.string().describe("The name of the branch to retrieve, e.g., 'main' or 'feature-branch'."),
776
786
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."),
@@ -976,11 +986,14 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
976
986
  originalPath: origPath,
977
987
  };
978
988
  });
979
- if (fileDiffParams.length > 0) {
980
- try {
989
+ try {
990
+ // Fetch diffs for modified files. Add/Delete files are excluded from getFileDiffs
991
+ // because they don't have two versions to compare; their content is fetched
992
+ // separately below via getItemText when includeLineContent is true.
993
+ let fileDiffs = [];
994
+ if (fileDiffParams.length > 0) {
981
995
  // Azure DevOps getFileDiffs API accepts max 10 files per request
982
996
  const FILE_DIFF_BATCH_SIZE = 10;
983
- let fileDiffs = [];
984
997
  for (let i = 0; i < fileDiffParams.length; i += FILE_DIFF_BATCH_SIZE) {
985
998
  const batch = fileDiffParams.slice(i, i + FILE_DIFF_BATCH_SIZE);
986
999
  const batchDiffs = await gitApi.getFileDiffs({
@@ -990,207 +1003,215 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
990
1003
  }, project || "", repositoryId);
991
1004
  fileDiffs = fileDiffs.concat(batchDiffs);
992
1005
  }
993
- // Merge diff content with change metadata
994
- const enrichedChanges = {
995
- ...changes,
996
- changeEntries: changes.changeEntries.map((entry) => {
997
- // Normalize path for comparison (remove leading slash)
998
- const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path;
999
- const matchingDiff = fileDiffs.find((diff) => diff.path === entryPath);
1000
- return {
1001
- ...entry,
1002
- diff: matchingDiff || null,
1003
- };
1004
- }),
1005
- };
1006
- // If includeLineContent is true, fetch actual file content with concurrency limit
1007
- if (includeLineContent && enrichedChanges.changeEntries) {
1008
- const CONCURRENCY_LIMIT = 10;
1009
- const entriesWithContent = [...enrichedChanges.changeEntries];
1010
- for (let i = 0; i < entriesWithContent.length; i += CONCURRENCY_LIMIT) {
1011
- const batch = entriesWithContent.slice(i, i + CONCURRENCY_LIMIT);
1012
- const batchResults = await Promise.all(batch.map(async (entry) => {
1013
- const ct = entry.changeType ?? 0;
1014
- const isAdd = !!(ct & VersionControlChangeType.Add);
1015
- const isDelete = !!(ct & VersionControlChangeType.Delete);
1016
- const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path;
1017
- if (!entryPath) {
1018
- return entry;
1019
- }
1020
- // Handle added files: fetch full content at target commit and create synthetic diff
1021
- if (isAdd && !entry.diff) {
1022
- try {
1023
- const targetStream = await gitApi
1024
- .getItemText(repositoryId, entryPath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit })
1025
- .catch(() => null);
1026
- if (targetStream) {
1027
- const targetText = await streamToString(targetStream);
1028
- const targetLines = targetText.split(/\r?\n/);
1029
- return {
1030
- ...entry,
1031
- diff: {
1032
- path: entryPath,
1033
- originalPath: entryPath,
1034
- lineDiffBlocks: [
1035
- {
1036
- changeType: 1, // Add
1037
- originalLineNumberStart: 0,
1038
- originalLinesCount: 0,
1039
- modifiedLineNumberStart: 1,
1040
- modifiedLinesCount: targetLines.length,
1041
- modifiedLines: targetLines,
1042
- },
1043
- ],
1044
- },
1045
- };
1046
- }
1047
- }
1048
- catch (addError) {
1006
+ }
1007
+ // Merge diff content with change metadata.
1008
+ // Added/deleted entries get diff: null here and are enriched below.
1009
+ const enrichedChanges = {
1010
+ ...changes,
1011
+ changeEntries: changes.changeEntries.map((entry) => {
1012
+ // Normalize path for comparison (remove leading slash)
1013
+ const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path;
1014
+ const matchingDiff = fileDiffs.find((diff) => diff.path === entryPath);
1015
+ return {
1016
+ ...entry,
1017
+ diff: matchingDiff || null,
1018
+ };
1019
+ }),
1020
+ };
1021
+ // If includeLineContent is true, fetch actual file content with concurrency limit
1022
+ if (includeLineContent && enrichedChanges.changeEntries) {
1023
+ const CONCURRENCY_LIMIT = 10;
1024
+ const entriesWithContent = [...enrichedChanges.changeEntries];
1025
+ for (let i = 0; i < entriesWithContent.length; i += CONCURRENCY_LIMIT) {
1026
+ const batch = entriesWithContent.slice(i, i + CONCURRENCY_LIMIT);
1027
+ const batchResults = await Promise.all(batch.map(async (entry) => {
1028
+ const ct = entry.changeType ?? 0;
1029
+ const isAdd = !!(ct & VersionControlChangeType.Add);
1030
+ const isDelete = !!(ct & VersionControlChangeType.Delete);
1031
+ const entryPath = entry.item?.path ? (entry.item.path.startsWith("/") ? entry.item.path.substring(1) : entry.item.path) : undefined;
1032
+ // For deleted files ADO sets item.path to null and puts the path in originalPath only.
1033
+ // Normalise originalPath once and use it as the fallback throughout.
1034
+ const normalizedOriginalPath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : undefined;
1035
+ // effectivePath is what we use as the "current" path for API calls / early-exit guard.
1036
+ // For additions/modifications it's item.path; for deletions it's originalPath.
1037
+ const effectivePath = entryPath ?? normalizedOriginalPath;
1038
+ if (!effectivePath) {
1039
+ return entry;
1040
+ }
1041
+ // Handle added files: fetch full content at target commit and create synthetic diff
1042
+ if (isAdd && !entry.diff) {
1043
+ try {
1044
+ const targetStream = await gitApi
1045
+ .getItemText(repositoryId, effectivePath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit })
1046
+ .catch(() => null);
1047
+ if (targetStream) {
1048
+ const targetText = await streamToString(targetStream);
1049
+ const targetLines = targetText.split(/\r?\n/);
1049
1050
  return {
1050
1051
  ...entry,
1051
- _contentFetchError: `Failed to fetch added file content: ${addError instanceof Error ? addError.message : "Unknown error"}`,
1052
+ diff: {
1053
+ path: effectivePath,
1054
+ originalPath: null,
1055
+ lineDiffBlocks: [
1056
+ {
1057
+ changeType: 1, // Add
1058
+ originalLineNumberStart: 0,
1059
+ originalLinesCount: 0,
1060
+ modifiedLineNumberStart: 1,
1061
+ modifiedLinesCount: targetLines.length,
1062
+ modifiedLines: targetLines,
1063
+ },
1064
+ ],
1065
+ },
1052
1066
  };
1053
1067
  }
1054
- return entry;
1055
1068
  }
1056
- // Handle deleted files: fetch full content at base commit and create synthetic diff
1057
- if (isDelete && !entry.diff) {
1058
- try {
1059
- const basePath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : entryPath;
1060
- const baseStream = await gitApi
1061
- .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit })
1062
- .catch(() => null);
1063
- if (baseStream) {
1064
- const baseText = await streamToString(baseStream);
1065
- const baseLines = baseText.split(/\r?\n/);
1066
- return {
1067
- ...entry,
1068
- diff: {
1069
- path: entryPath,
1070
- originalPath: basePath,
1071
- lineDiffBlocks: [
1072
- {
1073
- changeType: 2, // Delete
1074
- originalLineNumberStart: 1,
1075
- originalLinesCount: baseLines.length,
1076
- modifiedLineNumberStart: 0,
1077
- modifiedLinesCount: 0,
1078
- originalLines: baseLines,
1079
- },
1080
- ],
1081
- },
1082
- };
1083
- }
1084
- }
1085
- catch (delError) {
1069
+ catch (addError) {
1070
+ return {
1071
+ ...entry,
1072
+ _contentFetchError: `Failed to fetch added file content: ${addError instanceof Error ? addError.message : "Unknown error"}`,
1073
+ };
1074
+ }
1075
+ return entry;
1076
+ }
1077
+ // Handle deleted files: fetch full content at base commit and create synthetic diff.
1078
+ // basePath prefers originalPath (the pre-deletion path); falls back to effectivePath.
1079
+ if (isDelete && !entry.diff) {
1080
+ try {
1081
+ const basePath = normalizedOriginalPath ?? effectivePath;
1082
+ const baseStream = await gitApi
1083
+ .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit })
1084
+ .catch(() => null);
1085
+ if (baseStream) {
1086
+ const baseText = await streamToString(baseStream);
1087
+ const baseLines = baseText.split(/\r?\n/);
1086
1088
  return {
1087
1089
  ...entry,
1088
- _contentFetchError: `Failed to fetch deleted file content: ${delError instanceof Error ? delError.message : "Unknown error"}`,
1090
+ diff: {
1091
+ path: null,
1092
+ originalPath: basePath,
1093
+ lineDiffBlocks: [
1094
+ {
1095
+ changeType: 2, // Delete
1096
+ originalLineNumberStart: 1,
1097
+ originalLinesCount: baseLines.length,
1098
+ modifiedLineNumberStart: 0,
1099
+ modifiedLinesCount: 0,
1100
+ originalLines: baseLines,
1101
+ },
1102
+ ],
1103
+ },
1089
1104
  };
1090
1105
  }
1091
- return entry;
1092
- }
1093
- // For modified/renamed files, skip if no diff blocks
1094
- if (!entry.diff?.lineDiffBlocks || entry.diff.lineDiffBlocks.length === 0) {
1095
- return entry;
1096
- }
1097
- // For renamed/moved files, the base version is at the original path
1098
- const basePath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : entryPath;
1099
- try {
1100
- // Fetch file content at both commits
1101
- const [baseContent, targetContent] = await Promise.all([
1102
- // Base version (original) - use basePath for renamed files
1103
- gitApi
1104
- .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit })
1105
- .catch(() => null),
1106
- // Target version (modified)
1107
- gitApi
1108
- .getItemText(repositoryId, entryPath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit })
1109
- .catch(() => null),
1110
- ]);
1111
- // Convert streams to text
1112
- const baseText = baseContent ? await streamToString(baseContent) : "";
1113
- const targetText = targetContent ? await streamToString(targetContent) : "";
1114
- // Check if response is an Azure DevOps error (returned as JSON in the stream)
1115
- const checkForApiError = (text, label) => {
1116
- if (text.startsWith("{")) {
1117
- try {
1118
- const parsed = JSON.parse(text);
1119
- if (parsed.$id && parsed.innerException !== undefined) {
1120
- throw new Error(`Failed to fetch ${label} file content: ${parsed.message || text}`);
1121
- }
1122
- }
1123
- catch (e) {
1124
- if (e instanceof Error && e.message.startsWith("Failed to fetch"))
1125
- throw e;
1126
- // Not valid JSON or not an error response — treat as legitimate content
1127
- }
1128
- }
1129
- };
1130
- checkForApiError(baseText, "base");
1131
- checkForApiError(targetText, "target");
1132
- // Split into lines
1133
- const baseLines = baseText.split(/\r?\n/);
1134
- const targetLines = targetText.split(/\r?\n/);
1135
- // Enrich each lineDiffBlock with actual line content
1136
- const enrichedDiff = {
1137
- ...entry.diff,
1138
- lineDiffBlocks: entry.diff.lineDiffBlocks?.map((block) => {
1139
- const enrichedBlock = { ...block };
1140
- // Add original (base) lines if they exist
1141
- if (block.originalLineNumberStart && block.originalLinesCount) {
1142
- const startIdx = block.originalLineNumberStart - 1;
1143
- const endIdx = startIdx + block.originalLinesCount;
1144
- enrichedBlock.originalLines = baseLines.slice(startIdx, endIdx);
1145
- }
1146
- // Add modified (target) lines if they exist
1147
- if (block.modifiedLineNumberStart && block.modifiedLinesCount) {
1148
- const startIdx = block.modifiedLineNumberStart - 1;
1149
- const endIdx = startIdx + block.modifiedLinesCount;
1150
- enrichedBlock.modifiedLines = targetLines.slice(startIdx, endIdx);
1151
- }
1152
- return enrichedBlock;
1153
- }),
1154
- };
1155
- return {
1156
- ...entry,
1157
- diff: enrichedDiff,
1158
- };
1159
1106
  }
1160
- catch (contentError) {
1161
- // If content fetch fails, return entry with error
1107
+ catch (delError) {
1162
1108
  return {
1163
1109
  ...entry,
1164
- _contentFetchError: `Failed to fetch line content: ${contentError instanceof Error ? contentError.message : "Unknown error"}`,
1110
+ _contentFetchError: `Failed to fetch deleted file content: ${delError instanceof Error ? delError.message : "Unknown error"}`,
1165
1111
  };
1166
1112
  }
1167
- }));
1168
- // Write batch results back into the array
1169
- for (let j = 0; j < batchResults.length; j++) {
1170
- entriesWithContent[i + j] = batchResults[j];
1113
+ return entry;
1114
+ }
1115
+ // For modified/renamed files, skip if no diff blocks
1116
+ if (!entry.diff?.lineDiffBlocks || entry.diff.lineDiffBlocks.length === 0) {
1117
+ return entry;
1118
+ }
1119
+ // For renamed/moved files, the base version is at the original path
1120
+ const basePath = normalizedOriginalPath ?? effectivePath;
1121
+ try {
1122
+ // Fetch file content at both commits
1123
+ const [baseContent, targetContent] = await Promise.all([
1124
+ // Base version (original) - use basePath for renamed files
1125
+ gitApi
1126
+ .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit })
1127
+ .catch(() => null),
1128
+ // Target version (modified)
1129
+ gitApi
1130
+ .getItemText(repositoryId, effectivePath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit })
1131
+ .catch(() => null),
1132
+ ]);
1133
+ // Convert streams to text
1134
+ const baseText = baseContent ? await streamToString(baseContent) : "";
1135
+ const targetText = targetContent ? await streamToString(targetContent) : "";
1136
+ // Check if response is an Azure DevOps error (returned as JSON in the stream)
1137
+ const checkForApiError = (text, label) => {
1138
+ if (text.startsWith("{")) {
1139
+ try {
1140
+ const parsed = JSON.parse(text);
1141
+ if (parsed.$id && parsed.innerException !== undefined) {
1142
+ throw new Error(`Failed to fetch ${label} file content: ${parsed.message || text}`);
1143
+ }
1144
+ }
1145
+ catch (e) {
1146
+ if (e instanceof Error && e.message.startsWith("Failed to fetch"))
1147
+ throw e;
1148
+ // Not valid JSON or not an error response — treat as legitimate content
1149
+ }
1150
+ }
1151
+ };
1152
+ checkForApiError(baseText, "base");
1153
+ checkForApiError(targetText, "target");
1154
+ // Split into lines
1155
+ const baseLines = baseText.split(/\r?\n/);
1156
+ const targetLines = targetText.split(/\r?\n/);
1157
+ // Enrich each lineDiffBlock with actual line content
1158
+ const enrichedDiff = {
1159
+ ...entry.diff,
1160
+ lineDiffBlocks: entry.diff.lineDiffBlocks?.map((block) => {
1161
+ const enrichedBlock = { ...block };
1162
+ // Add original (base) lines if they exist
1163
+ if (block.originalLineNumberStart && block.originalLinesCount) {
1164
+ const startIdx = block.originalLineNumberStart - 1;
1165
+ const endIdx = startIdx + block.originalLinesCount;
1166
+ enrichedBlock.originalLines = baseLines.slice(startIdx, endIdx);
1167
+ }
1168
+ // Add modified (target) lines if they exist
1169
+ if (block.modifiedLineNumberStart && block.modifiedLinesCount) {
1170
+ const startIdx = block.modifiedLineNumberStart - 1;
1171
+ const endIdx = startIdx + block.modifiedLinesCount;
1172
+ enrichedBlock.modifiedLines = targetLines.slice(startIdx, endIdx);
1173
+ }
1174
+ return enrichedBlock;
1175
+ }),
1176
+ };
1177
+ return {
1178
+ ...entry,
1179
+ diff: enrichedDiff,
1180
+ };
1171
1181
  }
1182
+ catch (contentError) {
1183
+ // If content fetch fails, return entry with error
1184
+ return {
1185
+ ...entry,
1186
+ _contentFetchError: `Failed to fetch line content: ${contentError instanceof Error ? contentError.message : "Unknown error"}`,
1187
+ };
1188
+ }
1189
+ }));
1190
+ // Write batch results back into the array
1191
+ for (let j = 0; j < batchResults.length; j++) {
1192
+ entriesWithContent[i + j] = batchResults[j];
1172
1193
  }
1173
- enrichedChanges.changeEntries = entriesWithContent;
1174
1194
  }
1175
- return {
1176
- content: [{ type: "text", text: JSON.stringify(enrichedChanges, null, 2) }],
1177
- };
1178
- }
1179
- catch (diffError) {
1180
- // If diff fetching fails, return metadata with error info
1181
- return {
1182
- content: [
1183
- {
1184
- type: "text",
1185
- text: JSON.stringify({
1186
- ...changes,
1187
- _diffError: `Failed to fetch diff content: ${diffError instanceof Error ? diffError.message : "Unknown error"}`,
1188
- _note: "Returned metadata only",
1189
- }, null, 2),
1190
- },
1191
- ],
1192
- };
1195
+ enrichedChanges.changeEntries = entriesWithContent;
1193
1196
  }
1197
+ return {
1198
+ content: [{ type: "text", text: JSON.stringify(enrichedChanges, null, 2) }],
1199
+ };
1200
+ }
1201
+ catch (diffError) {
1202
+ // If diff fetching fails, return metadata with error info
1203
+ return {
1204
+ content: [
1205
+ {
1206
+ type: "text",
1207
+ text: JSON.stringify({
1208
+ ...changes,
1209
+ _diffError: `Failed to fetch diff content: ${diffError instanceof Error ? diffError.message : "Unknown error"}`,
1210
+ _note: "Returned metadata only",
1211
+ }, null, 2),
1212
+ },
1213
+ ],
1214
+ };
1194
1215
  }
1195
1216
  }
1196
1217
  }
@@ -1220,7 +1241,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1220
1241
  try {
1221
1242
  const connection = await connectionProvider();
1222
1243
  const gitApi = await connection.getGitApi();
1223
- const comment = await gitApi.createComment({ content }, repositoryId, pullRequestId, threadId, project);
1244
+ const comment = await gitApi.createComment({ content, commentType: 1 }, repositoryId, pullRequestId, threadId, project);
1224
1245
  // Check if the comment was successfully created
1225
1246
  if (!comment) {
1226
1247
  return {
@@ -1266,7 +1287,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1266
1287
  rightFileStartOffset: z
1267
1288
  .number()
1268
1289
  .optional()
1269
- .describe("Position of first character of the thread's span in right file. The line number of a thread's position. The character offset of a thread's position inside of a line. Starts at 1. Must be set if rightFileStartLine is also specified. (optional)"),
1290
+ .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)"),
1270
1291
  rightFileEndLine: z
1271
1292
  .number()
1272
1293
  .optional()
@@ -1274,7 +1295,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1274
1295
  rightFileEndOffset: z
1275
1296
  .number()
1276
1297
  .optional()
1277
- .describe("Position of last character of the thread's span in right file. The character offset of a thread's position inside of a line. Must be set if rightFileEndLine is also specified. (optional)"),
1298
+ .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)"),
1278
1299
  }, async ({ repositoryId, pullRequestId, content, project, filePath, status, rightFileStartLine, rightFileStartOffset, rightFileEndLine, rightFileEndOffset }) => {
1279
1300
  try {
1280
1301
  const connection = await connectionProvider();
@@ -1351,7 +1372,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1351
1372
  };
1352
1373
  }
1353
1374
  }
1354
- const thread = await gitApi.createThread({ comments: [{ content: content }], threadContext: threadContext, status: CommentThreadStatus[status] }, repositoryId, pullRequestId, project);
1375
+ const thread = await gitApi.createThread({ comments: [{ content: content, commentType: 1 }], threadContext: threadContext, status: CommentThreadStatus[status] }, repositoryId, pullRequestId, project);
1355
1376
  const trimmedThread = trimPullRequestThread(thread);
1356
1377
  return {
1357
1378
  content: [{ type: "text", text: JSON.stringify(trimmedThread, null, 2) }],
@@ -1598,7 +1619,18 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1598
1619
  WaitingForAuthor: -5,
1599
1620
  Rejected: -10,
1600
1621
  };
1601
- await gitApi.createPullRequestReviewer({ vote: voteMap[vote], id: userId }, repositoryId, pullRequestId, userId, project);
1622
+ const existingReviewer = await gitApi.getPullRequestReviewer(repositoryId, pullRequestId, userId, project).catch((error) => {
1623
+ if (!(error instanceof Error) || !/not found|reviewer does not exist/i.test(error.message)) {
1624
+ throw error;
1625
+ }
1626
+ return undefined;
1627
+ });
1628
+ const reviewerPayload = {
1629
+ vote: voteMap[vote],
1630
+ id: userId,
1631
+ ...(existingReviewer?.isRequired !== undefined ? { isRequired: existingReviewer.isRequired } : {}),
1632
+ };
1633
+ await gitApi.createPullRequestReviewer(reviewerPayload, repositoryId, pullRequestId, userId, project);
1602
1634
  return {
1603
1635
  content: [
1604
1636
  {
@@ -1608,7 +1640,7 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1608
1640
  ],
1609
1641
  };
1610
1642
  });
1611
- 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.", {
1643
+ 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.", {
1612
1644
  repositoryId: z.string().describe("The ID or name of the repository."),
1613
1645
  path: z.string().optional().default("/").describe("The directory path to list (e.g., '/src' or '/src/components'). Defaults to repository root."),
1614
1646
  project: z.string().optional().describe("Project ID or name. Required if repositoryId is a name rather than a GUID."),
@@ -1629,7 +1661,8 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1629
1661
  const items = await gitApi.getItems(repositoryId, project, path, recursionType, true, false, false, false, versionDescriptor);
1630
1662
  if (!items || items.length === 0) {
1631
1663
  return {
1632
- content: [{ type: "text", text: `No items found at path: ${path}` }],
1664
+ content: [{ type: "text", text: `No items found at path: ${path}. The path may not exist in the repository.` }],
1665
+ isError: true,
1633
1666
  };
1634
1667
  }
1635
1668
  let filteredItems = items;
@@ -1677,7 +1710,8 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1677
1710
  // ── Get file content at a specific version (branch, tag, or commit) ──
1678
1711
  const fileVersionTypeStrings = getEnumKeys(GitVersionType);
1679
1712
  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). " +
1680
- "Useful for reading source files from PR branches, specific commits, or tags without having them checked out locally.", {
1713
+ "Useful for reading source files from PR branches, specific commits, or tags without having them checked out locally. " +
1714
+ "Returns isError: true if the file is not found.", {
1681
1715
  repositoryId: z.string().describe("The ID (GUID) or name of the repository."),
1682
1716
  path: z.string().describe("The full path to the file in the repository, e.g., '/src/main.ts' or 'src/main.ts'."),
1683
1717
  project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name."),
@@ -1710,6 +1744,13 @@ function configureRepoTools(server, tokenProvider, connectionProvider, userAgent
1710
1744
  versionDescriptor, true // includeContent
1711
1745
  );
1712
1746
  const content = await streamToString(stream);
1747
+ const streamError = extractAdoStreamError(content);
1748
+ if (streamError) {
1749
+ return {
1750
+ content: [{ type: "text", text: `Error getting file content for '${path}': ${streamError}` }],
1751
+ isError: true,
1752
+ };
1753
+ }
1713
1754
  return {
1714
1755
  content: [{ type: "text", text: content }],
1715
1756
  };