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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PullRequestCommentDto>? Comments { get; init; }
Expand Down
72 changes: 72 additions & 0 deletions src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1411,6 +1411,77 @@ public async Task<IReadOnlyList<PullRequestThreadDto>> GetPullRequestThreadsAsyn
}
}

public async Task<PullRequestThreadDto> 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<PullRequestCommentDto> AddPullRequestThreadCommentAsync(
string repositoryNameOrId,
int pullRequestId,
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,28 @@ Task<IReadOnlyList<PullRequestThreadDto>> GetPullRequestThreadsAsync(
string? project = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Creates a new comment thread on a pull request.
/// </summary>
/// <param name="repositoryNameOrId">The repository name or ID.</param>
/// <param name="pullRequestId">The pull request ID.</param>
/// <param name="content">The initial comment text (Markdown supported).</param>
/// <param name="filePath">Optional file path for an inline thread.</param>
/// <param name="lineNumber">Optional line number for an inline thread.</param>
/// <param name="endLineNumber">Optional ending line number for an inline thread range.</param>
/// <param name="project">The project name (optional if default project is configured).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created comment thread.</returns>
Task<PullRequestThreadDto> CreatePullRequestThreadAsync(
string repositoryNameOrId,
int pullRequestId,
string content,
string? filePath = null,
int? lineNumber = null,
int? endLineNumber = null,
string? project = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Adds a comment to an existing comment thread on a pull request.
/// </summary>
Expand Down
54 changes: 54 additions & 0 deletions src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,60 @@ public async Task<string> 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<string> 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<string> AddPullRequestThreadComment(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};
Expand All @@ -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]
Expand All @@ -40,7 +42,8 @@ public void PullRequestThreadDto_ShouldDeserializeFromJson()
"id": 456,
"status": "Fixed",
"filePath": "/tests/Test.cs",
"lineNumber": 100
"lineNumber": 100,
"endLineNumber": 105
}
""";

Expand All @@ -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]
Expand Down Expand Up @@ -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);
}
}
Loading
Loading