Repository: microsoft/azure-devops-mcp

Issue:
Add a new tool to return pull requests where a given email or display name is a reviewer
# Summary

Add a new MCP tool to return list of pull requests where a given email address or a "Display Name" is part of reviewers.  The scenarios is: In large mono repos, reviewer's are usually group email addresses and not direct users. In such cases, current i_am_reviewer parameter in other tools may not be useful.  To help in such cases, having a tool that can search and return based on this criteria would be useful to go through the PRs and review them with the help of copilot right in VS Code.   For matching logic, it should be case insensitive and for Display Name, it should match even if the given display name is part of the reviewer display name string. 

# Tools

Develop the following tools with full parameter support, including optional ones:

## `ado_list_pull_requests_by_repo_and_reviewer`: Search Azure DevOps pull requests in given repo and reviewer

With following parameters

repositoryId
top
skip
created_by
review_by
status

# Rules

1. Adhere strictly to existing project standards and coding conventions.
2. Ensure each tool exposes all API parameters (required and optional).
3. Use the official [Azure DevOps Node API](https://github.com/microsoft/azure-devops-node-api) to interact with the APIs.

# Special treat

If you follow the rules, you'll get candy!


Discussion:

Solution patch:
diff --git a/src/tools/repositories.ts b/src/tools/repositories.ts
index dcad512..a47e55f 100644
--- a/src/tools/repositories.ts
+++ b/src/tools/repositories.ts
@@ -272,6 +272,10 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<Acce
       created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user."),
       created_by_user: z.string().optional().describe("Filter pull requests created by a specific user (provide email or unique name). Takes precedence over created_by_me if both are provided."),
       i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer."),
+      user_is_reviewer: z
+        .string()
+        .optional()
+        .describe("Filter pull requests where a specific user is a reviewer (provide email or unique name). Takes precedence over i_am_reviewer if both are provided."),
       status: z
         .enum(getEnumKeys(PullRequestStatus) as [string, ...string[]])
         .default("Active")
@@ -279,7 +283,7 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<Acce
       sourceRefName: z.string().optional().describe("Filter pull requests from this source branch (e.g., 'refs/heads/feature-branch')."),
       targetRefName: z.string().optional().describe("Filter pull requests into this target branch (e.g., 'refs/heads/main')."),
     },
-    async ({ repositoryId, top, skip, created_by_me, created_by_user, i_am_reviewer, status, sourceRefName, targetRefName }) => {
+    async ({ repositoryId, top, skip, created_by_me, created_by_user, i_am_reviewer, user_is_reviewer, status, sourceRefName, targetRefName }) => {
       const connection = await connectionProvider();
       const gitApi = await connection.getGitApi();
 
@@ -319,15 +323,31 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<Acce
             isError: true,
           };
         }
