Skip to content

Commit 2cd251a

Browse files
authored
Merge pull request #13 from viamus/codex/link-work-item-relations
Add work item relation linking tool
2 parents 9bd0a31 + e79795a commit 2cd251a

5 files changed

Lines changed: 314 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ This project implements an MCP server that exposes tools for querying and managi
128128
| `get_work_item_comments` | Reads comments (discussion history) of a work item, with pagination, sort order, and optional rendered HTML |
129129
| `create_work_item` | Creates a new work item (Bug, Task, User Story, etc.) with support for all standard fields, parent linking, and custom fields |
130130
| `update_work_item` | Updates an existing work item. Only specified fields are changed; omitted fields remain unchanged |
131+
| `link_work_items` | Links an existing work item to parent, child, predecessor, successor, or related work items |
131132

132133
### Git Repository Tools
133134

@@ -371,6 +372,7 @@ After configuring the MCP client, you can ask questions like:
371372
- "Create a User Story assigned to John with priority 2 under parent #100"
372373
- "Update work item #1234 to change state to 'Resolved' and assign to Jane"
373374
- "Set the iteration path of work item #567 to 'Project\Sprint 3'"
375+
- "Link user story #567 to predecessor #123 and parent #100"
374376

375377
### Git Repositories
376378

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

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,65 @@ FROM WorkItemLinks
211211
}
212212
}
213213

214+
public async Task<WorkItemDto> LinkWorkItemsAsync(
215+
int sourceWorkItemId,
216+
IEnumerable<int> targetWorkItemIds,
217+
string relationType,
218+
string? comment = null,
219+
string? project = null,
220+
CancellationToken cancellationToken = default)
221+
{
222+
var targetIds = targetWorkItemIds.Distinct().ToList();
223+
if (targetIds.Count == 0)
224+
{
225+
throw new ArgumentException("At least one target work item ID is required.", nameof(targetWorkItemIds));
226+
}
227+
228+
try
229+
{
230+
_logger.LogDebug(
231+
"Linking work item {SourceWorkItemId} to {TargetCount} work item(s) with relation {RelationType}",
232+
sourceWorkItemId,
233+
targetIds.Count,
234+
relationType);
235+
236+
var patchDocument = new JsonPatchDocument();
237+
var relationComment = string.IsNullOrWhiteSpace(comment) ? relationType : comment.Trim();
238+
239+
foreach (var targetId in targetIds)
240+
{
241+
patchDocument.Add(new JsonPatchOperation
242+
{
243+
Operation = Operation.Add,
244+
Path = "/relations/-",
245+
Value = new
246+
{
247+
rel = relationType,
248+
url = BuildWorkItemUrl(targetId),
249+
attributes = new { comment = relationComment }
250+
}
251+
});
252+
}
253+
254+
var result = await _witClient.UpdateWorkItemAsync(
255+
document: patchDocument,
256+
id: sourceWorkItemId,
257+
project: project ?? _options.DefaultProject,
258+
cancellationToken: cancellationToken);
259+
260+
return MapToDto(result, includeAllFields: true);
261+
}
262+
catch (Exception ex)
263+
{
264+
_logger.LogError(
265+
ex,
266+
"Error linking work item {SourceWorkItemId} with relation {RelationType}",
267+
sourceWorkItemId,
268+
relationType);
269+
throw;
270+
}
271+
}
272+
214273
public async Task<PaginatedResult<WorkItemSummaryDto>> QueryWorkItemsSummaryAsync(
215274
string wiqlQuery,
216275
string? project = null,
@@ -477,6 +536,9 @@ private static (string? repositoryId, string? artifactId)? ExtractGitArtifactInf
477536
return null;
478537
}
479538

539+
private string BuildWorkItemUrl(int workItemId) =>
540+
$"{_options.OrganizationUrl.TrimEnd('/')}/_apis/wit/workItems/{workItemId}";
541+
480542
public async Task<WorkItemCommentDto> AddWorkItemCommentAsync(
481543
int workItemId,
482544
string comment,
@@ -879,7 +941,7 @@ public async Task<WorkItemDto> CreateWorkItemAsync(
879941
Value = new
880942
{
881943
rel = "System.LinkTypes.Hierarchy-Reverse",
882-
url = $"{_options.OrganizationUrl}/_apis/wit/workItems/{parentId.Value}",
944+
url = BuildWorkItemUrl(parentId.Value),
883945
attributes = new { comment = "Parent" }
884946
}
885947
});

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,24 @@ public interface IAzureDevOpsService
4444
/// <returns>List of child work items.</returns>
4545
Task<IReadOnlyList<WorkItemDto>> GetChildWorkItemsAsync(int parentWorkItemId, string? project = null, CancellationToken cancellationToken = default);
4646

