diff --git a/README.md b/README.md index 4a2eb67..55ee02c 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ This project implements an MCP server that exposes tools for querying and managi | `get_work_item_comments` | Reads comments (discussion history) of a work item, with pagination, sort order, and optional rendered HTML | | `create_work_item` | Creates a new work item (Bug, Task, User Story, etc.) with support for all standard fields, parent linking, and custom fields | | `update_work_item` | Updates an existing work item. Only specified fields are changed; omitted fields remain unchanged | +| `link_work_items` | Links an existing work item to parent, child, predecessor, successor, or related work items | ### Git Repository Tools @@ -370,6 +371,7 @@ After configuring the MCP client, you can ask questions like: - "Create a User Story assigned to John with priority 2 under parent #100" - "Update work item #1234 to change state to 'Resolved' and assign to Jane" - "Set the iteration path of work item #567 to 'Project\Sprint 3'" +- "Link user story #567 to predecessor #123 and parent #100" ### Git Repositories diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs index 265b028..79213de 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs @@ -211,6 +211,65 @@ FROM WorkItemLinks } } + public async Task LinkWorkItemsAsync( + int sourceWorkItemId, + IEnumerable targetWorkItemIds, + string relationType, + string? comment = null, + string? project = null, + CancellationToken cancellationToken = default) + { + var targetIds = targetWorkItemIds.Distinct().ToList(); + if (targetIds.Count == 0) + { + throw new ArgumentException("At least one target work item ID is required.", nameof(targetWorkItemIds)); + } + + try + { + _logger.LogDebug( + "Linking work item {SourceWorkItemId} to {TargetCount} work item(s) with relation {RelationType}", + sourceWorkItemId, + targetIds.Count, + relationType); + + var patchDocument = new JsonPatchDocument(); + var relationComment = string.IsNullOrWhiteSpace(comment) ? relationType : comment.Trim(); + + foreach (var targetId in targetIds) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Add, + Path = "/relations/-", + Value = new + { + rel = relationType, + url = BuildWorkItemUrl(targetId), + attributes = new { comment = relationComment } + } + }); + } + + var result = await _witClient.UpdateWorkItemAsync( + document: patchDocument, + id: sourceWorkItemId, + project: project ?? _options.DefaultProject, + cancellationToken: cancellationToken); + + return MapToDto(result, includeAllFields: true); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error linking work item {SourceWorkItemId} with relation {RelationType}", + sourceWorkItemId, + relationType); + throw; + } + } + public async Task> QueryWorkItemsSummaryAsync( string wiqlQuery, string? project = null, @@ -477,6 +536,9 @@ private static (string? repositoryId, string? artifactId)? ExtractGitArtifactInf return null; } + private string BuildWorkItemUrl(int workItemId) => + $"{_options.OrganizationUrl.TrimEnd('/')}/_apis/wit/workItems/{workItemId}"; + public async Task AddWorkItemCommentAsync( int workItemId, string comment, @@ -879,7 +941,7 @@ public async Task CreateWorkItemAsync( Value = new { rel = "System.LinkTypes.Hierarchy-Reverse", - url = $"{_options.OrganizationUrl}/_apis/wit/workItems/{parentId.Value}", + url = BuildWorkItemUrl(parentId.Value), attributes = new { comment = "Parent" } } }); diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs index e5e7fe1..75b20f8 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs @@ -44,6 +44,24 @@ public interface IAzureDevOpsService /// List of child work items. Task> GetChildWorkItemsAsync(int parentWorkItemId, string? project = null, CancellationToken cancellationToken = default); + /// + /// Links an existing work item to one or more other work items. + /// + /// The work item ID to update with the new relation. + /// The work item IDs to link to. + /// The Azure DevOps relation reference name (for example, System.LinkTypes.Hierarchy-Reverse). + /// Optional relation comment. + /// The project name (optional if default project is configured). + /// Cancellation token. + /// The updated source work item. + Task LinkWorkItemsAsync( + int sourceWorkItemId, + IEnumerable targetWorkItemIds, + string relationType, + string? comment = null, + string? project = null, + CancellationToken cancellationToken = default); + /// /// Queries work items using WIQL and returns paginated summary results. /// This is optimized for large result sets with reduced payload size. diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs b/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs index 8cba486..4b96b7f 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs @@ -162,6 +162,69 @@ public async Task GetChildWorkItems( return JsonSerializer.Serialize(new { parentWorkItemId, count = workItems.Count, children = workItems }, JsonOptions); } + [McpServerTool(Name = "link_work_items")] + [Description("Links an existing Azure DevOps work item to one or more other work items. relationType accepts parent, child, predecessor, successor, related, or the matching System.LinkTypes.* reference name.")] + public async Task LinkWorkItems( + [Description("The work item ID to update with the new link. For a story, this is the story ID.")] int sourceWorkItemId, + [Description("Comma- or semicolon-separated target work item IDs to link to (e.g., '123,456' or '123;456')")] string targetWorkItemIds, + [Description("Relation from the source work item's perspective: parent, child, predecessor, successor, related, or a System.LinkTypes.* reference name")] string relationType, + [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("Optional comment to store on the relation")] string? comment = null, + CancellationToken cancellationToken = default) + { + if (sourceWorkItemId <= 0) + { + return JsonSerializer.Serialize(new { error = "sourceWorkItemId must be a positive integer" }, JsonOptions); + } + + var targetIds = ParseWorkItemIds(targetWorkItemIds); + if (targetIds.Count == 0) + { + return JsonSerializer.Serialize(new { error = "No valid target work item IDs provided" }, JsonOptions); + } + + if (targetIds.Contains(sourceWorkItemId)) + { + return JsonSerializer.Serialize(new { error = "A work item cannot be linked to itself" }, JsonOptions); + } + + var normalizedRelationType = NormalizeWorkItemRelationType(relationType); + if (normalizedRelationType is null) + { + return JsonSerializer.Serialize(new + { + error = "relationType must be one of: parent, child, predecessor, successor, related" + }, JsonOptions); + } + + if (normalizedRelationType == "System.LinkTypes.Hierarchy-Reverse" && targetIds.Count > 1) + { + return JsonSerializer.Serialize(new { error = "A work item can only have one parent" }, JsonOptions); + } + + var relationComment = string.IsNullOrWhiteSpace(comment) + ? GetDefaultRelationComment(normalizedRelationType) + : comment.Trim(); + + var workItem = await _azureDevOpsService.LinkWorkItemsAsync( + sourceWorkItemId, + targetIds, + normalizedRelationType, + relationComment, + project, + cancellationToken); + + return JsonSerializer.Serialize(new + { + success = true, + message = $"Work item {sourceWorkItemId} linked to {targetIds.Count} work item(s)", + sourceWorkItemId, + targetWorkItemIds = targetIds, + relationType = normalizedRelationType, + workItem + }, JsonOptions); + } + [McpServerTool(Name = "get_recent_work_items")] [Description("Gets recently changed work items with pagination. Returns a summary view (ID, Title, Type, State, Priority) to reduce payload size. Use get_work_item to get full details of a specific item.")] public async Task GetRecentWorkItems( @@ -477,6 +540,41 @@ public async Task UpdateWorkItem( } } + private static string? NormalizeWorkItemRelationType(string? relationType) + { + if (string.IsNullOrWhiteSpace(relationType)) + { + return null; + } + + var normalized = relationType + .Trim() + .Replace('_', '-') + .Replace(' ', '-') + .ToLowerInvariant(); + + return normalized switch + { + "parent" or "hierarchy-reverse" or "system.linktypes.hierarchy-reverse" => "System.LinkTypes.Hierarchy-Reverse", + "child" or "hierarchy-forward" or "system.linktypes.hierarchy-forward" => "System.LinkTypes.Hierarchy-Forward", + "predecessor" or "blocked-by" or "depends-on" or "dependency-reverse" or "system.linktypes.dependency-reverse" => "System.LinkTypes.Dependency-Reverse", + "successor" or "blocks" or "dependency-forward" or "system.linktypes.dependency-forward" => "System.LinkTypes.Dependency-Forward", + "related" or "system.linktypes.related" => "System.LinkTypes.Related", + _ => null + }; + } + + private static string GetDefaultRelationComment(string relationType) => + relationType switch + { + "System.LinkTypes.Hierarchy-Reverse" => "Parent", + "System.LinkTypes.Hierarchy-Forward" => "Child", + "System.LinkTypes.Dependency-Reverse" => "Predecessor", + "System.LinkTypes.Dependency-Forward" => "Successor", + "System.LinkTypes.Related" => "Related", + _ => relationType + }; + private static List ParseWorkItemIds(string workItemIds) { if (string.IsNullOrWhiteSpace(workItemIds)) @@ -485,7 +583,7 @@ private static List ParseWorkItemIds(string workItemIds) } return workItemIds - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(id => int.TryParse(id, out var parsed) ? parsed : (int?)null) .Where(id => id.HasValue) .Select(id => id!.Value) diff --git a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/WorkItemToolsTests.cs b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/WorkItemToolsTests.cs index fe989dd..52c9f2c 100644 --- a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/WorkItemToolsTests.cs +++ b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/WorkItemToolsTests.cs @@ -395,6 +395,138 @@ public async Task GetChildWorkItems_WhenNoChildren_ShouldReturnEmptyList() #endregion + #region LinkWorkItems Tests + + [Fact] + public async Task LinkWorkItems_WithPredecessors_ShouldLinkUsingDependencyReverse() + { + var workItem = new WorkItemDto { Id = 100, Title = "Story" }; + + _mockService + .Setup(s => s.LinkWorkItemsAsync( + 100, + It.Is>(ids => ids.SequenceEqual(new[] { 10, 20 })), + "System.LinkTypes.Dependency-Reverse", + "Predecessor", + null, + It.IsAny())) + .ReturnsAsync(workItem); + + var result = await _tools.LinkWorkItems(100, "10,20", "predecessor"); + + Assert.Contains("\"success\": true", result); + Assert.Contains("\"relationType\": \"System.LinkTypes.Dependency-Reverse\"", result); + Assert.Contains("\"targetWorkItemIds\":", result); + _mockService.Verify(s => s.LinkWorkItemsAsync( + 100, + It.Is>(ids => ids.SequenceEqual(new[] { 10, 20 })), + "System.LinkTypes.Dependency-Reverse", + "Predecessor", + null, + It.IsAny()), Times.Once); + } + + [Fact] + public async Task LinkWorkItems_WithParent_ShouldLinkUsingHierarchyReverse() + { + var workItem = new WorkItemDto { Id = 100, Title = "Story", ParentId = 50 }; + + _mockService + .Setup(s => s.LinkWorkItemsAsync( + 100, + It.Is>(ids => ids.SequenceEqual(new[] { 50 })), + "System.LinkTypes.Hierarchy-Reverse", + "Parent", + "MyProject", + It.IsAny())) + .ReturnsAsync(workItem); + + var result = await _tools.LinkWorkItems(100, "50", "parent", project: "MyProject"); + + Assert.Contains("\"success\": true", result); + Assert.Contains("\"relationType\": \"System.LinkTypes.Hierarchy-Reverse\"", result); + _mockService.Verify(s => s.LinkWorkItemsAsync( + 100, + It.Is>(ids => ids.SequenceEqual(new[] { 50 })), + "System.LinkTypes.Hierarchy-Reverse", + "Parent", + "MyProject", + It.IsAny()), Times.Once); + } + + [Fact] + public async Task LinkWorkItems_WithCustomComment_ShouldPassCommentToService() + { + var workItem = new WorkItemDto { Id = 100, Title = "Story" }; + + _mockService + .Setup(s => s.LinkWorkItemsAsync( + 100, + It.Is>(ids => ids.SequenceEqual(new[] { 30 })), + "System.LinkTypes.Dependency-Forward", + "Blocks downstream story", + null, + It.IsAny())) + .ReturnsAsync(workItem); + + await _tools.LinkWorkItems(100, "30", "successor", comment: "Blocks downstream story"); + + _mockService.Verify(s => s.LinkWorkItemsAsync( + 100, + It.Is>(ids => ids.SequenceEqual(new[] { 30 })), + "System.LinkTypes.Dependency-Forward", + "Blocks downstream story", + null, + It.IsAny()), Times.Once); + } + + [Fact] + public async Task LinkWorkItems_WithInvalidSourceId_ShouldReturnError() + { + var result = await _tools.LinkWorkItems(0, "10", "predecessor"); + + Assert.Contains("error", result); + Assert.Contains("sourceWorkItemId", result); + } + + [Fact] + public async Task LinkWorkItems_WithNoValidTargets_ShouldReturnError() + { + var result = await _tools.LinkWorkItems(100, "abc", "predecessor"); + + Assert.Contains("error", result); + Assert.Contains("No valid target work item IDs provided", result); + } + + [Fact] + public async Task LinkWorkItems_WithSelfLink_ShouldReturnError() + { + var result = await _tools.LinkWorkItems(100, "100", "related"); + + Assert.Contains("error", result); + Assert.Contains("cannot be linked to itself", result); + } + + [Fact] + public async Task LinkWorkItems_WithInvalidRelationType_ShouldReturnError() + { + var result = await _tools.LinkWorkItems(100, "10", "duplicate"); + + Assert.Contains("error", result); + Assert.Contains("relationType", result); + } + + [Fact] + public async Task LinkWorkItems_WithMultipleParents_ShouldReturnError() + { + var result = await _tools.LinkWorkItems(100, "10;20", "parent"); + + Assert.Contains("error", result); + Assert.Contains("only have one parent", result); + } + + #endregion + #region GetRecentWorkItems Tests [Fact]