diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs index 47f1989..c13ef83 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs @@ -1177,6 +1177,72 @@ public async Task> SearchPullRequestsAsync( } } + public async Task CreatePullRequestAsync( + string repositoryNameOrId, + string sourceRefName, + string targetRefName, + string title, + string? description = null, + bool isDraft = false, + string? project = null, + IEnumerable? reviewerIds = null, + IEnumerable? workItemIds = null, + CancellationToken cancellationToken = default) + { + try + { + var projectName = project ?? _options.DefaultProject; + _logger.LogDebug("Creating pull request in repository {Repository}", repositoryNameOrId); + + var gitPullRequest = new GitPullRequest + { + Title = title, + Description = description, + SourceRefName = sourceRefName, + TargetRefName = targetRefName, + IsDraft = isDraft + }; + + if (reviewerIds != null) + { + var reviewers = reviewerIds + .Where(id => Guid.TryParse(id, out _)) + .Select(id => new IdentityRefWithVote { Id = id }) + .ToList(); + + if (reviewers.Count > 0) + { + gitPullRequest.Reviewers = reviewers.ToArray(); + } + } + + if (workItemIds != null) + { + var workItemRefs = workItemIds + .Select(id => new ResourceRef { Id = id.ToString(), Url = $"{_options.OrganizationUrl}/_apis/wit/workItems/{id}" }) + .ToList(); + + if (workItemRefs.Count > 0) + { + gitPullRequest.WorkItemRefs = workItemRefs.ToArray(); + } + } + + var result = await _gitClient.CreatePullRequestAsync( + gitPullRequestToCreate: gitPullRequest, + repositoryId: repositoryNameOrId, + project: projectName, + cancellationToken: cancellationToken); + + return MapToPullRequestDto(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating pull request in repository {Repository}", repositoryNameOrId); + throw; + } + } + private static PullRequestStatus? ParsePullRequestStatus(string? status) { if (string.IsNullOrEmpty(status)) diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs index dbf73dd..2ef9353 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs @@ -287,6 +287,32 @@ Task> SearchPullRequestsAsync( int top = 50, CancellationToken cancellationToken = default); + /// + /// Creates a new pull request in the specified repository. + /// + /// The repository name or ID. + /// The source branch (e.g., refs/heads/feature-branch). + /// The target branch (e.g., refs/heads/main). + /// The pull request title. + /// Optional pull request description. + /// Whether to create the PR as a draft. + /// The project name (optional if default project is configured). + /// Optional reviewer GUIDs. + /// Optional work item IDs to link. + /// Cancellation token. + /// The created pull request. + Task CreatePullRequestAsync( + string repositoryNameOrId, + string sourceRefName, + string targetRefName, + string title, + string? description = null, + bool isDraft = false, + string? project = null, + IEnumerable? reviewerIds = null, + IEnumerable? workItemIds = 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 9f96069..c773294 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs @@ -168,6 +168,63 @@ public async Task SearchPullRequests( }, JsonOptions); } + [McpServerTool(Name = "create_pull_request")] + [Description("Creates a new pull request in a Git repository. Supports setting title, description, source/target branches, draft status, reviewers, and linked work items.")] + public async Task CreatePullRequest( + [Description("The repository name or ID")] string repositoryNameOrId, + [Description("The source branch (e.g., 'refs/heads/feature-branch')")] string sourceRefName, + [Description("The target branch (e.g., 'refs/heads/main')")] string targetRefName, + [Description("The pull request title")] string title, + [Description("The pull request description")] string? description = null, + [Description("Whether to create as a draft pull request (default: false)")] bool isDraft = false, + [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("Semicolon-separated reviewer GUIDs (e.g., 'guid1;guid2')")] string? reviewerIds = null, + [Description("Semicolon-separated work item IDs to link (e.g., '123;456')")] string? workItemIds = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(repositoryNameOrId)) + { + return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions); + } + + if (string.IsNullOrWhiteSpace(sourceRefName)) + { + return JsonSerializer.Serialize(new { error = "Source branch is required" }, JsonOptions); + } + + if (string.IsNullOrWhiteSpace(targetRefName)) + { + return JsonSerializer.Serialize(new { error = "Target branch is required" }, JsonOptions); + } + + if (string.IsNullOrWhiteSpace(title)) + { + return JsonSerializer.Serialize(new { error = "Title is required" }, JsonOptions); + } + + var parsedReviewerIds = string.IsNullOrWhiteSpace(reviewerIds) + ? null + : reviewerIds.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var parsedWorkItemIds = string.IsNullOrWhiteSpace(workItemIds) + ? null + : workItemIds.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(id => int.TryParse(id, out _)) + .Select(int.Parse); + + var pullRequest = await _azureDevOpsService.CreatePullRequestAsync( + repositoryNameOrId, sourceRefName, targetRefName, title, + description, isDraft, project, parsedReviewerIds, parsedWorkItemIds, + cancellationToken); + + return JsonSerializer.Serialize(new + { + success = true, + message = $"Pull request {pullRequest.PullRequestId} created 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( 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 a6cbb93..870c230 100644 --- a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/PullRequestToolsTests.cs +++ b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/PullRequestToolsTests.cs @@ -378,6 +378,162 @@ public async Task SearchPullRequests_WithStatus_ShouldPassStatusToService() #endregion + #region CreatePullRequest Tests + + [Fact] + public async Task CreatePullRequest_WithRequiredFieldsOnly_ShouldReturnSuccess() + { + var pullRequest = new PullRequestDto + { + PullRequestId = 42, + Title = "Add new feature", + SourceBranch = "refs/heads/feature", + TargetBranch = "refs/heads/main", + Status = "Active" + }; + + _mockService + .Setup(s => s.CreatePullRequestAsync( + "my-repo", "refs/heads/feature", "refs/heads/main", "Add new feature", + null, false, null, null, null, + It.IsAny())) + .ReturnsAsync(pullRequest); + + var result = await _tools.CreatePullRequest("my-repo", "refs/heads/feature", "refs/heads/main", "Add new feature"); + + Assert.Contains("\"success\": true", result); + Assert.Contains("Pull request 42 created successfully", result); + Assert.Contains("\"pullRequestId\": 42", result); + } + + [Fact] + public async Task CreatePullRequest_WithAllFields_ShouldPassAllToService() + { + var pullRequest = new PullRequestDto + { + PullRequestId = 43, + Title = "Full PR", + SourceBranch = "refs/heads/feature", + TargetBranch = "refs/heads/main", + Status = "Active", + IsDraft = true + }; + + _mockService + .Setup(s => s.CreatePullRequestAsync( + "my-repo", "refs/heads/feature", "refs/heads/main", "Full PR", + "A description", true, "MyProject", + It.Is>(r => r.Count() == 2), + It.Is>(w => w.Count() == 2), + It.IsAny())) + .ReturnsAsync(pullRequest); + + var result = await _tools.CreatePullRequest( + "my-repo", "refs/heads/feature", "refs/heads/main", "Full PR", + description: "A description", + isDraft: true, + project: "MyProject", + reviewerIds: "guid1;guid2", + workItemIds: "123;456"); + + Assert.Contains("\"success\": true", result); + Assert.Contains("\"isDraft\": true", result); + } + + [Fact] + public async Task CreatePullRequest_WithEmptyRepoName_ShouldReturnError() + { + var result = await _tools.CreatePullRequest("", "refs/heads/feature", "refs/heads/main", "Title"); + + Assert.Contains("error", result); + Assert.Contains("Repository name or ID is required", result); + } + + [Fact] + public async Task CreatePullRequest_WithEmptySourceRef_ShouldReturnError() + { + var result = await _tools.CreatePullRequest("repo", "", "refs/heads/main", "Title"); + + Assert.Contains("error", result); + Assert.Contains("Source branch is required", result); + } + + [Fact] + public async Task CreatePullRequest_WithEmptyTargetRef_ShouldReturnError() + { + var result = await _tools.CreatePullRequest("repo", "refs/heads/feature", "", "Title"); + + Assert.Contains("error", result); + Assert.Contains("Target branch is required", result); + } + + [Fact] + public async Task CreatePullRequest_WithEmptyTitle_ShouldReturnError() + { + var result = await _tools.CreatePullRequest("repo", "refs/heads/feature", "refs/heads/main", ""); + + Assert.Contains("error", result); + Assert.Contains("Title is required", result); + } + + [Fact] + public async Task CreatePullRequest_WithInvalidWorkItemIds_ShouldIgnoreInvalidOnes() + { + var pullRequest = new PullRequestDto + { + PullRequestId = 44, + Title = "PR with work items", + SourceBranch = "refs/heads/feature", + TargetBranch = "refs/heads/main", + Status = "Active" + }; + + _mockService + .Setup(s => s.CreatePullRequestAsync( + "repo", "refs/heads/feature", "refs/heads/main", "PR with work items", + null, false, null, null, + It.Is>(w => w.Count() == 1 && w.First() == 123), + It.IsAny())) + .ReturnsAsync(pullRequest); + + var result = await _tools.CreatePullRequest( + "repo", "refs/heads/feature", "refs/heads/main", "PR with work items", + workItemIds: "123;abc;xyz"); + + Assert.Contains("\"success\": true", result); + } + + [Fact] + public async Task CreatePullRequest_AsDraft_ShouldPassIsDraftTrue() + { + var pullRequest = new PullRequestDto + { + PullRequestId = 45, + Title = "Draft PR", + IsDraft = true, + Status = "Active" + }; + + _mockService + .Setup(s => s.CreatePullRequestAsync( + "repo", "refs/heads/feature", "refs/heads/main", "Draft PR", + null, true, null, null, null, + It.IsAny())) + .ReturnsAsync(pullRequest); + + var result = await _tools.CreatePullRequest( + "repo", "refs/heads/feature", "refs/heads/main", "Draft PR", + isDraft: true); + + Assert.Contains("\"success\": true", result); + _mockService.Verify(s => s.CreatePullRequestAsync( + "repo", "refs/heads/feature", "refs/heads/main", "Draft PR", + null, true, null, null, null, + It.IsAny()), Times.Once); + } + + #endregion + #region QueryPullRequests Tests [Fact]