From ca51727a54a8fb7f1bf979ce40eb932bf84c491b Mon Sep 17 00:00:00 2001 From: Eduardo Augusto Coleta de Souza Date: Tue, 21 Jul 2026 16:01:53 -0300 Subject: [PATCH] feat: Add work item relationship visibility and tree tools --- README.md | 6 +- docs/workitem-relations-analysis.md | 60 ++++ docs/workitem-relations-architecture.md | 147 +++++++++ docs/workitem-relations-prd.md | 136 +++++++++ .../Models/WorkItemDto.cs | 5 + .../Models/WorkItemRelationDto.cs | 24 ++ .../Models/WorkItemTreeNodeDto.cs | 10 + .../Services/AzureDevOpsService.cs | 285 +++++++++++++++++- .../Services/IAzureDevOpsService.cs | 41 +++ .../Tools/WorkItemTools.cs | 57 +++- .../Models/WorkItemRelationDtoTests.cs | 92 ++++++ .../Tools/WorkItemToolsTests.cs | 79 ++++- 12 files changed, 921 insertions(+), 21 deletions(-) create mode 100644 docs/workitem-relations-analysis.md create mode 100644 docs/workitem-relations-architecture.md create mode 100644 docs/workitem-relations-prd.md create mode 100644 src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemRelationDto.cs create mode 100644 src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemTreeNodeDto.cs create mode 100644 tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/WorkItemRelationDtoTests.cs diff --git a/README.md b/README.md index 8c7f524..c83bddc 100644 --- a/README.md +++ b/README.md @@ -142,8 +142,10 @@ This project implements an MCP server that exposes tools for querying and managi | Tool | Description | |------|-------------| -| `get_work_item` | Gets details of a specific work item by ID | -| `get_work_items` | Gets multiple work items by IDs (batch retrieval) | +| `get_work_item` | Gets details of a specific work item by ID (supports optional `includeRelations` parameter) | +| `get_work_items` | Gets multiple work items by IDs (batch retrieval, supports optional `includeRelations` parameter) | +| `get_work_item_relations` | Gets the normalized list of relationships (Parent, Child, Related, Predecessor, Successor, Tests, Tested By, Hyperlink, Attachment) associated with a work item | +| `get_work_item_tree` | Recursively gets parent and child work items starting from a root work item, up to a specified depth limit. Includes cycle detection | | `query_work_items` | Queries work items using WIQL (Work Item Query Language) | | `get_work_items_by_state` | Filters work items by state (Active, New, Closed, etc.) | | `get_work_items_assigned_to` | Gets work items assigned to a specific user | diff --git a/docs/workitem-relations-analysis.md b/docs/workitem-relations-analysis.md new file mode 100644 index 0000000..af55cc3 --- /dev/null +++ b/docs/workitem-relations-analysis.md @@ -0,0 +1,60 @@ +# Technical Analysis: Azure DevOps Work Item Relationship Collection + +**Feature:** Work Item Relationship Collection Visibility +**Target Repository:** `SouzaEduardoAC/mcp-azure-devops` +**Phase:** 2a — Discovery & Forensic Analysis + +--- + +## 1. Codebase Forensics & Current Architecture + +### Existing Code Structure +- **Service Layer (`AzureDevOpsService.cs`):** + - Uses `Microsoft.TeamFoundation.WorkItemTracking.WebApi.WorkItemTrackingHttpClient` to communicate with Azure DevOps. + - In `GetWorkItemAsync` and `GetWorkItemsAsync`, calls `WitClient.GetWorkItemAsync(..., expand: WorkItemExpand.All)` which already retrieves the complete `relations` array from Azure DevOps REST API. + - `MapToDto` currently iterates over `workItem.Relations` solely to extract `ParentId` (from `System.LinkTypes.Hierarchy-Reverse`), `LinkedCommits` (from `vstfs:///Git/Commit/...`), and `LinkedPullRequests` (from `vstfs:///Git/PullRequestId/...`). + - All other relations (such as `System.LinkTypes.Related`, `System.LinkTypes.Dependency-Forward`/`Reverse`, `Microsoft.VSTS.Common.TestedBy-Forward`/`Reverse`) are currently discarded during DTO projection. + +- **DTO Models (`WorkItemDto.cs`, `WorkItemSummaryDto.cs`):** + - `WorkItemDto` currently lacks a `Relations` property. + - `WorkItemSummaryDto` contains basic scalar fields (`Id`, `Title`, `State`, `AssignedTo`, `ParentId`). + +- **MCP Tools (`WorkItemTools.cs`):** + - `link_work_items` tool already exists for creating links (supporting relation types: `parent`, `child`, `predecessor`, `successor`, `related`). + - Read tools (`get_work_item`, `get_work_items`, `query_work_items`, `get_child_work_items`) do not return normalized relation collections. + +--- + +## 2. Target API & Relation Type Normalization + +### Azure DevOps Relation Types (`rel`) + +| Raw Azure DevOps `rel` URI | Normalized Name | Directional Meaning | +| :--- | :--- | :--- | +| `System.LinkTypes.Hierarchy-Reverse` | `Parent` | Item's parent work item | +| `System.LinkTypes.Hierarchy-Forward` | `Child` | Item's child work item | +| `System.LinkTypes.Related` | `Related` | Bilateral link (e.g., origin defect link) | +| `System.LinkTypes.Dependency-Reverse` | `Predecessor` | Blocking item that must finish first | +| `System.LinkTypes.Dependency-Forward` | `Successor` | Blocked item depending on this | +| `Microsoft.VSTS.Common.TestedBy-Forward` | `Tested By` | Test Case testing this requirement | +| `Microsoft.VSTS.Common.TestedBy-Reverse` | `Tests` | Requirement tested by this test case | +| `Hyperlink` | `Hyperlink` | External web URL | +| `AttachedFile` | `Attachment` | File attachment | + +### ID Extraction Heuristics +Target URLs for work items follow the format: +`https://dev.azure.com/{organization}/{project}/_apis/wit/workItems/{targetId}` +`TargetId` can be parsed via standard regex or string segment parsing (`/workItems/(\d+)$`). + +--- + +## 3. Impact & Risk Analysis + +1. **Zero Extra REST Calls for Single/Batch Fetch:** Because `WitClient.GetWorkItemAsync` and `GetWorkItemsAsync` use `WorkItemExpand.All`, relation parsing occurs entirely in-memory without extra network calls. +2. **Token Efficiency:** Adding `includeRelations: false` by default preserves lightweight JSON responses for existing LLM prompts while allowing targeted inspection when relationship visibility is needed. +3. **Cycle Safety in Hierarchy Trees:** Recursive tree retrieval (`get_work_item_tree`) must track visited work item IDs (`HashSet`) to prevent infinite recursion if invalid or circular link graphs exist. + +--- + +## 4. Next Steps +Upon approval of this Technical Analysis (`discovery` gate), Phase 2b Architecture & ADR will specify the exact C# DTO contracts, interface additions, and unit testing strategy. diff --git a/docs/workitem-relations-architecture.md b/docs/workitem-relations-architecture.md new file mode 100644 index 0000000..5272919 --- /dev/null +++ b/docs/workitem-relations-architecture.md @@ -0,0 +1,147 @@ +# Architecture Decision Record (ADR) & Implementation Plan: Work Item Relationship Collection + +**Feature Title:** Azure DevOps Work Item Relationship Collection Visibility +**Target Repository:** `SouzaEduardoAC/mcp-azure-devops` +**Phase:** 2b — Architecture & Design Specifications +**Status:** Proposed + +--- + +## 1. Context & Architectural Strategy + +Work items in Azure DevOps use relation collections to capture links between items. Currently, `mcp-azure-devops` strips out relation arrays during `MapToDto` except for parent ID, commit links, and pull request links. + +To allow LLMs to trace defect origins, inspect parent/child hierarchies, analyze predecessor/successor blockers, and inspect test links, we are introducing structured relationship visibility into the MCP server. + +--- + +## 2. Architectural Decisions (ADRs) + +### ADR 001: Relation Type Normalization +Raw Azure DevOps relation URIs can be verbose (e.g. `System.LinkTypes.Hierarchy-Forward`, `Microsoft.VSTS.Common.TestedBy-Forward`). + +**Decision:** We will map relation strings to standardized, human- and LLM-friendly aliases while retaining `rawRel` for full transparency: + +```csharp +private static readonly Dictionary RelationTypeMappings = new(StringComparer.OrdinalIgnoreCase) +{ + ["System.LinkTypes.Hierarchy-Reverse"] = "Parent", + ["System.LinkTypes.Hierarchy-Forward"] = "Child", + ["System.LinkTypes.Related"] = "Related", + ["System.LinkTypes.Dependency-Reverse"] = "Predecessor", + ["System.LinkTypes.Dependency-Forward"] = "Successor", + ["Microsoft.VSTS.Common.TestedBy-Forward"] = "Tested By", + ["Microsoft.VSTS.Common.TestedBy-Reverse"] = "Tests", + ["Hyperlink"] = "Hyperlink", + ["AttachedFile"] = "Attachment" +}; +``` + +--- + +### ADR 002: Dedicated `get_work_item_relations` MCP Tool +**Decision:** Create a dedicated tool `get_work_item_relations` tailored specifically for relationship inspection. +- **Parameters:** + - `workItemId` (int, required) + - `relationTypeFilter` (string, optional): Filter by `"Related"`, `"Parent"`, `"Child"`, `"Predecessor"`, `"Successor"`, `"Tests"`, `"Tested By"`. + - `expandTargetSummary` (bool, optional, default: `false`): When `true`, fetches the target work item title, state, and type inline using batch `WitClient.GetWorkItemsAsync`. + - `project` (string, optional) + - `organization` (string, optional) + +--- + +### ADR 003: Recursive Hierarchy Tree Tool `get_work_item_tree` +**Decision:** Provide `get_work_item_tree` to render recursive Parent/Child trees for Epics, Features, User Stories, and Tasks. +- **Parameters:** + - `workItemId` (int, required) + - `maxDepth` (int, optional, default: 2, max: 5) + - `project` (string, optional) + - `organization` (string, optional) +- **Cycle Safety:** Employs a `HashSet visitedWorkItemIds` during traversal to prevent infinite loops if circular relationships exist. + +--- + +### ADR 004: Opt-In Extension on `get_work_item` & `get_work_items` +**Decision:** Add `includeRelations` (bool, default: `false`) to `GetWorkItem` and `GetWorkItems` tools. +When `includeRelations: true`, `WorkItemDto` populates its `Relations` property (`List`). + +--- + +## 3. Data Transfer Object (DTO) Contracts + +### `WorkItemRelationDto.cs` +```csharp +namespace Viamus.Azure.Devops.Mcp.Server.Models; + +public sealed record WorkItemRelationDto +{ + public string RelationType { get; init; } = null!; + public string RawRel { get; init; } = null!; + public int? TargetId { get; init; } + public string? TargetUrl { get; init; } + public string? Comment { get; init; } + public WorkItemSummaryDto? TargetSummary { get; init; } +} + +public sealed record WorkItemRelationsResultDto +{ + public int WorkItemId { get; init; } + public int Count { get; init; } + public IReadOnlyList Relations { get; init; } = []; +} + +public sealed record WorkItemTreeNodeDto +{ + public WorkItemDto WorkItem { get; init; } = null!; + public IReadOnlyList Children { get; init; } = []; +} +``` + +--- + +## 4. Modified Component Map + +``` +┌────────────────────────────────────────────────────────┐ +│ WorkItemTools.cs │ +│ - get_work_item_relations (NEW) │ +│ - get_work_item_tree (NEW) │ +│ - get_work_item (UPDATED: includeRelations) │ +│ - get_work_items (UPDATED: includeRelations) │ +└──────────────────────────┬─────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────┐ +│ IAzureDevOpsService.cs │ +│ - GetWorkItemRelationsAsync (NEW) │ +│ - GetWorkItemTreeAsync (NEW) │ +│ - GetWorkItemAsync (UPDATED: includeRelations) │ +│ - GetWorkItemsAsync (UPDATED: includeRelations) │ +└──────────────────────────┬─────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────┐ +│ AzureDevOpsService.cs │ +│ - MapToDto (UPDATED: relation parsing logic) │ +│ - ParseRelations (NEW static helper) │ +└────────────────────────────────────────────────────────┘ +``` + +--- + +## 5. Verification & Testing Plan + +### Automated Unit Tests +- `WorkItemRelationTests.cs`: + - Tests relation type normalization for all known Azure DevOps relation URIs. + - Tests `TargetId` parsing from URLs. + - Tests `MapToDto` with `includeRelations = true`. + - Tests `get_work_item_tree` cycle detection on self-referential or cyclic links. + +--- + +## 6. Definition of Done +- [x] Architecture & ADR drafted and saved to `docs/workitem-relations-architecture.md`. +- [ ] Sign-off received for `plan` gate via `request_approval`. +- [ ] Phase 3 Compliance Evaluation complete. +- [ ] Phase 4 Implementation & Peer Review complete. diff --git a/docs/workitem-relations-prd.md b/docs/workitem-relations-prd.md new file mode 100644 index 0000000..47447fc --- /dev/null +++ b/docs/workitem-relations-prd.md @@ -0,0 +1,136 @@ +# Product Requirements Document (PRD): Work Item Relationship Collection Visibility + +**Feature Title:** Azure DevOps Work Item Relationship Collection (Related, Parent, Child, Predecessor, Successor, Tests) +**Target Repository:** `SouzaEduardoAC/mcp-azure-devops` (Forked from `viamus/mcp-azure-devops`) +**Document Status:** Draft — Pending Phase 1 Gate Sign-off + +--- + +## 1. Executive Summary & Problem Statement + +In Azure DevOps, work items are linked via rich relationship collections (`System.LinkTypes.Hierarchy-Reverse` [Parent], `System.LinkTypes.Hierarchy-Forward` [Child], `System.LinkTypes.Related` [Related Work], `System.LinkTypes.Dependency-Reverse` [Predecessor], `System.LinkTypes.Dependency-Forward` [Successor], `Microsoft.VSTS.Common.TestedBy-Forward`/`Reverse` [Tests/Tested By], etc.). + +Currently, when LLMs query work items via `mcp-azure-devops` tools (`get_work_item`, `get_work_items`, `query_work_items`), the returned work item model omits the `relations` collection. Consequently, LLM agents have zero visibility into linked items. + +### Business & Workflow Context +When a work item (e.g., User Story or Feature) encounters an issue during implementation or QA, engineering teams create a related work item (e.g., Bug or Issue) linked directly to the defective one to track origin and traceability. +Without relationship visibility in `mcp-azure-devops`: +- Downstream LLM agents cannot trace defects back to their originating work item. +- LLMs cannot understand hierarchy trees (Parent Epics / Child Tasks) or blocking dependencies (Predecessors / Successors). +- LLMs cannot verify test coverage links (Tested By / Tests). + +This PRD specifies the functional requirements to expose work item relationship collections in `mcp-azure-devops`. + +--- + +## 2. Target Users & Use Cases + +### Target Users +- **LLM Bug & Origin Traceability Agents:** AI assistants identifying defect origins, duplicate issues, and root causes across related work items. +- **Project & Portfolio Management AI:** Agents analyzing parent/child breakdown, epic completion, and dependency blockers. +- **QA Automation & Test Analysts:** LLMs verifying whether work items have associated test cases or bug reports attached. + +### Key Use Cases +1. **Defect Origin Tracing:** An LLM inspects a Bug work item, finds its `Related` or `Parent` work item, and determines which original user story or task introduced the defective code. +2. **Hierarchy & Epic Breakdown:** An LLM queries a Parent Feature to list all Child User Stories and Tasks to generate progress reports. +3. **Dependency Analysis:** An LLM inspects a work item's `Predecessor` links to identify unblocked tasks or potential delivery bottlenecks. + +--- + +## 3. Scope & Requirements (MoSCoW) + +### 3.1 Must Have (V1 Scope) + +#### 1. Relation Model & Type Normalization +Map raw Azure DevOps relation URIs / relation types to clean, user-friendly relationship names: + +| Azure DevOps Relation Type (`rel`) | Normalized Name | Description | +| :--- | :--- | :--- | +| `System.LinkTypes.Hierarchy-Reverse` | `Parent` | Parent work item in hierarchy | +| `System.LinkTypes.Hierarchy-Forward` | `Child` | Child work item in hierarchy | +| `System.LinkTypes.Related` | `Related` | Direct bilateral relationship (e.g. bug tracking origin) | +| `System.LinkTypes.Dependency-Reverse` | `Predecessor` | Work item that must be completed prior | +| `System.LinkTypes.Dependency-Forward` | `Successor` | Work item dependent on this item | +| `Microsoft.VSTS.Common.TestedBy-Forward` | `Tested By` | Test Case testing this work item | +| `Microsoft.VSTS.Common.TestedBy-Reverse` | `Tests` | Work item tested by this test case | +| `Hyperlink` | `Hyperlink` | External web URL link | +| `AttachedFile` | `Attachment` | File attachment link | + +#### 2. `WorkItemRelation` Data Model +```json +{ + "relationType": "Related", + "rawRel": "System.LinkTypes.Related", + "targetId": 1234, + "targetUrl": "https://dev.azure.com/org/proj/_apis/wit/workItems/1234", + "comment": "Origin bug reported during Sprint 5 testing" +} +``` + +#### 3. Azure DevOps REST Integration +- Use `$expand=relations` parameter on Azure DevOps WIT REST API endpoints (`GET _apis/wit/workitems/{id}?$expand=relations` and `GET _apis/wit/workitems?ids={ids}&$expand=relations`). +- Parse the `relations` JSON array returned by Azure DevOps. + +#### 4. Dedicated MCP Tool: `get_work_item_relations` +- **Name:** `get_work_item_relations` +- **Parameters:** + - `workItemId` (integer, required): ID of the work item. + - `relationTypeFilter` (string, optional): Filter by normalized relation type (e.g. `"Related"`, `"Parent"`, `"Child"`, `"Predecessor"`, `"Successor"`). + - `project` (string, optional): Azure DevOps project. + - `organization` (string, optional): Azure DevOps organization. +- **Returns:** List of normalized `WorkItemRelation` objects, with target work item summary metadata (ID, Title, WorkItemType, State) expanded where applicable. + +#### 5. Opt-in `includeRelations` Parameter on Existing Query Tools +- Add optional `includeRelations` (boolean, default: `false`) parameter to: + - `get_work_item` + - `get_work_items` + - `query_work_items` +- When `includeRelations: true`, populate a `relations` list on each returned `WorkItemDto`. + +--- + +### 3.2 Should Have (V1 Scope) +- **`get_work_item_tree` Tool:** + - Recursively fetches `Parent` and `Child` relationships up to a configurable `depth` (default: 2, max: 5) to return a full tree structure for Epics/Features/Stories/Tasks. + +--- + +### 3.3 Could Have (V1.1 Scope) +- Summary counts of relations per type (e.g., `relatedCount`, `childCount`, `bugCount`) embedded directly in basic `WorkItemDto`. + +--- + +### 3.4 Won't Have (Out of V1 Scope) +- Relation creation/deletion (writing link updates to Azure DevOps). V1 focuses exclusively on visibility and read queries for LLMs. + +--- + +## 4. Technical Constraints & Performance Considerations + +1. **REST API Batching:** + - Azure DevOps API supports `$expand=relations` on batch work item calls (`/workitems?ids=1,2,3&$expand=relations`). + - Using `$expand=relations` on single or bulk GETs requires zero extra HTTP calls per work item. +2. **Backward Compatibility:** + - `includeRelations` defaults to `false` on existing query endpoints to ensure existing JSON contracts and prompt token consumption remain optimized. +3. **Target ID Extraction:** + - Azure DevOps relation objects contain `url` like `https://dev.azure.com/org/project/_apis/wit/workItems/42`. `targetId` must be reliably extracted via regex/URL parsing (`/workItems/(\d+)$`). + +--- + +## 5. RICE Prioritization + +| Feature Component | Reach | Impact | Confidence | Effort | RICE Score | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **`WorkItemRelation` model & `$expand=relations` REST integration** | High (100%) | Massive (3.0) | High (100%) | Low (2) | **150.0** | +| **`get_work_item_relations` tool** | High (100%) | High (2.0) | High (100%) | Low (1) | **200.0** | +| **`includeRelations` flag on `get_work_item(s)` / `query_work_items`** | High (80%) | High (2.0) | High (95%) | Low (1) | **152.0** | +| **`get_work_item_tree` tool** | Medium (50%) | Medium (1.0) | High (90%) | Medium (2) | **22.5** | + +--- + +## 6. Definition of Done (Phase 1 Exit Criteria) + +- [x] PRD written to `docs/workitem-relations-prd.md`. +- [ ] PRD submitted for human review (`prd` gate). +- [ ] Approval received via `/squad:approve prd`. +- [ ] Phase 2 Architecture & ADR design initiated. diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemDto.cs b/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemDto.cs index 03c5d5b..4ca5a7c 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemDto.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemDto.cs @@ -35,6 +35,11 @@ public sealed record WorkItemDto /// List of pull request IDs linked to this work item. /// public List? LinkedPullRequests { get; init; } + + /// + /// List of all normalized relationships associated with this work item. + /// + public List? Relations { get; init; } } /// diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemRelationDto.cs b/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemRelationDto.cs new file mode 100644 index 0000000..e703e4c --- /dev/null +++ b/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemRelationDto.cs @@ -0,0 +1,24 @@ +namespace Viamus.Azure.Devops.Mcp.Server.Models; + +/// +/// Data transfer object representing a normalized work item relationship. +/// +public sealed record WorkItemRelationDto +{ + public string RelationType { get; init; } = null!; + public string RawRel { get; init; } = null!; + public int? TargetId { get; init; } + public string? TargetUrl { get; init; } + public string? Comment { get; init; } + public WorkItemSummaryDto? TargetSummary { get; init; } +} + +/// +/// Data transfer object representing the result of querying work item relations. +/// +public sealed record WorkItemRelationsResultDto +{ + public int WorkItemId { get; init; } + public int Count { get; init; } + public IReadOnlyList Relations { get; init; } = []; +} diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemTreeNodeDto.cs b/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemTreeNodeDto.cs new file mode 100644 index 0000000..f7d97d5 --- /dev/null +++ b/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemTreeNodeDto.cs @@ -0,0 +1,10 @@ +namespace Viamus.Azure.Devops.Mcp.Server.Models; + +/// +/// Data transfer object representing a node in a recursive work item hierarchy tree. +/// +public sealed record WorkItemTreeNodeDto +{ + public WorkItemDto WorkItem { get; init; } = null!; + public IReadOnlyList Children { get; init; } = []; +} diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs index f4edc32..eac4b1d 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs @@ -217,7 +217,10 @@ private static string NormalizeOrganizationKey(string organization) => return uri.Host; } - public async Task GetWorkItemAsync(int workItemId, string? project = null, CancellationToken cancellationToken = default) + public Task GetWorkItemAsync(int workItemId, string? project = null, CancellationToken cancellationToken = default) + => GetWorkItemAsync(workItemId, project, includeRelations: false, cancellationToken); + + public async Task GetWorkItemAsync(int workItemId, string? project = null, bool includeRelations = false, CancellationToken cancellationToken = default) { try { @@ -229,7 +232,7 @@ private static string NormalizeOrganizationKey(string organization) => expand: WorkItemExpand.All, cancellationToken: cancellationToken); - return MapToDto(workItem, includeAllFields: true); + return MapToDto(workItem, includeAllFields: true, includeRelations: includeRelations); } catch (Exception ex) { @@ -238,7 +241,10 @@ private static string NormalizeOrganizationKey(string organization) => } } - public async Task> GetWorkItemsAsync(IEnumerable workItemIds, string? project = null, CancellationToken cancellationToken = default) + public Task> GetWorkItemsAsync(IEnumerable workItemIds, string? project = null, CancellationToken cancellationToken = default) + => GetWorkItemsAsync(workItemIds, project, includeRelations: false, cancellationToken); + + public async Task> GetWorkItemsAsync(IEnumerable workItemIds, string? project = null, bool includeRelations = false, CancellationToken cancellationToken = default) { var ids = workItemIds.ToList(); if (ids.Count == 0) @@ -256,7 +262,7 @@ public async Task> GetWorkItemsAsync(IEnumerable expand: WorkItemExpand.All, cancellationToken: cancellationToken); - return workItems.Select(wi => MapToDto(wi, includeAllFields: true)).ToList(); + return workItems.Select(wi => MapToDto(wi, includeAllFields: true, includeRelations: includeRelations)).ToList(); } catch (Exception ex) { @@ -505,7 +511,7 @@ private static WorkItemSummaryDto MapToSummaryDto(WorkItem workItem) }; } - private static WorkItemDto MapToDto(WorkItem workItem, bool includeAllFields = false) + private static WorkItemDto MapToDto(WorkItem workItem, bool includeAllFields = false, bool includeRelations = false) { var fields = workItem.Fields; @@ -615,11 +621,76 @@ private static WorkItemDto MapToDto(WorkItem workItem, bool includeAllFields = f { dto = dto with { LinkedPullRequests = linkedPullRequests }; } + + if (includeRelations) + { + var relationsList = new List(); + foreach (var relation in workItem.Relations) + { + var relType = NormalizeRelationType(relation.Rel); + var targetId = ExtractTargetWorkItemId(relation.Url); + string? comment = null; + + if (relation.Attributes != null && relation.Attributes.TryGetValue("comment", out var commentVal)) + { + comment = commentVal?.ToString(); + } + + relationsList.Add(new WorkItemRelationDto + { + RelationType = relType, + RawRel = relation.Rel, + TargetId = targetId, + TargetUrl = relation.Url, + Comment = comment + }); + } + dto = dto with { Relations = relationsList }; + } } return dto; } + private static readonly Dictionary RelationTypeMappings = new(StringComparer.OrdinalIgnoreCase) + { + ["System.LinkTypes.Hierarchy-Reverse"] = "Parent", + ["System.LinkTypes.Hierarchy-Forward"] = "Child", + ["System.LinkTypes.Related"] = "Related", + ["System.LinkTypes.Dependency-Reverse"] = "Predecessor", + ["System.LinkTypes.Dependency-Forward"] = "Successor", + ["Microsoft.VSTS.Common.TestedBy-Forward"] = "Tested By", + ["Microsoft.VSTS.Common.TestedBy-Reverse"] = "Tests", + ["Hyperlink"] = "Hyperlink", + ["AttachedFile"] = "Attachment" + }; + + private static string NormalizeRelationType(string rawRel) + { + if (string.IsNullOrWhiteSpace(rawRel)) return "Unknown"; + if (RelationTypeMappings.TryGetValue(rawRel, out var normalized)) + { + return normalized; + } + var lastDot = rawRel.LastIndexOf('.'); + if (lastDot >= 0 && lastDot < rawRel.Length - 1) + { + return rawRel[(lastDot + 1)..]; + } + return rawRel; + } + + private static int? ExtractTargetWorkItemId(string url) + { + if (string.IsNullOrWhiteSpace(url)) return null; + var lastSlash = url.LastIndexOf('/'); + if (lastSlash >= 0 && lastSlash < url.Length - 1 && int.TryParse(url[(lastSlash + 1)..], out var parsedId)) + { + return parsedId; + } + return null; + } + /// /// Extracts repository ID and artifact ID from a Git artifact URL. /// URL format: vstfs:///Git/{Type}/{projectId}/{repoId}/{artifactId} @@ -1416,6 +1487,210 @@ public async Task> GetWorkItemsHistoryAs } } + public async Task GetWorkItemRelationsAsync( + int workItemId, + string? relationTypeFilter = null, + bool expandTargetSummary = false, + string? project = null, + CancellationToken cancellationToken = default) + { + try + { + _logger.LogDebug("Getting relations for work item {WorkItemId}", workItemId); + + var workItem = await WitClient.GetWorkItemAsync( + project: project ?? DefaultProject, + id: workItemId, + expand: WorkItemExpand.Relations, + cancellationToken: cancellationToken); + + if (workItem is null) + { + return null; + } + + if (workItem.Relations is null || workItem.Relations.Count == 0) + { + return new WorkItemRelationsResultDto + { + WorkItemId = workItemId, + Count = 0, + Relations = [] + }; + } + + var relationsList = new List(); + var targetsToFetch = new List<(WorkItemRelationDto relation, int targetId)>(); + + foreach (var relation in workItem.Relations) + { + var relType = NormalizeRelationType(relation.Rel); + + if (!string.IsNullOrWhiteSpace(relationTypeFilter) && + !string.Equals(relType, relationTypeFilter, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var targetId = ExtractTargetWorkItemId(relation.Url); + string? comment = null; + + if (relation.Attributes != null && relation.Attributes.TryGetValue("comment", out var commentVal)) + { + comment = commentVal?.ToString(); + } + + var relationDto = new WorkItemRelationDto + { + RelationType = relType, + RawRel = relation.Rel, + TargetId = targetId, + TargetUrl = relation.Url, + Comment = comment + }; + + relationsList.Add(relationDto); + + if (expandTargetSummary && targetId.HasValue) + { + targetsToFetch.Add((relationDto, targetId.Value)); + } + } + + if (expandTargetSummary && targetsToFetch.Count > 0) + { + var targetIds = targetsToFetch.Select(t => t.targetId).Distinct().ToList(); + var summaries = await GetWorkItemSummariesAsync(targetIds, project ?? DefaultProject, cancellationToken); + var summaryLookup = summaries.ToDictionary(s => s.Id); + + var updatedRelations = new List(); + foreach (var rel in relationsList) + { + if (rel.TargetId.HasValue && summaryLookup.TryGetValue(rel.TargetId.Value, out var summary)) + { + updatedRelations.Add(rel with { TargetSummary = summary }); + } + else + { + updatedRelations.Add(rel); + } + } + relationsList = updatedRelations; + } + + return new WorkItemRelationsResultDto + { + WorkItemId = workItemId, + Count = relationsList.Count, + Relations = relationsList + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting relations for work item {WorkItemId}", workItemId); + throw; + } + } + + public async Task GetWorkItemTreeAsync( + int workItemId, + int maxDepth = 2, + string? project = null, + CancellationToken cancellationToken = default) + { + maxDepth = Math.Clamp(maxDepth, 1, 5); + try + { + var rootItem = await GetWorkItemAsync(workItemId, project, includeRelations: true, cancellationToken); + if (rootItem is null) return null; + + var visited = new HashSet { workItemId }; + return await BuildTreeNodeAsync(rootItem, 1, maxDepth, project, visited, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting work item tree for {WorkItemId}", workItemId); + throw; + } + } + + private async Task BuildTreeNodeAsync( + WorkItemDto workItem, + int currentDepth, + int maxDepth, + string? project, + HashSet visited, + CancellationToken cancellationToken) + { + if (currentDepth >= maxDepth || workItem.Relations is null || workItem.Relations.Count == 0) + { + return new WorkItemTreeNodeDto + { + WorkItem = workItem, + Children = Array.Empty() + }; + } + + var childRelations = workItem.Relations + .Where(r => string.Equals(r.RelationType, "Child", StringComparison.OrdinalIgnoreCase) && r.TargetId.HasValue) + .ToList(); + + if (childRelations.Count == 0) + { + return new WorkItemTreeNodeDto + { + WorkItem = workItem, + Children = Array.Empty() + }; + } + + var childrenList = new List(); + var childIdsToFetch = new List(); + + foreach (var rel in childRelations) + { + var childId = rel.TargetId!.Value; + if (!visited.Contains(childId)) + { + visited.Add(childId); + childIdsToFetch.Add(childId); + } + } + + if (childIdsToFetch.Count > 0) + { + var childrenDetails = await GetWorkItemsAsync(childIdsToFetch, project, includeRelations: true, cancellationToken); + var childTasks = childrenDetails.Select(childDto => + BuildTreeNodeAsync(childDto, currentDepth + 1, maxDepth, project, visited, cancellationToken) + ); + var resolvedChildren = await Task.WhenAll(childTasks); + childrenList.AddRange(resolvedChildren); + } + + return new WorkItemTreeNodeDto + { + WorkItem = workItem, + Children = childrenList + }; + } + + private async Task> GetWorkItemSummariesAsync( + IEnumerable ids, + string? project = null, + CancellationToken cancellationToken = default) + { + var idList = ids.ToList(); + if (idList.Count == 0) return Array.Empty(); + + var workItems = await WitClient.GetWorkItemsAsync( + project: project ?? DefaultProject, + ids: idList, + fields: SummaryFields, + cancellationToken: cancellationToken); + + return workItems.Select(MapToSummaryDto).ToList(); + } + #region Git Operations public async Task> GetRepositoriesAsync(string? project = null, CancellationToken cancellationToken = default) diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs index 62dc8e4..943f368 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs @@ -16,6 +16,16 @@ public interface IAzureDevOpsService /// The work item details. Task GetWorkItemAsync(int workItemId, string? project = null, CancellationToken cancellationToken = default); + /// + /// Gets a work item by its ID, with optional relationship details. + /// + /// The work item ID. + /// The project name (optional if default project is configured). + /// Whether to include relation collections. + /// Cancellation token. + /// The work item details. + Task GetWorkItemAsync(int workItemId, string? project = null, bool includeRelations = false, CancellationToken cancellationToken = default); + /// /// Gets multiple work items by their IDs. /// @@ -25,6 +35,37 @@ public interface IAzureDevOpsService /// List of work items. Task> GetWorkItemsAsync(IEnumerable workItemIds, string? project = null, CancellationToken cancellationToken = default); + /// + /// Gets multiple work items by their IDs, with optional relationship details. + /// + /// The work item IDs. + /// The project name (optional if default project is configured). + /// Whether to include relation collections. + /// Cancellation token. + /// List of work items. + Task> GetWorkItemsAsync(IEnumerable workItemIds, string? project = null, bool includeRelations = false, CancellationToken cancellationToken = default); + + /// + /// Gets normalized relations for a work item. + /// + /// The work item ID. + /// Optional filter by relation type name. + /// Whether to fetch full work item summary of the target work items. + /// The project name (optional). + /// Cancellation token. + /// Result containing relations. + Task GetWorkItemRelationsAsync(int workItemId, string? relationTypeFilter = null, bool expandTargetSummary = false, string? project = null, CancellationToken cancellationToken = default); + + /// + /// Recursively retrieves a hierarchical parent/child tree of work items. + /// + /// The root work item ID. + /// Maximum depth to recurse (default 2, max 5). + /// The project name (optional). + /// Cancellation token. + /// The root tree node. + Task GetWorkItemTreeAsync(int workItemId, int maxDepth = 2, string? project = null, CancellationToken cancellationToken = default); + /// /// Queries work items using WIQL (Work Item Query Language). /// diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs b/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs index b92f589..60bba25 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs @@ -38,10 +38,11 @@ public async Task GetWorkItem( [Description("The ID of the work item to retrieve")] int workItemId, [Description("The project name (optional if default project is configured)")] string? project = null, [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, + [Description("Whether to include the work item's relationship collection (default: false)")] bool includeRelations = false, CancellationToken cancellationToken = default) { using var organizationScope = _organizationContextAccessor.Use(organization); - var workItem = await _azureDevOpsService.GetWorkItemAsync(workItemId, project, cancellationToken); + var workItem = await _azureDevOpsService.GetWorkItemAsync(workItemId, project, includeRelations, cancellationToken); if (workItem is null) { @@ -100,6 +101,7 @@ public async Task GetWorkItems( [Description("Comma-separated list of work item IDs to retrieve (e.g., '123,456,789')")] string workItemIds, [Description("The project name (optional if default project is configured)")] string? project = null, [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, + [Description("Whether to include the work items' relationship collections (default: false)")] bool includeRelations = false, CancellationToken cancellationToken = default) { using var organizationScope = _organizationContextAccessor.Use(organization); @@ -110,7 +112,7 @@ public async Task GetWorkItems( return JsonSerializer.Serialize(new { error = "No valid work item IDs provided" }, JsonOptions); } - var workItems = await _azureDevOpsService.GetWorkItemsAsync(ids, project, cancellationToken); + var workItems = await _azureDevOpsService.GetWorkItemsAsync(ids, project, includeRelations, cancellationToken); return JsonSerializer.Serialize(new { count = workItems.Count, workItems }, JsonOptions); } @@ -291,6 +293,57 @@ public async Task LinkWorkItems( }, JsonOptions); } + [McpServerTool(Name = "get_work_item_relations")] + [Description("Gets the normalized list of relationships (Parent, Child, Related, Predecessor, Successor, Tests, Tested By, Hyperlink, Attachment) associated with a work item.")] + public async Task GetWorkItemRelations( + [Description("The ID of the work item to retrieve relations for")] int workItemId, + [Description("Optional relation type to filter by (e.g. 'Related', 'Parent', 'Child', 'Predecessor', 'Successor')")] string? relationTypeFilter = null, + [Description("Whether to retrieve full summaries (ID, Title, State, Type) for the target work items inline (default: false)")] bool expandTargetSummary = false, + [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, + CancellationToken cancellationToken = default) + { + using var organizationScope = _organizationContextAccessor.Use(organization); + if (workItemId <= 0) + { + return JsonSerializer.Serialize(new { error = "Work item ID must be a positive integer" }, JsonOptions); + } + + var result = await _azureDevOpsService.GetWorkItemRelationsAsync( + workItemId, relationTypeFilter, expandTargetSummary, project, cancellationToken); + + if (result is null) + { + return JsonSerializer.Serialize(new { error = $"Work item {workItemId} not found" }, JsonOptions); + } + + return JsonSerializer.Serialize(result, JsonOptions); + } + + [McpServerTool(Name = "get_work_item_tree")] + [Description("Recursively gets parent and child work items starting from a root work item, up to a specified depth limit. Includes cycle detection to prevent infinite recursion on loop links.")] + public async Task GetWorkItemTree( + [Description("The ID of the root work item to build the tree from")] int workItemId, + [Description("Maximum depth to recurse (1-5, default: 2)")] int maxDepth = 2, + [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("The Azure DevOps organization alias or URL (optional if default organization is configured)")] string? organization = null, + CancellationToken cancellationToken = default) + { + using var organizationScope = _organizationContextAccessor.Use(organization); + if (workItemId <= 0) + { + return JsonSerializer.Serialize(new { error = "Work item ID must be a positive integer" }, JsonOptions); + } + + var result = await _azureDevOpsService.GetWorkItemTreeAsync(workItemId, maxDepth, project, cancellationToken); + if (result is null) + { + return JsonSerializer.Serialize(new { error = $"Work item {workItemId} not found" }, JsonOptions); + } + + return JsonSerializer.Serialize(result, 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( diff --git a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/WorkItemRelationDtoTests.cs b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/WorkItemRelationDtoTests.cs new file mode 100644 index 0000000..d9d95be --- /dev/null +++ b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/WorkItemRelationDtoTests.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using Viamus.Azure.Devops.Mcp.Server.Models; + +namespace Viamus.Azure.Devops.Mcp.Server.Tests.Models; + +public class WorkItemRelationDtoTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + [Fact] + public void WorkItemRelationDto_ShouldSerializeToJson() + { + var relation = new WorkItemRelationDto + { + RelationType = "Related", + RawRel = "System.LinkTypes.Related", + TargetId = 456, + TargetUrl = "https://dev.azure.com/org/proj/_apis/wit/workItems/456", + Comment = "defect origin tracking", + TargetSummary = new WorkItemSummaryDto + { + Id = 456, + Title = "Original Bug", + State = "Active", + WorkItemType = "Bug" + } + }; + + var json = JsonSerializer.Serialize(relation, JsonOptions); + + Assert.Contains("\"relationType\":\"Related\"", json); + Assert.Contains("\"rawRel\":\"System.LinkTypes.Related\"", json); + Assert.Contains("\"targetId\":456", json); + Assert.Contains("\"comment\":\"defect origin tracking\"", json); + Assert.Contains("\"targetSummary\":", json); + Assert.Contains("\"title\":\"Original Bug\"", json); + } + + [Fact] + public void WorkItemRelationsResultDto_ShouldSerializeToJson() + { + var result = new WorkItemRelationsResultDto + { + WorkItemId = 123, + Count = 1, + Relations = new List + { + new() + { + RelationType = "Parent", + RawRel = "System.LinkTypes.Hierarchy-Reverse", + TargetId = 789 + } + } + }; + + var json = JsonSerializer.Serialize(result, JsonOptions); + + Assert.Contains("\"workItemId\":123", json); + Assert.Contains("\"count\":1", json); + Assert.Contains("\"relations\":", json); + Assert.Contains("\"relationType\":\"Parent\"", json); + } + + [Fact] + public void WorkItemTreeNodeDto_ShouldSerializeToJson() + { + var treeNode = new WorkItemTreeNodeDto + { + WorkItem = new WorkItemDto { Id = 1, Title = "Epic" }, + Children = new List + { + new() + { + WorkItem = new WorkItemDto { Id = 2, Title = "Feature" }, + Children = [] + } + } + }; + + var json = JsonSerializer.Serialize(treeNode, JsonOptions); + + Assert.Contains("\"workItem\":", json); + Assert.Contains("\"title\":\"Epic\"", json); + Assert.Contains("\"children\":", json); + Assert.Contains("\"title\":\"Feature\"", json); + } +} 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 85d0437..78cdbe1 100644 --- a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/WorkItemToolsTests.cs +++ b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/WorkItemToolsTests.cs @@ -35,7 +35,7 @@ public async Task GetWorkItem_WhenWorkItemExists_ShouldReturnSerializedWorkItem( }; _mockService - .Setup(s => s.GetWorkItemAsync(123, null, It.IsAny())) + .Setup(s => s.GetWorkItemAsync(123, null, It.IsAny(), It.IsAny())) .ReturnsAsync(workItem); var result = await _tools.GetWorkItem(123); @@ -49,7 +49,7 @@ public async Task GetWorkItem_WhenWorkItemExists_ShouldReturnSerializedWorkItem( public async Task GetWorkItem_WhenWorkItemNotFound_ShouldReturnError() { _mockService - .Setup(s => s.GetWorkItemAsync(999, null, It.IsAny())) + .Setup(s => s.GetWorkItemAsync(999, null, It.IsAny(), It.IsAny())) .ReturnsAsync((WorkItemDto?)null); var result = await _tools.GetWorkItem(999); @@ -63,12 +63,12 @@ public async Task GetWorkItem_WithProject_ShouldPassProjectToService() { var workItem = new WorkItemDto { Id = 123, Title = "Test" }; _mockService - .Setup(s => s.GetWorkItemAsync(123, "MyProject", It.IsAny())) + .Setup(s => s.GetWorkItemAsync(123, "MyProject", It.IsAny(), It.IsAny())) .ReturnsAsync(workItem); await _tools.GetWorkItem(123, "MyProject"); - _mockService.Verify(s => s.GetWorkItemAsync(123, "MyProject", It.IsAny()), Times.Once); + _mockService.Verify(s => s.GetWorkItemAsync(123, "MyProject", It.IsAny(), It.IsAny()), Times.Once); } #endregion @@ -85,7 +85,7 @@ public async Task GetWorkItems_WithValidIds_ShouldReturnWorkItems() }; _mockService - .Setup(s => s.GetWorkItemsAsync(It.Is>(ids => ids.Contains(1) && ids.Contains(2)), null, It.IsAny())) + .Setup(s => s.GetWorkItemsAsync(It.Is>(ids => ids.Contains(1) && ids.Contains(2)), null, It.IsAny(), It.IsAny())) .ReturnsAsync(workItems); var result = await _tools.GetWorkItems("1,2"); @@ -131,7 +131,7 @@ public async Task GetWorkItems_WithMixedValidAndInvalidIds_ShouldProcessValidOne }; _mockService - .Setup(s => s.GetWorkItemsAsync(It.Is>(ids => ids.Count() == 1 && ids.Contains(1)), null, It.IsAny())) + .Setup(s => s.GetWorkItemsAsync(It.Is>(ids => ids.Count() == 1 && ids.Contains(1)), null, It.IsAny(), It.IsAny())) .ReturnsAsync(workItems); var result = await _tools.GetWorkItems("1,abc,xyz"); @@ -148,12 +148,12 @@ public async Task GetWorkItems_WithDuplicateIds_ShouldProcessDistinct() }; _mockService - .Setup(s => s.GetWorkItemsAsync(It.Is>(ids => ids.Count() == 1), null, It.IsAny())) + .Setup(s => s.GetWorkItemsAsync(It.Is>(ids => ids.Count() == 1), null, It.IsAny(), It.IsAny())) .ReturnsAsync(workItems); var result = await _tools.GetWorkItems("1,1,1"); - _mockService.Verify(s => s.GetWorkItemsAsync(It.Is>(ids => ids.Count() == 1), null, It.IsAny()), Times.Once); + _mockService.Verify(s => s.GetWorkItemsAsync(It.Is>(ids => ids.Count() == 1), null, It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -166,7 +166,7 @@ public async Task GetWorkItems_WithSpacesAroundIds_ShouldTrimAndProcess() }; _mockService - .Setup(s => s.GetWorkItemsAsync(It.IsAny>(), null, It.IsAny())) + .Setup(s => s.GetWorkItemsAsync(It.IsAny>(), null, It.IsAny(), It.IsAny())) .ReturnsAsync(workItems); var result = await _tools.GetWorkItems(" 1 , 2 "); @@ -1156,12 +1156,67 @@ public async Task GetWorkItemsHistory_WithBatchIds_ShouldReturnBatchResults() } [Fact] - public async Task GetWorkItemsHistory_WithNoValidIds_ShouldReturnError() + public async Task GetWorkItemRelations_WithValidId_ShouldReturnRelations() { - var result = await _tools.GetWorkItemsHistory("abc,xyz"); + var relationsResult = new WorkItemRelationsResultDto + { + WorkItemId = 123, + Count = 1, + Relations = new List + { + new() { RelationType = "Related", RawRel = "System.LinkTypes.Related", TargetId = 456 } + } + }; + + _mockService + .Setup(s => s.GetWorkItemRelationsAsync(123, null, false, null, It.IsAny())) + .ReturnsAsync(relationsResult); + + var result = await _tools.GetWorkItemRelations(123); + + Assert.Contains("\"workItemId\": 123", result); + Assert.Contains("\"relationType\": \"Related\"", result); + Assert.Contains("\"targetId\": 456", result); + } + + [Fact] + public async Task GetWorkItemRelations_WithInvalidId_ShouldReturnError() + { + var result = await _tools.GetWorkItemRelations(0); Assert.Contains("error", result); - Assert.Contains("No valid work item IDs provided", result); + Assert.Contains("must be a positive integer", result); + } + + [Fact] + public async Task GetWorkItemTree_WithValidId_ShouldReturnTree() + { + var treeResult = new WorkItemTreeNodeDto + { + WorkItem = new WorkItemDto { Id = 1, Title = "Root Epic" }, + Children = new List + { + new() { WorkItem = new WorkItemDto { Id = 2, Title = "Child Feature" }, Children = [] } + } + }; + + _mockService + .Setup(s => s.GetWorkItemTreeAsync(1, 2, null, It.IsAny())) + .ReturnsAsync(treeResult); + + var result = await _tools.GetWorkItemTree(1); + + Assert.Contains("\"title\": \"Root Epic\"", result); + Assert.Contains("\"title\": \"Child Feature\"", result); + } + + [Fact] + public async Task GetWorkItemTree_WithInvalidId_ShouldReturnError() + { + var result = await _tools.GetWorkItemTree(-5); + + Assert.Contains("error", result); + Assert.Contains("must be a positive integer", result); } #endregion