From 2f99efa416d9052a6cd3136dda339dad99de433f Mon Sep 17 00:00:00 2001 From: "daniel.junior" Date: Tue, 19 May 2026 16:19:35 -0300 Subject: [PATCH 1/2] Creating new tool to start a new thread on pull requets --- .../Models/PullRequestThreadDto.cs | 1 + .../Services/AzureDevOpsService.cs | 72 +++++++++++++++++++ .../Services/IAzureDevOpsService.cs | 22 ++++++ .../Tools/PullRequestTools.cs | 54 ++++++++++++++ 4 files changed, 149 insertions(+) diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Models/PullRequestThreadDto.cs b/src/Viamus.Azure.Devops.Mcp.Server/Models/PullRequestThreadDto.cs index 1c0df60..b7b5e2e 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Models/PullRequestThreadDto.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Models/PullRequestThreadDto.cs @@ -9,6 +9,7 @@ public sealed record PullRequestThreadDto public string? Status { get; init; } public string? FilePath { get; init; } public int? LineNumber { get; init; } + public int? EndLineNumber { get; init; } public DateTime? PublishedDate { get; init; } public DateTime? LastUpdatedDate { get; init; } public List? Comments { get; init; } diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs index 265b028..2c6ec86 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs @@ -1411,6 +1411,77 @@ public async Task> GetPullRequestThreadsAsyn } } + public async Task CreatePullRequestThreadAsync( + string repositoryNameOrId, + int pullRequestId, + string content, + string? filePath = null, + int? lineNumber = null, + int? endLineNumber = null, + string? project = null, + CancellationToken cancellationToken = default) + { + try + { + _logger.LogDebug( + "Creating thread on pull request {PullRequestId} for repository {Repository}", + pullRequestId, + repositoryNameOrId); + + var thread = new GitPullRequestCommentThread + { + Comments = + [ + new Microsoft.TeamFoundation.SourceControl.WebApi.Comment + { + Content = content, + CommentType = CommentType.Text + } + ], + Status = CommentThreadStatus.Active + }; + + if (!string.IsNullOrWhiteSpace(filePath) && lineNumber.HasValue) + { + var start = new CommentPosition + { + Line = lineNumber.Value, + Offset = 1 + }; + + thread.ThreadContext = new CommentThreadContext + { + FilePath = filePath, + RightFileStart = start, + RightFileEnd = new CommentPosition + { + Line = endLineNumber ?? lineNumber.Value, + Offset = 1 + } + }; + } + + var created = await _gitClient.CreateThreadAsync( + commentThread: thread, + project: project ?? _options.DefaultProject, + repositoryId: repositoryNameOrId, + pullRequestId: pullRequestId, + userState: null, + cancellationToken: cancellationToken); + + return MapToPullRequestThreadDto(created); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error creating thread on pull request {PullRequestId} for repository {Repository}", + pullRequestId, + repositoryNameOrId); + throw; + } + } + public async Task AddPullRequestThreadCommentAsync( string repositoryNameOrId, int pullRequestId, @@ -1631,6 +1702,7 @@ private static PullRequestThreadDto MapToPullRequestThreadDto(GitPullRequestComm Status = thread.Status.ToString(), FilePath = thread.ThreadContext?.FilePath, LineNumber = thread.ThreadContext?.RightFileStart?.Line, + EndLineNumber = thread.ThreadContext?.RightFileEnd?.Line, PublishedDate = thread.PublishedDate, LastUpdatedDate = thread.LastUpdatedDate, Comments = thread.Comments?.Select(c => new PullRequestCommentDto diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs index e5e7fe1..3df135a 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs @@ -319,6 +319,28 @@ Task> GetPullRequestThreadsAsync( string? project = null, CancellationToken cancellationToken = default); + /// + /// Creates a new comment thread on a pull request. + /// + /// The repository name or ID. + /// The pull request ID. + /// The initial comment text (Markdown supported). + /// Optional file path for an inline thread. + /// Optional line number for an inline thread. + /// Optional ending line number for an inline thread range. + /// The project name (optional if default project is configured). + /// Cancellation token. + /// The created comment thread. + Task CreatePullRequestThreadAsync( + string repositoryNameOrId, + int pullRequestId, + string content, + string? filePath = null, + int? lineNumber = null, + int? endLineNumber = null, + string? project = null, + CancellationToken cancellationToken = default); + /// /// Adds a comment to an existing comment thread on a pull request. /// diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs b/src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs index 718128a..f69ee11 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs @@ -136,6 +136,60 @@ public async Task GetPullRequestThreads( }, JsonOptions); } + [McpServerTool(Name = "create_pull_request_thread")] + [Description("Creates a new comment thread on a pull request. Omit filePath and lineNumber for a general PR discussion, or provide them to create an inline file comment.")] + public async Task CreatePullRequestThread( + [Description("The repository name or ID")] string repositoryNameOrId, + [Description("The pull request ID")] int pullRequestId, + [Description("The initial comment text (Markdown supported)")] string content, + [Description("Optional file path for an inline thread, e.g. '/src/App.cs'")] string? filePath = null, + [Description("Optional line number for an inline thread on the right/new file")] int? lineNumber = null, + [Description("Optional ending line number for an inline thread range on the right/new file")] int? endLineNumber = null, + [Description("The project name (optional if default project is configured)")] string? project = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(repositoryNameOrId)) + { + return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); + } + + if (pullRequestId <= 0) + { + return JsonSerializer.Serialize(new { error = "Pull request ID must be a positive integer" }, JsonOptions); + } + + if (string.IsNullOrWhiteSpace(content)) + { + return JsonSerializer.Serialize(new { error = "Comment content cannot be empty" }, JsonOptions); + } + + if (!string.IsNullOrWhiteSpace(filePath) && (!lineNumber.HasValue || lineNumber <= 0)) + { + return JsonSerializer.Serialize(new { error = "Line number must be a positive integer when filePath is provided" }, JsonOptions); + } + + if (lineNumber.HasValue && string.IsNullOrWhiteSpace(filePath)) + { + return JsonSerializer.Serialize(new { error = "File path is required when lineNumber is provided" }, JsonOptions); + } + + if (endLineNumber.HasValue && (!lineNumber.HasValue || endLineNumber < lineNumber)) + { + return JsonSerializer.Serialize(new { error = "End line number must be greater than or equal to lineNumber" }, JsonOptions); + } + + var thread = await _azureDevOpsService.CreatePullRequestThreadAsync( + repositoryNameOrId, pullRequestId, content, + filePath, lineNumber, endLineNumber, project, cancellationToken); + + return JsonSerializer.Serialize(new + { + success = true, + message = $"Thread {thread.Id} created on pull request {pullRequestId}", + thread + }, JsonOptions); + } + [McpServerTool(Name = "add_pull_request_thread_comment")] [Description("Adds a comment to an existing comment thread on a pull request. Use this to reply to discussions returned by get_pull_request_threads. Pass parentCommentId to reply to a specific comment within the thread; omit it to add a top-level comment.")] public async Task AddPullRequestThreadComment( From 84cda90e05d228761ce3beb6a1d77b0c19f8f2ff Mon Sep 17 00:00:00 2001 From: "daniel.junior" Date: Thu, 21 May 2026 15:37:59 -0300 Subject: [PATCH 2/2] test: cover pull request thread creation tool --- README.md | 5 +- .../Models/PullRequestThreadDtoTests.cs | 8 +- .../Tools/PullRequestToolsTests.cs | 161 ++++++++++++++++++ 3 files changed, 172 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4a2eb67..8759c25 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ This project implements an MCP server that exposes tools for querying and managi | `get_pull_request` | Gets details of a specific pull request by ID within a repository | | `get_pull_request_by_id` | Gets details of a pull request by ID only, searching across all repositories in the project | | `get_pull_request_threads` | Gets comment threads for a pull request | +| `create_pull_request_thread` | Creates a new comment thread on a pull request, either as a general discussion or inline file comment | | `search_pull_requests` | Searches pull requests by text in title or description | | `query_pull_requests` | Advanced query with multiple combined filters | | `create_pull_request` | Creates a new pull request with title, description, source/target branches, draft flag, reviewers, and linked work items | @@ -386,6 +387,8 @@ After configuring the MCP client, you can ask questions like: - "Get details of pull request #123" - "Find pull request #456 anywhere in the project" - "What comments are on PR #456?" +- "Create a new comment thread on PR #456 saying 'Please review the validation logic'" +- "Add an inline PR comment on /src/App.cs line 42" - "Search for pull requests related to 'authentication'" - "Show me PRs targeting the 'main' branch" - "List PRs created by user@email.com" @@ -565,7 +568,7 @@ Complete pull request details including: Reviewer information including DisplayName, Vote (-10 to 10), IsRequired, and HasDeclined status. #### PullRequestThreadDto -Comment thread with Id, Status (Active, Fixed, etc.), FilePath, LineNumber, and Comments list. +Comment thread with Id, Status (Active, Fixed, etc.), FilePath, LineNumber, EndLineNumber, and Comments list. #### PullRequestCommentDto Individual comment with Id, Content, Author, timestamps, and CommentType. diff --git a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/PullRequestThreadDtoTests.cs b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/PullRequestThreadDtoTests.cs index 846e7a3..a04a641 100644 --- a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/PullRequestThreadDtoTests.cs +++ b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/PullRequestThreadDtoTests.cs @@ -20,6 +20,7 @@ public void PullRequestThreadDto_ShouldSerializeToJson() Status = "Active", FilePath = "/src/Program.cs", LineNumber = 42, + EndLineNumber = 45, PublishedDate = new DateTime(2024, 1, 15, 10, 30, 0), LastUpdatedDate = new DateTime(2024, 1, 16, 14, 0, 0) }; @@ -30,6 +31,7 @@ public void PullRequestThreadDto_ShouldSerializeToJson() Assert.Contains("\"status\":\"Active\"", json); Assert.Contains("\"filePath\":\"/src/Program.cs\"", json); Assert.Contains("\"lineNumber\":42", json); + Assert.Contains("\"endLineNumber\":45", json); } [Fact] @@ -40,7 +42,8 @@ public void PullRequestThreadDto_ShouldDeserializeFromJson() "id": 456, "status": "Fixed", "filePath": "/tests/Test.cs", - "lineNumber": 100 + "lineNumber": 100, + "endLineNumber": 105 } """; @@ -51,6 +54,7 @@ public void PullRequestThreadDto_ShouldDeserializeFromJson() Assert.Equal("Fixed", dto.Status); Assert.Equal("/tests/Test.cs", dto.FilePath); Assert.Equal(100, dto.LineNumber); + Assert.Equal(105, dto.EndLineNumber); } [Fact] @@ -104,12 +108,14 @@ public void PullRequestThreadDto_NullableProperties_ShouldAllowNull() Status = null, FilePath = null, LineNumber = null, + EndLineNumber = null, Comments = null }; Assert.Null(dto.Status); Assert.Null(dto.FilePath); Assert.Null(dto.LineNumber); + Assert.Null(dto.EndLineNumber); Assert.Null(dto.Comments); } } diff --git a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/PullRequestToolsTests.cs b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/PullRequestToolsTests.cs index 870c230..4d4970d 100644 --- a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/PullRequestToolsTests.cs +++ b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/PullRequestToolsTests.cs @@ -322,6 +322,167 @@ public async Task GetPullRequestThreads_WhenEmpty_ShouldReturnEmptyList() #endregion + #region CreatePullRequestThread Tests + + [Fact] + public async Task CreatePullRequestThread_ShouldCreateGeneralThread() + { + var thread = new PullRequestThreadDto + { + Id = 10, + Status = "Active", + Comments = new List + { + new() { Id = 1, Content = "Please review this change" } + } + }; + + _mockService + .Setup(s => s.CreatePullRequestThreadAsync( + "repo", + 123, + "Please review this change", + null, + null, + null, + null, + It.IsAny())) + .ReturnsAsync(thread); + + var result = await _tools.CreatePullRequestThread("repo", 123, "Please review this change"); + + Assert.Contains("\"success\": true", result); + Assert.Contains("Thread 10 created on pull request 123", result); + Assert.Contains("\"id\": 10", result); + _mockService.Verify(s => s.CreatePullRequestThreadAsync( + "repo", + 123, + "Please review this change", + null, + null, + null, + null, + It.IsAny()), Times.Once); + } + + [Fact] + public async Task CreatePullRequestThread_WithInlineRange_ShouldPassContextToService() + { + var thread = new PullRequestThreadDto + { + Id = 11, + Status = "Active", + FilePath = "/src/Program.cs", + LineNumber = 42, + EndLineNumber = 45 + }; + + _mockService + .Setup(s => s.CreatePullRequestThreadAsync( + "repo", + 123, + "Inline comment", + "/src/Program.cs", + 42, + 45, + "MyProject", + It.IsAny())) + .ReturnsAsync(thread); + + var result = await _tools.CreatePullRequestThread( + "repo", + 123, + "Inline comment", + "/src/Program.cs", + 42, + 45, + "MyProject"); + + Assert.Contains("\"success\": true", result); + Assert.Contains("\"filePath\": \"/src/Program.cs\"", result); + Assert.Contains("\"endLineNumber\": 45", result); + _mockService.Verify(s => s.CreatePullRequestThreadAsync( + "repo", + 123, + "Inline comment", + "/src/Program.cs", + 42, + 45, + "MyProject", + It.IsAny()), Times.Once); + } + + [Fact] + public async Task CreatePullRequestThread_WithEmptyRepoName_ShouldReturnError() + { + var result = await _tools.CreatePullRequestThread("", 123, "Comment"); + + Assert.Contains("error", result); + Assert.Contains("Repository name or ID is required", result); + _mockService.Verify(s => s.CreatePullRequestThreadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task CreatePullRequestThread_WithInvalidPRId_ShouldReturnError() + { + var result = await _tools.CreatePullRequestThread("repo", 0, "Comment"); + + Assert.Contains("error", result); + Assert.Contains("Pull request ID must be a positive integer", result); + } + + [Fact] + public async Task CreatePullRequestThread_WithEmptyContent_ShouldReturnError() + { + var result = await _tools.CreatePullRequestThread("repo", 123, ""); + + Assert.Contains("error", result); + Assert.Contains("Comment content cannot be empty", result); + } + + [Fact] + public async Task CreatePullRequestThread_WithFilePathWithoutLineNumber_ShouldReturnError() + { + var result = await _tools.CreatePullRequestThread("repo", 123, "Comment", "/src/Program.cs"); + + Assert.Contains("error", result); + Assert.Contains("Line number must be a positive integer when filePath is provided", result); + } + + [Fact] + public async Task CreatePullRequestThread_WithLineNumberWithoutFilePath_ShouldReturnError() + { + var result = await _tools.CreatePullRequestThread("repo", 123, "Comment", lineNumber: 42); + + Assert.Contains("error", result); + Assert.Contains("File path is required when lineNumber is provided", result); + } + + [Fact] + public async Task CreatePullRequestThread_WithEndLineBeforeStartLine_ShouldReturnError() + { + var result = await _tools.CreatePullRequestThread( + "repo", + 123, + "Comment", + "/src/Program.cs", + 42, + 41); + + Assert.Contains("error", result); + Assert.Contains("End line number must be greater than or equal to lineNumber", result); + } + + #endregion + #region SearchPullRequests Tests [Fact]