Skip to content

Commit f95ad6b

Browse files
authored
Merge pull request #14 from viamus/codex/update-pr-thread-status
Add pull request thread status update tool
2 parents 2cd251a + 19bfb21 commit f95ad6b

5 files changed

Lines changed: 264 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ This project implements an MCP server that exposes tools for querying and managi
150150
| `get_pull_request_by_id` | Gets details of a pull request by ID only, searching across all repositories in the project |
151151
| `get_pull_request_threads` | Gets comment threads for a pull request |
152152
| `create_pull_request_thread` | Creates a new comment thread on a pull request, either as a general discussion or inline file comment |
153+
| `update_pull_request_thread_status` | Updates a pull request comment thread status, including close/resolve aliases |
153154
| `search_pull_requests` | Searches pull requests by text in title or description |
154155
| `query_pull_requests` | Advanced query with multiple combined filters |
155156
| `create_pull_request` | Creates a new pull request with title, description, source/target branches, draft flag, reviewers, and linked work items |

src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,6 +1598,54 @@ public async Task<PullRequestCommentDto> AddPullRequestThreadCommentAsync(
15981598
}
15991599
}
16001600

1601+
public async Task<PullRequestThreadDto> UpdatePullRequestThreadStatusAsync(
1602+
string repositoryNameOrId,
1603+
int pullRequestId,
1604+
int threadId,
1605+
string status,
1606+
string? project = null,
1607+
CancellationToken cancellationToken = default)
1608+
{
1609+
try
1610+
{
1611+
var projectName = project ?? _options.DefaultProject;
1612+
var threadStatus = ParseCommentThreadStatus(status)
1613+
?? throw new ArgumentException($"Unsupported pull request thread status '{status}'", nameof(status));
1614+
1615+
_logger.LogDebug(
1616+
"Updating thread {ThreadId} on pull request {PullRequestId} to status {Status}",
1617+
threadId,
1618+
pullRequestId,
1619+
threadStatus);
1620+
1621+
var thread = new GitPullRequestCommentThread
1622+
{
1623+
Status = threadStatus
1624+
};
1625+
1626+
var updated = await _gitClient.UpdateThreadAsync(
1627+
commentThread: thread,
1628+
project: projectName,
1629+
repositoryId: repositoryNameOrId,
1630+
pullRequestId: pullRequestId,
1631+
threadId: threadId,
1632+
userState: null,
1633+
cancellationToken: cancellationToken);
1634+
1635+
return MapToPullRequestThreadDto(updated);
1636+
}
1637+
catch (Exception ex)
1638+
{
1639+
_logger.LogError(
1640+
ex,
1641+
"Error updating thread {ThreadId} on pull request {PullRequestId} to status {Status}",
1642+
threadId,
1643+
pullRequestId,
1644+
status);
1645+
throw;
1646+
}
1647+
}
1648+
16011649
public async Task<IReadOnlyList<PullRequestDto>> SearchPullRequestsAsync(
16021650
string repositoryNameOrId,
16031651
string searchText,
@@ -1756,6 +1804,23 @@ private static PullRequestDto MapToPullRequestDto(GitPullRequest pr)
17561804
};
17571805
}
17581806

1807+
private static CommentThreadStatus? ParseCommentThreadStatus(string? status)
1808+
{
1809+
if (string.IsNullOrWhiteSpace(status))
1810+
return null;
1811+
1812+
return status.Trim().ToLowerInvariant().Replace("_", string.Empty).Replace("-", string.Empty) switch
1813+
{
1814+
"active" or "open" or "reopen" or "reopened" => CommentThreadStatus.Active,
1815+
"fixed" or "fix" or "resolve" or "resolved" => CommentThreadStatus.Fixed,
1816+
"wontfix" or "wont" => CommentThreadStatus.WontFix,
1817+
"closed" or "close" => CommentThreadStatus.Closed,
1818+
"bydesign" => CommentThreadStatus.ByDesign,
1819+
"pending" => CommentThreadStatus.Pending,
1820+
_ => null
1821+
};
1822+
}
1823+
17591824
private static PullRequestThreadDto MapToPullRequestThreadDto(GitPullRequestCommentThread thread)
17601825
{
17611826
return new PullRequestThreadDto

src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,24 @@ Task<PullRequestCommentDto> AddPullRequestThreadCommentAsync(
379379
string? project = null,
380380
CancellationToken cancellationToken = default);
381381

382+
/// <summary>
383+
/// Updates the status of an existing comment thread on a pull request.
384+
/// </summary>
385+
/// <param name="repositoryNameOrId">The repository name or ID.</param>
386+
/// <param name="pullRequestId">The pull request ID.</param>
387+
/// <param name="threadId">The thread ID to update.</param>
388+
/// <param name="status">The target thread status, such as Active, Fixed, Closed, WontFix, ByDesign, or Pending.</param>
389+
/// <param name="project">The project name (optional if default project is configured).</param>
390+
/// <param name="cancellationToken">Cancellation token.</param>
391+
/// <returns>The updated comment thread.</returns>
392+
Task<PullRequestThreadDto> UpdatePullRequestThreadStatusAsync(
393+
string repositoryNameOrId,
394+
int pullRequestId,
395+
int threadId,
396+
string status,
397+
string? project = null,
398+
CancellationToken cancellationToken = default);
399+
382400
/// <summary>
383401
/// Searches pull requests by title or description.
384402
/// </summary>

src/Viamus.Azure.Devops.Mcp.Server/Tools/PullRequestTools.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,52 @@ public async Task<string> AddPullRequestThreadComment(
233233
}, JsonOptions);
234234
}
235235