47+
/// <summary>
48+
/// Links an existing work item to one or more other work items.
49+
/// </summary>
50+
/// <param name="sourceWorkItemId">The work item ID to update with the new relation.</param>
51+
/// <param name="targetWorkItemIds">The work item IDs to link to.</param>
52+
/// <param name="relationType">The Azure DevOps relation reference name (for example, System.LinkTypes.Hierarchy-Reverse).</param>
53+
/// <param name="comment">Optional relation comment.</param>
54+
/// <param name="project">The project name (optional if default project is configured).</param>
55+
/// <param name="cancellationToken">Cancellation token.</param>
56+
/// <returns>The updated source work item.</returns>
57+
Task<WorkItemDto> LinkWorkItemsAsync(
58+
int sourceWorkItemId,
59+
IEnumerable<int> targetWorkItemIds,
60+
string relationType,
61+
string? comment = null,
62+
string? project = null,
63+
CancellationToken cancellationToken = default);
64+
4765
/// <summary>
4866
/// Queries work items using WIQL and returns paginated summary results.
4967
/// This is optimized for large result sets with reduced payload size.

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

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,69 @@ public async Task<string> GetChildWorkItems(
162162
return JsonSerializer.Serialize(new { parentWorkItemId, count = workItems.Count, children = workItems }, JsonOptions);
163163
}
164164