-      } else if (created_by_me || i_am_reviewer) {
+      } else if (created_by_me) {
         const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
         const userId = data.authenticatedUser.id;
-        if (created_by_me) {
-          searchCriteria.creatorId = userId;
-        }
-        if (i_am_reviewer) {
-          searchCriteria.reviewerId = userId;
+        searchCriteria.creatorId = userId;
+      }
+
+      if (user_is_reviewer) {
+        try {
+          const reviewerUserId = await getUserIdFromEmail(user_is_reviewer, tokenProvider, connectionProvider, userAgentProvider);
+          searchCriteria.reviewerId = reviewerUserId;
+        } catch (error) {
+          return {
+            content: [
+              {
+                type: "text",
+                text: `Error finding reviewer with email ${user_is_reviewer}: ${error instanceof Error ? error.message : String(error)}`,
+              },
+            ],
+            isError: true,
+          };
         }
+      } else if (i_am_reviewer) {
+        const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
+        const userId = data.authenticatedUser.id;
+        searchCriteria.reviewerId = userId;
       }
 
       const pullRequests = await gitApi.getPullRequests(
@@ -371,6 +391,10 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<Acce
       created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user."),
       created_by_user: z.string().optional().describe("Filter pull requests created by a specific user (provide email or unique name). Takes precedence over created_by_me if both are provided."),
       i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer."),
+      user_is_reviewer: z
+        .string()
+        .optional()
+        .describe("Filter pull requests where a specific user is a reviewer (provide email or unique name). Takes precedence over i_am_reviewer if both are provided."),
       status: z
         .enum(getEnumKeys(PullRequestStatus) as [string, ...string[]])
         .default("Active")
@@ -378,7 +402,7 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<Acce
       sourceRefName: z.string().optional().describe("Filter pull requests from this source branch (e.g., 'refs/heads/feature-branch')."),
       targetRefName: z.string().optional().describe("Filter pull requests into this target branch (e.g., 'refs/heads/main')."),
     },
-    async ({ project, top, skip, created_by_me, created_by_user, i_am_reviewer, status, sourceRefName, targetRefName }) => {
+    async ({ project, top, skip, created_by_me, created_by_user, i_am_reviewer, user_is_reviewer, status, sourceRefName, targetRefName }) => {
       const connection = await connectionProvider();
       const gitApi = await connection.getGitApi();
 
@@ -416,15 +440,31 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise<Acce
             isError: true,
           };
         }
-      } else if (created_by_me || i_am_reviewer) {
+      } else if (created_by_me) {
         const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
         const userId = data.authenticatedUser.id;
-        if (created_by_me) {
-          gitPullRequestSearchCriteria.creatorId = userId;
-        }
-        if (i_am_reviewer) {
-          gitPullRequestSearchCriteria.reviewerId = userId;
+        gitPullRequestSearchCriteria.creatorId = userId;
+      }
+
+      if (user_is_reviewer) {
+        try {
+          const reviewerUserId = await getUserIdFromEmail(user_is_reviewer, tokenProvider, connectionProvider, userAgentProvider);
+          gitPullRequestSearchCriteria.reviewerId = reviewerUserId;
+        } catch (error) {
+          return {
+            content: [
+              {
+                type: "text",
+                text: `Error finding reviewer with email ${user_is_reviewer}: ${error instanceof Error ? error.message : String(error)}`,
+              },
+            ],
+            isError: true,
+          };
         }
+      } else if (i_am_reviewer) {
+        const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider);
+        const userId = data.authenticatedUser.id;
+        gitPullRequestSearchCriteria.reviewerId = userId;
       }
 
       const pullRequests = await gitApi.getPullRequestsByProject(



Test patch:
diff --git a/test/src/tools/repositories.test.ts b/test/src/tools/repositories.test.ts
index 0f651ce..079322c 100644
--- a/test/src/tools/repositories.test.ts
+++ b/test/src/tools/repositories.test.ts
@@ -765,6 +765,91 @@ describe("repos tools", () => {
         100
       );
     });
