Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1177,6 +1177,72 @@ public async Task<IReadOnlyList<PullRequestDto>> SearchPullRequestsAsync(
}
}

public async Task<PullRequestDto> CreatePullRequestAsync(
string repositoryNameOrId,
string sourceRefName,
string targetRefName,
string title,
string? description = null,
bool isDraft = false,
string? project = null,
IEnumerable<string>? reviewerIds = null,
IEnumerable<int>? 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))
Expand Down
26 changes: 26 additions & 0 deletions src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,32 @@ Task<IReadOnlyList<PullRequestDto>> SearchPullRequestsAsync(
int top = 50,
CancellationToken cancellationToken = default);

/// <summary>
/// Creates a new pull request in the specified repository.
/// </summary>
/// <param name="repositoryNameOrId">The repository name or ID.</param>
/// <param name="sourceRefName">The source branch (e.g., refs/heads/feature-branch).</param>
/// <param name="targetRefName">The target branch (e.g., refs/heads/main).</param>
/// <param name="title">The pull request title.</param>
/// <param name="description">Optional pull request description.</param>
/// <param name="isDraft">Whether to create the PR as a draft.</param>
/// <param name="project">The project name (optional if default project is configured).</param>
/// <param name="reviewerIds">Optional reviewer GUIDs.</param>
/// <param name="workItemIds">Optional work item IDs to link.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created pull request.</returns>
Task<PullRequestDto> CreatePullRequestAsync(
string repositoryNameOrId,
string sourceRefName,
string targetRefName,
string title,
string? description = null,
bool isDraft = false,
string? project = null,
IEnumerable<string>? reviewerIds = null,
IEnumerable<int>? workItemIds = null,
CancellationToken cancellationToken = default);

#endregion

#region Pipeline/Build Operations
Expand Down
57 changes: 57 additions & 0 deletions src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,63 @@ public async Task<string> 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<string> 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<string> QueryPullRequests(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CancellationToken>()))
.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<IEnumerable<string>>(r => r.Count() == 2),
It.Is<IEnumerable<int>>(w => w.Count() == 2),
It.IsAny<CancellationToken>()))
.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<IEnumerable<int>>(w => w.Count() == 1 && w.First() == 123),
It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()), Times.Once);
}

#endregion

#region QueryPullRequests Tests

[Fact]
Expand Down