165+
[McpServerTool(Name = "link_work_items")]
166+
[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.")]
167+
public async Task<string> LinkWorkItems(
168+
[Description("The work item ID to update with the new link. For a story, this is the story ID.")] int sourceWorkItemId,
169+
[Description("Comma- or semicolon-separated target work item IDs to link to (e.g., '123,456' or '123;456')")] string targetWorkItemIds,
170+
[Description("Relation from the source work item's perspective: parent, child, predecessor, successor, related, or a System.LinkTypes.* reference name")] string relationType,
171+
[Description("The project name (optional if default project is configured)")] string? project = null,
172+
[Description("Optional comment to store on the relation")] string? comment = null,
173+
CancellationToken cancellationToken = default)
174+
{
175+
if (sourceWorkItemId <= 0)
176+
{
177+
return JsonSerializer.Serialize(new { error = "sourceWorkItemId must be a positive integer" }, JsonOptions);
178+
}
179+
180+
var targetIds = ParseWorkItemIds(targetWorkItemIds);
181+
if (targetIds.Count == 0)
182+
{
183+
return JsonSerializer.Serialize(new { error = "No valid target work item IDs provided" }, JsonOptions);
184+
}
185+
186+
if (targetIds.Contains(sourceWorkItemId))
187+
{
188+
return JsonSerializer.Serialize(new { error = "A work item cannot be linked to itself" }, JsonOptions);
189+
}
190+
191+
var normalizedRelationType = NormalizeWorkItemRelationType(relationType);
192+
if (normalizedRelationType is null)
193+
{
194+
return JsonSerializer.Serialize(new
195+
{
196+
error = "relationType must be one of: parent, child, predecessor, successor, related"
197+
}, JsonOptions);
198+
}
199+
200+
if (normalizedRelationType == "System.LinkTypes.Hierarchy-Reverse" && targetIds.Count > 1)
201+
{
202+
return JsonSerializer.Serialize(new { error = "A work item can only have one parent" }, JsonOptions);
203+
}
204+
205+
var relationComment = string.IsNullOrWhiteSpace(comment)
206+
? GetDefaultRelationComment(normalizedRelationType)
207+
: comment.Trim();
208+
209+
var workItem = await _azureDevOpsService.LinkWorkItemsAsync(
210+
sourceWorkItemId,
211+
targetIds,
212+
normalizedRelationType,
213+
relationComment,
214+
project,
215+
cancellationToken);
216+
217+
return JsonSerializer.Serialize(new
218+
{
219+
success = true,
220+
message = $"Work item {sourceWorkItemId} linked to {targetIds.Count} work item(s)",
221+
sourceWorkItemId,
222+
targetWorkItemIds = targetIds,
223+
relationType = normalizedRelationType,
224+
workItem
225+
}, JsonOptions);
226+
}
227+
165228
[McpServerTool(Name = "get_recent_work_items")]
166229
[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.")]
167230
public async Task<string> GetRecentWorkItems(
@@ -477,6 +540,41 @@ public async Task<string> UpdateWorkItem(
477540
}
478541
}
479542

543+
private static string? NormalizeWorkItemRelationType(string? relationType)
544+
{
545+
if (string.IsNullOrWhiteSpace(relationType))
546+
{
547+
return null;
548+
}
549+
550+
var normalized = relationType
551+
.Trim()
552+
.Replace('_', '-')
553+
.Replace(' ', '-')
554+
.ToLowerInvariant();
555+
556+
return normalized switch
557+
{
558+
"parent" or "hierarchy-reverse" or "system.linktypes.hierarchy-reverse" => "System.LinkTypes.Hierarchy-Reverse",
559+
"child" or "hierarchy-forward" or "system.linktypes.hierarchy-forward" => "System.LinkTypes.Hierarchy-Forward",
560+
"predecessor" or "blocked-by" or "depends-on" or "dependency-reverse" or "system.linktypes.dependency-reverse" => "System.LinkTypes.Dependency-Reverse",
561+
"successor" or "blocks" or "dependency-forward" or "system.linktypes.dependency-forward" => "System.LinkTypes.Dependency-Forward",
562+
"related" or "system.linktypes.related" => "System.LinkTypes.Related",
563+
_ => null
564+
};
565+
}
566+
567+
private static string GetDefaultRelationComment(string relationType) =>
568+
relationType switch
569+
{
570+
"System.LinkTypes.Hierarchy-Reverse" => "Parent",
571+
"System.LinkTypes.Hierarchy-Forward" => "Child",
572+
"System.LinkTypes.Dependency-Reverse" => "Predecessor",
573+
"System.LinkTypes.Dependency-Forward" => "Successor",
574+
"System.LinkTypes.Related" => "Related",
575+
_ => relationType
576+
};
577+
480578
private static List<int> ParseWorkItemIds(string workItemIds)
481579
{
482580
if (string.IsNullOrWhiteSpace(workItemIds))
@@ -485,7 +583,7 @@ private static List<int> ParseWorkItemIds(string workItemIds)
485583
}
486584

487585
return workItemIds
488-
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
586+
.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
489587
.Select(id => int.TryParse(id, out var parsed) ? parsed : (int?)null)
490588
.Where(id => id.HasValue)
491589
.Select(id => id!.Value)

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

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,138 @@ public async Task GetChildWorkItems_WhenNoChildren_ShouldReturnEmptyList()
395395

396396
#endregion
397397

398+
#region LinkWorkItems Tests
399+
400+
[Fact]
401+
public async Task LinkWorkItems_WithPredecessors_ShouldLinkUsingDependencyReverse()
402+
{
403+
var workItem = new WorkItemDto { Id = 100, Title = "Story" };
404+
405+
_mockService
406+
.Setup(s => s.LinkWorkItemsAsync(
407+
100,
408+
It.Is<IEnumerable<int>>(ids => ids.SequenceEqual(new[] { 10, 20 })),
409+
"System.LinkTypes.Dependency-Reverse",
410+
"Predecessor",
411+
null,
412+
It.IsAny<CancellationToken>()))
413+
.ReturnsAsync(workItem);
414+
415+
var result = await _tools.LinkWorkItems(100, "10,20", "predecessor");
416+
417+
Assert.Contains("\"success\": true", result);
418+
Assert.Contains("\"relationType\": \"System.LinkTypes.Dependency-Reverse\"", result);
419+
Assert.Contains("\"targetWorkItemIds\":", result);
420+
_mockService.Verify(s => s.LinkWorkItemsAsync(
421+
100,
422+
It.Is<IEnumerable<int>>(ids => ids.SequenceEqual(new[] { 10, 20 })),
423+
"System.LinkTypes.Dependency-Reverse",
424+
"Predecessor",
425+
null,
426+
It.IsAny<CancellationToken>()), Times.Once);
427+
}
428+
429+
[Fact]
430+
public async Task LinkWorkItems_WithParent_ShouldLinkUsingHierarchyReverse()
431+
{
432+
var workItem = new WorkItemDto { Id = 100, Title = "Story", ParentId = 50 };
433+
434+
_mockService
435+
.Setup(s => s.LinkWorkItemsAsync(
436+
100,
437+
It.Is<IEnumerable<int>>(ids => ids.SequenceEqual(new[] { 50 })),
438+
"System.LinkTypes.Hierarchy-Reverse",
439+
"Parent",
440+
"MyProject",
441+
It.IsAny<CancellationToken>()))
442+
.ReturnsAsync(workItem);
443+
444+
var result = await _tools.LinkWorkItems(100, "50", "parent", project: "MyProject");
445+
446+
Assert.Contains("\"success\": true", result);
447+
Assert.Contains("\"relationType\": \"System.LinkTypes.Hierarchy-Reverse\"", result);
448+
_mockService.Verify(s => s.LinkWorkItemsAsync(
449+
100,
450+
It.Is<IEnumerable<int>>(ids => ids.SequenceEqual(new[] { 50 })),
451+
"System.LinkTypes.Hierarchy-Reverse",
452+
"Parent",
453+
"MyProject",
454+
It.IsAny<CancellationToken>()), Times.Once);
455+
}
456+
457+
[Fact]
458+
public async Task LinkWorkItems_WithCustomComment_ShouldPassCommentToService()
459+
{
460+
var workItem = new WorkItemDto { Id = 100, Title = "Story" };
461+
462+
_mockService
463+
.Setup(s => s.LinkWorkItemsAsync(
464+
100,
465+
It.Is<IEnumerable<int>>(ids => ids.SequenceEqual(new[] { 30 })),
466+
"System.LinkTypes.Dependency-Forward",
467+
"Blocks downstream story",
468+
null,
469+
It.IsAny<CancellationToken>()))
470+
.ReturnsAsync(workItem);
471+
472+
await _tools.LinkWorkItems(100, "30", "successor", comment: "Blocks downstream story");
473+
474+
_mockService.Verify(s => s.LinkWorkItemsAsync(
475+
100,
476+
It.Is<IEnumerable<int>>(ids => ids.SequenceEqual(new[] { 30 })),
477+
"System.LinkTypes.Dependency-Forward",
478+
"Blocks downstream story",
479+
null,
480+
It.IsAny<CancellationToken>()), Times.Once);
481+
}
482+
483+
[Fact]
484+
public async Task LinkWorkItems_WithInvalidSourceId_ShouldReturnError()
485+
{
486+
var result = await _tools.LinkWorkItems(0, "10", "predecessor");
487+
488+
Assert.Contains("error", result);
489+
Assert.Contains("sourceWorkItemId", result);
490+
}
491+
492+
[Fact]
493+
public async Task LinkWorkItems_WithNoValidTargets_ShouldReturnError()
494+
{
495+
var result = await _tools.LinkWorkItems(100, "abc", "predecessor");
496+
497+
Assert.Contains("error", result);
498+
Assert.Contains("No valid target work item IDs provided", result);
499+
}
500+
501+
[Fact]
502+
public async Task LinkWorkItems_WithSelfLink_ShouldReturnError()
503+
{
504+
var result = await _tools.LinkWorkItems(100, "100", "related");
505+
506+
Assert.Contains("error", result);
507+
Assert.Contains("cannot be linked to itself", result);
508+
}
509+
510+
[Fact]
511+
public async Task LinkWorkItems_WithInvalidRelationType_ShouldReturnError()
512+
{
513+
var result = await _tools.LinkWorkItems(100, "10", "duplicate");
514+
515+
Assert.Contains("error", result);
516+
Assert.Contains("relationType", result);
517+
}
518+
519+
[Fact]
520+
public async Task LinkWorkItems_WithMultipleParents_ShouldReturnError()
521+
{
522+
var result = await _tools.LinkWorkItems(100, "10;20", "parent");
523+
524+
Assert.Contains("error", result);
525+
Assert.Contains("only have one parent", result);
526+
}
527+
528+
#endregion
529+
398530
#region GetRecentWorkItems Tests
399531

400532
[Fact]

0 commit comments

Comments
 (0)