diff --git a/README.md b/README.md index 8301246..c7fd7a7 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ This project implements an MCP server that exposes tools for querying and managi | `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 | +| `update_pull_request` | Updates an existing pull request title, description, target branch, status, or draft flag | ### Pipeline/Build Tools @@ -398,6 +399,8 @@ After configuring the MCP client, you can ask questions like: - "Create a pull request from 'feature/login' to 'main' titled 'Add login page'" - "Open a draft PR from my branch to main with a description of the changes" - "Create a PR and link it to work items #123 and #456" +- "Update PR #456 to change the title and mark it ready for review" +- "Abandon PR #456 in the 'my-repo' repository" ### Pipelines and Builds diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs index b936f69..796e2b5 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs @@ -1757,6 +1757,74 @@ public async Task CreatePullRequestAsync( } } + public async Task UpdatePullRequestAsync( + string repositoryNameOrId, + int pullRequestId, + string? title = null, + string? description = null, + string? targetRefName = null, + string? status = null, + bool? isDraft = null, + string? project = null, + CancellationToken cancellationToken = default) + { + try + { + var projectName = project ?? _options.DefaultProject; + _logger.LogDebug( + "Updating pull request {PullRequestId} in repository {Repository}", + pullRequestId, + repositoryNameOrId); + + var pullRequestUpdate = new GitPullRequest(); + + if (title is not null) + { + pullRequestUpdate.Title = title; + } + + if (description is not null) + { + pullRequestUpdate.Description = description; + } + + if (targetRefName is not null) + { + pullRequestUpdate.TargetRefName = targetRefName; + } + + if (status is not null) + { + pullRequestUpdate.Status = ParsePullRequestUpdateStatus(status) + ?? throw new ArgumentException($"Unsupported pull request status '{status}'", nameof(status)); + } + + if (isDraft.HasValue) + { + pullRequestUpdate.IsDraft = isDraft.Value; + } + + var result = await _gitClient.UpdatePullRequestAsync( + gitPullRequestToUpdate: pullRequestUpdate, + project: projectName, + repositoryId: repositoryNameOrId, + pullRequestId: pullRequestId, + userState: null, + cancellationToken: cancellationToken); + + return MapToPullRequestDto(result); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error updating pull request {PullRequestId} in repository {Repository}", + pullRequestId, + repositoryNameOrId); + throw; + } + } + private static PullRequestStatus? ParsePullRequestStatus(string? status) { if (string.IsNullOrEmpty(status)) @@ -1772,6 +1840,20 @@ public async Task CreatePullRequestAsync( }; } + private static PullRequestStatus? ParsePullRequestUpdateStatus(string? status) + { + if (string.IsNullOrWhiteSpace(status)) + return null; + + return status.Trim().ToLowerInvariant().Replace("_", string.Empty).Replace("-", string.Empty) switch + { + "active" or "open" or "reopen" or "reactivate" or "reactivated" => PullRequestStatus.Active, + "abandoned" or "abandon" => PullRequestStatus.Abandoned, + "completed" or "complete" or "merge" or "merged" => PullRequestStatus.Completed, + _ => null + }; + } + private static PullRequestDto MapToPullRequestDto(GitPullRequest pr) { return new PullRequestDto diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs index 3d2e792..ca2bee4 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs @@ -441,6 +441,30 @@ Task CreatePullRequestAsync( IEnumerable? workItemIds = null, CancellationToken cancellationToken = default); + /// + /// Updates an existing pull request in the specified repository. + /// + /// The repository name or ID. + /// The pull request ID. + /// Optional new pull request title. + /// Optional new pull request description. + /// Optional new target branch (e.g., refs/heads/main). + /// Optional new status: active, abandoned, or completed. + /// Optional draft flag. + /// The project name (optional if default project is configured). + /// Cancellation token. + /// The updated pull request. + Task UpdatePullRequestAsync( + string repositoryNameOrId, + int pullRequestId, + string? title = null, + string? description = null, + string? targetRefName = null, + string? status = null, + bool? isDraft = null, + string? project = null, + CancellationToken cancellationToken = default); + #endregion #region Pipeline/Build Operations diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs b/src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs index 45d0fa3..19f353a 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs @@ -385,6 +385,76 @@ public async Task CreatePullRequest( }, JsonOptions); } + [McpServerTool(Name = "update_pull_request")] + [Description("Updates an existing pull request. Only specified fields are changed; omitted fields remain unchanged. Supports title, description, target branch, status, and draft flag.")] + public async Task UpdatePullRequest( + [Description("The repository name or ID")] string repositoryNameOrId, + [Description("The pull request ID")] int pullRequestId, + [Description("New pull request title")] string? title = null, + [Description("New pull request description")] string? description = null, + [Description("New target branch (e.g., 'refs/heads/main')")] string? targetRefName = null, + [Description("New status: Active, Abandoned, or Completed. Aliases include open, reopen, abandon, complete, and merge.")] string? status = null, + [Description("New draft flag. Use true to mark as draft, false to mark ready for review.")] bool? isDraft = 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 (title is not null && string.IsNullOrWhiteSpace(title)) + { + return JsonSerializer.Serialize(new { error = "Title cannot be empty" }, JsonOptions); + } + + if (targetRefName is not null && string.IsNullOrWhiteSpace(targetRefName)) + { + return JsonSerializer.Serialize(new { error = "Target branch cannot be empty" }, JsonOptions); + } + + var normalizedStatus = status is null ? null : NormalizePullRequestStatus(status); + if (status is not null && normalizedStatus is null) + { + return JsonSerializer.Serialize(new + { + error = "Unsupported pull request status. Supported statuses are Active, Abandoned, and Completed. Aliases include open, reopen, abandon, complete, and merge." + }, JsonOptions); + } + + if (title is null && + description is null && + targetRefName is null && + normalizedStatus is null && + isDraft is null) + { + return JsonSerializer.Serialize(new { error = "At least one pull request field must be provided" }, JsonOptions); + } + + var pullRequest = await _azureDevOpsService.UpdatePullRequestAsync( + repositoryNameOrId, + pullRequestId, + title, + description, + targetRefName, + normalizedStatus, + isDraft, + project, + cancellationToken); + + return JsonSerializer.Serialize(new + { + success = true, + message = $"Pull request {pullRequest.PullRequestId} updated successfully", + pullRequest + }, JsonOptions); + } + [McpServerTool(Name = "query_pull_requests")] [Description("Advanced query for pull requests with multiple combined filters. Allows filtering by status, branches, dates, creator, and reviewer simultaneously.")] public async Task QueryPullRequests( @@ -424,4 +494,18 @@ public async Task QueryPullRequests( pullRequests }, JsonOptions); } + + private static string? NormalizePullRequestStatus(string? status) + { + if (string.IsNullOrWhiteSpace(status)) + return null; + + return status.Trim().ToLowerInvariant().Replace("_", string.Empty).Replace("-", string.Empty) switch + { + "active" or "open" or "reopen" or "reactivate" or "reactivated" => "Active", + "abandoned" or "abandon" => "Abandoned", + "completed" or "complete" or "merge" or "merged" => "Completed", + _ => null + }; + } } 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 1f25ec4..d1b4f69 100644 --- a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/PullRequestToolsTests.cs +++ b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/PullRequestToolsTests.cs @@ -812,6 +812,185 @@ public async Task CreatePullRequest_AsDraft_ShouldPassIsDraftTrue() #endregion + #region UpdatePullRequest Tests + + [Fact] + public async Task UpdatePullRequest_WithTitleAndDraftFlag_ShouldReturnSuccess() + { + var pullRequest = new PullRequestDto + { + PullRequestId = 123, + Title = "Updated PR", + Status = "Active", + IsDraft = false + }; + + _mockService + .Setup(s => s.UpdatePullRequestAsync( + "repo", + 123, + "Updated PR", + null, + null, + null, + false, + null, + It.IsAny())) + .ReturnsAsync(pullRequest); + + var result = await _tools.UpdatePullRequest("repo", 123, title: "Updated PR", isDraft: false); + + Assert.Contains("\"success\": true", result); + Assert.Contains("Pull request 123 updated successfully", result); + Assert.Contains("\"title\": \"Updated PR\"", result); + _mockService.Verify(s => s.UpdatePullRequestAsync( + "repo", + 123, + "Updated PR", + null, + null, + null, + false, + null, + It.IsAny()), Times.Once); + } + + [Fact] + public async Task UpdatePullRequest_WithAllFields_ShouldNormalizeStatusAndPassAllToService() + { + var pullRequest = new PullRequestDto + { + PullRequestId = 124, + Title = "Retargeted PR", + Description = "Updated description", + TargetBranch = "refs/heads/release", + Status = "Abandoned", + IsDraft = true + }; + + _mockService + .Setup(s => s.UpdatePullRequestAsync( + "repo", + 124, + "Retargeted PR", + "Updated description", + "refs/heads/release", + "Abandoned", + true, + "MyProject", + It.IsAny())) + .ReturnsAsync(pullRequest); + + var result = await _tools.UpdatePullRequest( + "repo", + 124, + title: "Retargeted PR", + description: "Updated description", + targetRefName: "refs/heads/release", + status: "abandon", + isDraft: true, + project: "MyProject"); + + Assert.Contains("\"success\": true", result); + Assert.Contains("\"status\": \"Abandoned\"", result); + _mockService.Verify(s => s.UpdatePullRequestAsync( + "repo", + 124, + "Retargeted PR", + "Updated description", + "refs/heads/release", + "Abandoned", + true, + "MyProject", + It.IsAny()), Times.Once); + } + + [Fact] + public async Task UpdatePullRequest_WithEmptyRepoName_ShouldReturnError() + { + var result = await _tools.UpdatePullRequest("", 123, title: "Title"); + + Assert.Contains("error", result); + Assert.Contains("Repository name or ID is required", result); + _mockService.Verify(s => s.UpdatePullRequestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdatePullRequest_WithInvalidPRId_ShouldReturnError() + { + var result = await _tools.UpdatePullRequest("repo", 0, title: "Title"); + + Assert.Contains("error", result); + Assert.Contains("Pull request ID must be a positive integer", result); + } + + [Fact] + public async Task UpdatePullRequest_WithNoFields_ShouldReturnError() + { + var result = await _tools.UpdatePullRequest("repo", 123); + + Assert.Contains("error", result); + Assert.Contains("At least one pull request field must be provided", result); + _mockService.Verify(s => s.UpdatePullRequestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdatePullRequest_WithEmptyTitle_ShouldReturnError() + { + var result = await _tools.UpdatePullRequest("repo", 123, title: ""); + + Assert.Contains("error", result); + Assert.Contains("Title cannot be empty", result); + } + + [Fact] + public async Task UpdatePullRequest_WithEmptyTargetRef_ShouldReturnError() + { + var result = await _tools.UpdatePullRequest("repo", 123, targetRefName: ""); + + Assert.Contains("error", result); + Assert.Contains("Target branch cannot be empty", result); + } + + [Fact] + public async Task UpdatePullRequest_WithUnsupportedStatus_ShouldReturnError() + { + var result = await _tools.UpdatePullRequest("repo", 123, status: "all"); + + Assert.Contains("error", result); + Assert.Contains("Unsupported pull request status", result); + _mockService.Verify(s => s.UpdatePullRequestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + #endregion + #region QueryPullRequests Tests [Fact]