236+
[McpServerTool(Name = "update_pull_request_thread_status")]
237+
[Description("Updates the status of an existing pull request comment thread. Supports statuses/aliases such as active/open/reopen, fixed/resolve/resolved, closed/close, wontFix/wont-fix, byDesign/by-design, and pending.")]
238+
public async Task<string> UpdatePullRequestThreadStatus(
239+
[Description("The repository name or ID")] string repositoryNameOrId,
240+
[Description("The pull request ID")] int pullRequestId,
241+
[Description("The thread ID (from get_pull_request_threads)")] int threadId,
242+
[Description("Target status: Active, Fixed, Closed, WontFix, ByDesign, Pending, or aliases like close/resolve")] string status,
243+
[Description("The project name (optional if default project is configured)")] string? project = null,
244+
CancellationToken cancellationToken = default)
245+
{
246+
if (string.IsNullOrWhiteSpace(repositoryNameOrId))
247+
{
248+
return JsonSerializer.Serialize(new { error = "Repository name or ID is required" }, JsonOptions);
249+
}
250+
251+
if (pullRequestId <= 0)
252+
{
253+
return JsonSerializer.Serialize(new { error = "Pull request ID must be a positive integer" }, JsonOptions);
254+
}
255+
256+
if (threadId <= 0)
257+
{
258+
return JsonSerializer.Serialize(new { error = "Thread ID must be a positive integer" }, JsonOptions);
259+
}
260+
261+
var normalizedStatus = NormalizeThreadStatus(status);
262+
if (normalizedStatus is null)
263+
{
264+
return JsonSerializer.Serialize(new
265+
{
266+
error = "Unsupported thread status. Supported statuses are Active, Fixed, Closed, WontFix, ByDesign, and Pending. Aliases include open, reopen, resolve, resolved, close, closed, wont-fix, and by-design."
267+
}, JsonOptions);
268+
}
269+
270+
var thread = await _azureDevOpsService.UpdatePullRequestThreadStatusAsync(
271+
repositoryNameOrId, pullRequestId, threadId, normalizedStatus,
272+
project, cancellationToken);
273+
274+
return JsonSerializer.Serialize(new
275+
{
276+
success = true,
277+
message = $"Thread {threadId} on pull request {pullRequestId} updated to {thread.Status}",
278+
thread
279+
}, JsonOptions);
280+
}
281+
236282
[McpServerTool(Name = "search_pull_requests")]
237283
[Description("Searches pull requests by text in title or description. Useful for finding PRs related to specific features or bugs.")]
238284
public async Task<string> SearchPullRequests(
@@ -265,6 +311,23 @@ public async Task<string> SearchPullRequests(
265311
}, JsonOptions);
266312
}
267313