+
+    it("should filter pull requests by specific reviewer successfully", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo);
+      if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered");
+      const [, , , handler] = call;
+
+      // Mock successful user lookup
+      mockGetUserIdFromEmail.mockResolvedValue("reviewer-user-123");
+      mockGitApi.getPullRequests.mockResolvedValue([]);
+
+      const params = {
+        repositoryId: "repo123",
+        user_is_reviewer: "reviewer@example.com",
+        status: "Active",
+        top: 100,
+        skip: 0,
+      };
+
+      await handler(params);
+
+      expect(mockGetUserIdFromEmail).toHaveBeenCalledWith("reviewer@example.com", tokenProvider, connectionProvider, userAgentProvider);
+      expect(mockGitApi.getPullRequests).toHaveBeenCalledWith("repo123", { status: PullRequestStatus.Active, repositoryId: "repo123", reviewerId: "reviewer-user-123" }, undefined, undefined, 0, 100);
+    });
+
+    it("should prioritize user_is_reviewer over i_am_reviewer flag", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo);
+      if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered");
+      const [, , , handler] = call;
+
+      // Mock successful user lookup
+      mockGetUserIdFromEmail.mockResolvedValue("specific-reviewer-123");
+      mockGitApi.getPullRequests.mockResolvedValue([]);
+
+      const params = {
+        repositoryId: "repo123",
+        user_is_reviewer: "specific-reviewer@example.com",
+        i_am_reviewer: true, // This should be ignored
+        status: "Active",
+        top: 100,
+        skip: 0,
+      };
+
+      await handler(params);
+
+      expect(mockGetUserIdFromEmail).toHaveBeenCalledWith("specific-reviewer@example.com", tokenProvider, connectionProvider, userAgentProvider);
+      expect(mockGetCurrentUserDetails).not.toHaveBeenCalled(); // Should not be called since user_is_reviewer takes precedence
+      expect(mockGitApi.getPullRequests).toHaveBeenCalledWith(
+        "repo123",
+        { status: PullRequestStatus.Active, repositoryId: "repo123", reviewerId: "specific-reviewer-123" },
+        undefined,
+        undefined,
+        0,
+        100
+      );
+    });
+
+    it("should handle error when user_is_reviewer user not found", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo);
+      if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered");
+      const [, , , handler] = call;
+
+      // Mock user lookup failure
+      mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found"));
+
+      const params = {
+        repositoryId: "repo123",
+        user_is_reviewer: "nonexistent@example.com",
+        status: "Active",
+        top: 100,
+        skip: 0,
+      };
+
+      const result = await handler(params);
+
+      expect(mockGetUserIdFromEmail).toHaveBeenCalledWith("nonexistent@example.com", tokenProvider, connectionProvider, userAgentProvider);
+      expect(result.isError).toBe(true);
+      expect(result.content[0].text).toBe("Error finding reviewer with email nonexistent@example.com: User not found");
+      expect(mockGitApi.getPullRequests).not.toHaveBeenCalled();
+    });
   });
 
   describe("repo_list_pull_requests_by_project", () => {
@@ -1172,6 +1257,155 @@ describe("repos tools", () => {
         100
       );
     });
+
+    it("should filter pull requests by specific reviewer successfully", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_project);
+      if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered");
+      const [, , , handler] = call;
+
+      // Mock successful user lookup
+      mockGetUserIdFromEmail.mockResolvedValue("reviewer-user-123");
+      const mockPRs = [
+        {
+          pullRequestId: 555,
+          codeReviewId: 666,
+          repository: { name: "test-repo" },
+          status: PullRequestStatus.Active,
+          createdBy: { displayName: "Another User", uniqueName: "another@example.com" },
+          creationDate: "2023-01-05T00:00:00Z",
+          title: "PR Reviewed by Specific User",
+          isDraft: false,
+          sourceRefName: "refs/heads/reviewed-branch",
+          targetRefName: "refs/heads/main",
+        },
+      ];
+      mockGitApi.getPullRequestsByProject.mockResolvedValue(mockPRs);
+
+      const params = {
+        project: "test-project",
+        user_is_reviewer: "reviewer@example.com",
+        status: "Active",
+        top: 100,
+        skip: 0,
+      };
+
+      const result = await handler(params);
+
+      expect(mockGetUserIdFromEmail).toHaveBeenCalledWith("reviewer@example.com", tokenProvider, connectionProvider, userAgentProvider);
+      expect(mockGitApi.getPullRequestsByProject).toHaveBeenCalledWith("test-project", { status: PullRequestStatus.Active, reviewerId: "reviewer-user-123" }, undefined, 0, 100);
+
+      const expectedResult = [
+        {
+          pullRequestId: 555,
+          codeReviewId: 666,
+          repository: "test-repo",
+          status: PullRequestStatus.Active,
+          createdBy: { displayName: "Another User", uniqueName: "another@example.com" },
+          creationDate: "2023-01-05T00:00:00Z",
+          title: "PR Reviewed by Specific User",
+          isDraft: false,
+          sourceRefName: "refs/heads/reviewed-branch",
+          targetRefName: "refs/heads/main",
+        },
+      ];
+
+      expect(result.content[0].text).toBe(JSON.stringify(expectedResult, null, 2));
+    });
+
+    it("should prioritize user_is_reviewer over i_am_reviewer flag", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_project);
+      if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered");
+      const [, , , handler] = call;
+
+      // Mock successful user lookup
+      mockGetUserIdFromEmail.mockResolvedValue("specific-reviewer-123");
+      mockGitApi.getPullRequestsByProject.mockResolvedValue([]);
+
+      const params = {
+        project: "test-project",
+        user_is_reviewer: "specific-reviewer@example.com",
+        i_am_reviewer: true, // This should be ignored
+        status: "Active",
+        top: 100,
+        skip: 0,
+      };
+
+      await handler(params);
+
+      expect(mockGetUserIdFromEmail).toHaveBeenCalledWith("specific-reviewer@example.com", tokenProvider, connectionProvider, userAgentProvider);
+      expect(mockGetCurrentUserDetails).not.toHaveBeenCalled(); // Should not be called since user_is_reviewer takes precedence
+      expect(mockGitApi.getPullRequestsByProject).toHaveBeenCalledWith("test-project", { status: PullRequestStatus.Active, reviewerId: "specific-reviewer-123" }, undefined, 0, 100);
+    });
+
+    it("should handle error when user_is_reviewer user not found", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_project);
+      if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered");
+      const [, , , handler] = call;
+
+      // Mock user lookup failure
+      mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found"));
+
+      const params = {
+        project: "test-project",
+        user_is_reviewer: "nonexistent@example.com",
+        status: "Active",
+        top: 100,
+        skip: 0,
+      };
+
+      const result = await handler(params);
+
+      expect(mockGetUserIdFromEmail).toHaveBeenCalledWith("nonexistent@example.com", tokenProvider, connectionProvider, userAgentProvider);
+      expect(result.isError).toBe(true);
+      expect(result.content[0].text).toBe("Error finding reviewer with email nonexistent@example.com: User not found");
+      expect(mockGitApi.getPullRequestsByProject).not.toHaveBeenCalled();
+    });
+
+    it("should support both created_by_user and user_is_reviewer filters simultaneously", async () => {
+      configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider);
+
+      const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_project);
+      if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered");
+      const [, , , handler] = call;
+
+      // Mock both user lookups
+      mockGetUserIdFromEmail
+        .mockResolvedValueOnce("creator-user-123") // First call for created_by_user
+        .mockResolvedValueOnce("reviewer-user-123"); // Second call for user_is_reviewer
+
+      mockGitApi.getPullRequestsByProject.mockResolvedValue([]);
+
+      const params = {
+        project: "test-project",
+        created_by_user: "creator@example.com",
+        user_is_reviewer: "reviewer@example.com",
+        status: "Active",
+        top: 100,
+        skip: 0,
+      };
+
+      await handler(params);
+
+      expect(mockGetUserIdFromEmail).toHaveBeenCalledWith("creator@example.com", tokenProvider, connectionProvider, userAgentProvider);
+      expect(mockGetUserIdFromEmail).toHaveBeenCalledWith("reviewer@example.com", tokenProvider, connectionProvider, userAgentProvider);
+      expect(mockGitApi.getPullRequestsByProject).toHaveBeenCalledWith(
+        "test-project",
+        {
+          status: PullRequestStatus.Active,
+          creatorId: "creator-user-123",
+          reviewerId: "reviewer-user-123",
+        },
+        undefined,
+        0,
+        100
+      );
+    });
   });
 
   describe("repo_list_pull_request_threads", () => {