314+
private static string? NormalizeThreadStatus(string? status)
315+
{
316+
if (string.IsNullOrWhiteSpace(status))
317+
return null;
318+
319+
return status.Trim().ToLowerInvariant().Replace("_", string.Empty).Replace("-", string.Empty) switch
320+
{
321+
"active" or "open" or "reopen" or "reopened" => "Active",
322+
"fixed" or "fix" or "resolve" or "resolved" => "Fixed",
323+
"wontfix" or "wont" => "WontFix",
324+
"closed" or "close" => "Closed",
325+
"bydesign" => "ByDesign",
326+
"pending" => "Pending",
327+
_ => null
328+
};
329+
}
330+
268331
[McpServerTool(Name = "create_pull_request")]
269332
[Description("Creates a new pull request in a Git repository. Supports setting title, description, source/target branches, draft status, reviewers, and linked work items.")]
270333
public async Task<string> CreatePullRequest(

tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/PullRequestToolsTests.cs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,123 @@ public async Task CreatePullRequestThread_WithEndLineBeforeStartLine_ShouldRetur
483483

484484
#endregion
485485

486+
#region UpdatePullRequestThreadStatus Tests
487+
488+
[Fact]
489+
public async Task UpdatePullRequestThreadStatus_ShouldUpdateStatus()
490+
{
491+
var thread = new PullRequestThreadDto
492+
{
493+
Id = 10,
494+
Status = "Closed"
495+
};
496+
497+
_mockService
498+
.Setup(s => s.UpdatePullRequestThreadStatusAsync(
499+
"repo",
500+
123,
501+
10,
502+
"Closed",
503+
null,
504+
It.IsAny<CancellationToken>()))
505+
.ReturnsAsync(thread);
506+
507+
var result = await _tools.UpdatePullRequestThreadStatus("repo", 123, 10, "close");
508+
509+
Assert.Contains("\"success\": true", result);
510+
Assert.Contains("updated to Closed", result);
511+
Assert.Contains("\"status\": \"Closed\"", result);
512+
_mockService.Verify(s => s.UpdatePullRequestThreadStatusAsync(
513+
"repo",
514+
123,
515+
10,
516+
"Closed",
517+
null,
518+
It.IsAny<CancellationToken>()), Times.Once);
519+
}
520+
521+
[Fact]
522+
public async Task UpdatePullRequestThreadStatus_WithResolveAlias_ShouldSendFixed()
523+
{
524+
var thread = new PullRequestThreadDto
525+
{
526+
Id = 10,
527+
Status = "Fixed"
528+
};
529+
530+
_mockService
531+
.Setup(s => s.UpdatePullRequestThreadStatusAsync(
532+
"repo",
533+
123,
534+
10,
535+
"Fixed",
536+
"MyProject",
537+
It.IsAny<CancellationToken>()))
538+
.ReturnsAsync(thread);
539+
540+
await _tools.UpdatePullRequestThreadStatus("repo", 123, 10, "resolve", "MyProject");
541+
542+
_mockService.Verify(s => s.UpdatePullRequestThreadStatusAsync(
543+
"repo",
544+
123,
545+
10,
546+
"Fixed",
547+
"MyProject",
548+
It.IsAny<CancellationToken>()), Times.Once);
549+
}
550+
551+
[Fact]
552+
public async Task UpdatePullRequestThreadStatus_WithEmptyRepoName_ShouldReturnError()
553+
{
554+
var result = await _tools.UpdatePullRequestThreadStatus("", 123, 10, "closed");
555+
556+
Assert.Contains("error", result);
557+
Assert.Contains("Repository name or ID is required", result);
558+
_mockService.Verify(s => s.UpdatePullRequestThreadStatusAsync(
559+
It.IsAny<string>(),
560+
It.IsAny<int>(),
561+
It.IsAny<int>(),
562+
It.IsAny<string>(),
563+
It.IsAny<string?>(),
564+
It.IsAny<CancellationToken>()), Times.Never);
565+
}
566+
567+
[Fact]
568+
public async Task UpdatePullRequestThreadStatus_WithInvalidPRId_ShouldReturnError()
569+
{
570+
var result = await _tools.UpdatePullRequestThreadStatus("repo", 0, 10, "closed");
571+
572+
Assert.Contains("error", result);
573+
Assert.Contains("Pull request ID must be a positive integer", result);
574+
}
575+
576+
[Fact]
577+
public async Task UpdatePullRequestThreadStatus_WithInvalidThreadId_ShouldReturnError()
578+
{
579+
var result = await _tools.UpdatePullRequestThreadStatus("repo", 123, 0, "closed");
580+
581+
Assert.Contains("error", result);
582+
Assert.Contains("Thread ID must be a positive integer", result);
583+
}
584+
585+
[Fact]
586+
public async Task UpdatePullRequestThreadStatus_WithUnsupportedStatus_ShouldReturnError()
587+
{
588+
var result = await _tools.UpdatePullRequestThreadStatus("repo", 123, 10, "done");
589+
590+
Assert.Contains("error", result);
591+
Assert.Contains("Unsupported thread status", result);
592+
_mockService.Verify(s => s.UpdatePullRequestThreadStatusAsync(
593+
It.IsAny<string>(),
594+
It.IsAny<int>(),
595+
It.IsAny<int>(),
596+
It.IsAny<string>(),
597+
It.IsAny<string?>(),
598+
It.IsAny<CancellationToken>()), Times.Never);
599+
}
600+
601+
#endregion
602+
486603
#region SearchPullRequests Tests
487604

488605
[Fact]

0 commit comments

Comments
 (0)