From 8d91c097f63f4536459586f61f82e897f90ae1ac Mon Sep 17 00:00:00 2001 From: viamu Date: Tue, 24 Feb 2026 09:27:17 -0300 Subject: [PATCH] feat: add ActivatedDate/ClosedDate to work items and remove analytics tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ActivatedDate and ClosedDate fields to WorkItemDto and WorkItemSummaryDto - Include Microsoft.VSTS.Common.ActivatedDate and ClosedDate in DefaultFields and SummaryFields so they are fetched from Azure DevOps - Map both fields in MapToDto and MapToSummaryDto - Remove AnalyticsTools (get_flow_metrics, compare_flow_metrics, get_wip_analysis, get_bottlenecks, get_team_workload, get_aging_report) and their supporting models (FlowMetricsDto, WipAnalysisDto) — Claude can now derive these metrics directly from ActivatedDate/ClosedDate on the work item data - Update README to reflect all changes Co-Authored-By: Claude Sonnet 4.6 --- README.md | 94 +- .../Models/FlowMetricsDto.cs | 170 --- .../Models/WipAnalysisDto.cs | 463 ------ .../Models/WorkItemDto.cs | 2 + .../Models/WorkItemSummaryDto.cs | 2 + .../Services/AzureDevOpsService.cs | 8 + .../Tools/AnalyticsTools.cs | 1334 ----------------- .../Viamus.Azure.Devops.Mcp.Server.csproj | 2 +- .../Models/FlowMetricsDtoTests.cs | 351 ----- .../Models/WipAnalysisDtoTests.cs | 536 ------- .../Models/WorkItemSummaryDtoTests.cs | 6 + 11 files changed, 23 insertions(+), 2945 deletions(-) delete mode 100644 src/Viamus.Azure.Devops.Mcp.Server/Models/FlowMetricsDto.cs delete mode 100644 src/Viamus.Azure.Devops.Mcp.Server/Models/WipAnalysisDto.cs delete mode 100644 src/Viamus.Azure.Devops.Mcp.Server/Tools/AnalyticsTools.cs delete mode 100644 tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/FlowMetricsDtoTests.cs delete mode 100644 tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/WipAnalysisDtoTests.cs diff --git a/README.md b/README.md index e1ad687..4571628 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for Azure DevOps integration, enabling AI assistants to interact with Azure DevOps Work Items, Git Repositories, Pull Requests, and Pipelines. +> **Note**: Flow metrics, WIP analysis, and other analytics features were removed. With `ActivatedDate` and `ClosedDate` now exposed on all work item queries, Claude can derive cycle time, lead time, aging, and throughput directly from the data — no pre-built analytics tools needed. + --- ## Quick Start @@ -163,40 +165,6 @@ This project implements an MCP server that exposes tools for querying and managi | `get_build_timeline` | Gets the timeline (stages, jobs, tasks) for a build | | `query_builds` | Advanced query with multiple combined filters | -### Analytics Tools - -Tools for analyzing delivery performance, identifying bottlenecks, and tracking work health. - -| Tool | Description | -|------|-------------| -| `get_flow_metrics` | Calculates Lead Time, Cycle Time, and Throughput with statistical analysis | -| `compare_flow_metrics` | Compares metrics between two periods to identify trends | -| `get_wip_analysis` | Analyzes Work in Progress by state, area, and person | -| `get_bottlenecks` | Identifies workflow bottlenecks with severity scores and recommendations | -| `get_team_workload` | Analyzes workload distribution across team members | -| `get_aging_report` | Reports aging work items with urgency classification and recommendations | - -#### Flow Metrics - -- **Lead Time**: Time from work item creation to completion (measures total delivery time) -- **Cycle Time**: Time from work started to completion (measures active work time) -- **Throughput**: Number of items completed per period - -#### WIP Analysis - -- **By State**: Where work is accumulating -- **By Area**: Which teams/squads are overloaded -- **By Person**: Who has too many items assigned -- **Aging Items**: Work that has been stuck too long - -#### Aging Report - -Items are classified by urgency based on: -- Priority (P1 items age faster than P4) -- Time since last update -- Current state (blocked items are more urgent) -- Assignment status (unassigned items need attention) - --- ## Prerequisites @@ -434,19 +402,6 @@ After configuring the MCP client, you can ask questions like: - "Show me the timeline of build #123" - "What builds are currently in progress?" -### Analytics and Insights - -- "What's our average delivery time (lead time)?" -- "Are we delivering faster or slower than last month?" -- "Where is work piling up? Show me bottlenecks" -- "Who on the team is overloaded?" -- "What items have been stuck for too long?" -- "Show me aging work items that need attention" -- "How many items did we complete last week?" -- "What's our cycle time for bugs vs user stories?" -- "Is the 'Backend' team overloaded?" -- "Compare this sprint's throughput to the previous sprint" - --- ## Troubleshooting @@ -541,16 +496,13 @@ mcp-azure-devops/ │ │ ├── WorkItemDto.cs # Work item models │ │ ├── RepositoryDto.cs # Git repository models │ │ ├── PullRequestDto.cs # Pull request models -│ │ ├── PipelineDto.cs # Pipeline/build models -│ │ ├── FlowMetricsDto.cs # Flow metrics models -│ │ └── WipAnalysisDto.cs # WIP and aging models +│ │ └── PipelineDto.cs # Pipeline/build models │ ├── Services/ # Azure DevOps SDK integration │ ├── Tools/ # MCP tool implementations │ │ ├── WorkItemTools.cs # Work item operations │ │ ├── GitTools.cs # Git repository operations │ │ ├── PullRequestTools.cs # Pull request operations -│ │ ├── PipelineTools.cs # Pipeline/build operations -│ │ └── AnalyticsTools.cs # Analytics and metrics +│ │ └── PipelineTools.cs # Pipeline/build operations │ ├── Program.cs # Entry point │ ├── appsettings.json # App settings │ └── Dockerfile # Container definition @@ -636,44 +588,6 @@ Build log metadata with Id, Type, Url, LineCount, and timestamps. #### BuildTimelineRecordDto Timeline record (stage, job, or task) with Id, ParentId, Type, Name, State, Result, timing information, and error/warning counts. -### Analytics DTOs - -#### FlowMetricsDto -Flow metrics analysis results including: -- PeriodStart, PeriodEnd, Throughput (total completed items) -- LeadTime, CycleTime (MetricStatistics with average, median, percentiles) -- ThroughputByPeriod (breakdown by day/week) -- ThroughputByType (breakdown by work item type) -- Items (optional detailed list) - -#### MetricStatistics -Statistical measures for a metric including Average, Median, Percentile85, Percentile95, Min, Max, StdDev, and Count. - -#### WipAnalysisDto -Work in Progress analysis including: -- TotalWip, ByState, ByArea, ByPerson, ByType -- AgingItems (items stuck too long) -- Insights (auto-generated observations) - -#### BottleneckAnalysisDto -Bottleneck identification results with: -- Bottlenecks (list ordered by severity) -- Recommendations (actionable suggestions) - -#### BottleneckDto -Individual bottleneck with Type (State/Person/Area), Location, ItemCount, Severity (1-10), Description, and SampleItemIds. - -#### AgingReportDto -Comprehensive aging analysis including: -- TotalAgingItems, Summary (statistics) -- ByUrgency (Critical/High/Medium/Low item IDs) -- ByState, ByAssignee, ByArea (grouped counts) -- Items (detailed list with recommendations) -- Recommendations (overall action items) - -#### AgingItemDetailDto -Detailed aging item with Id, Title, WorkItemType, State, AssignedTo, AreaPath, Priority, DaysSinceUpdate, DaysSinceCreation, Urgency, UrgencyScore, and Recommendation. - --- ## Development diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Models/FlowMetricsDto.cs b/src/Viamus.Azure.Devops.Mcp.Server/Models/FlowMetricsDto.cs deleted file mode 100644 index d3aaaeb..0000000 --- a/src/Viamus.Azure.Devops.Mcp.Server/Models/FlowMetricsDto.cs +++ /dev/null @@ -1,170 +0,0 @@ -namespace Viamus.Azure.Devops.Mcp.Server.Models; - -/// -/// Flow metrics analysis results. -/// -public sealed record FlowMetricsDto -{ - /// - /// Analysis period start date. - /// - public DateTime PeriodStart { get; init; } - - /// - /// Analysis period end date. - /// - public DateTime PeriodEnd { get; init; } - - /// - /// Total number of completed work items in the period. - /// - public int Throughput { get; init; } - - /// - /// Lead time statistics in days (from creation to completion). - /// - public MetricStatistics LeadTime { get; init; } = new(); - - /// - /// Cycle time statistics in days (from started to completion). - /// - public MetricStatistics CycleTime { get; init; } = new(); - - /// - /// Throughput breakdown by period (day/week). - /// - public List ThroughputByPeriod { get; init; } = []; - - /// - /// Throughput breakdown by work item type. - /// - public Dictionary ThroughputByType { get; init; } = new(); - - /// - /// Individual work item flow data for detailed analysis. - /// - public List Items { get; init; } = []; -} - -/// -/// Statistical measures for a metric. -/// -public sealed record MetricStatistics -{ - /// - /// Average value. - /// - public double Average { get; init; } - - /// - /// Median value (50th percentile). - /// - public double Median { get; init; } - - /// - /// 85th percentile value. - /// - public double Percentile85 { get; init; } - - /// - /// 95th percentile value. - /// - public double Percentile95 { get; init; } - - /// - /// Minimum value. - /// - public double Min { get; init; } - - /// - /// Maximum value. - /// - public double Max { get; init; } - - /// - /// Standard deviation. - /// - public double StdDev { get; init; } - - /// - /// Number of items in the sample. - /// - public int Count { get; init; } -} - -/// -/// Throughput data for a specific period. -/// -public sealed record ThroughputPeriod -{ - /// - /// Period start date. - /// - public DateTime PeriodStart { get; init; } - - /// - /// Period end date. - /// - public DateTime PeriodEnd { get; init; } - - /// - /// Number of items completed in this period. - /// - public int Count { get; init; } -} - -/// -/// Flow data for a single work item. -/// -public sealed record WorkItemFlowDataDto -{ - /// - /// Work item ID. - /// - public int Id { get; init; } - - /// - /// Work item title. - /// - public string? Title { get; init; } - - /// - /// Work item type (Bug, Task, User Story, etc.). - /// - public string? WorkItemType { get; init; } - - /// - /// Date the work item was created. - /// - public DateTime? CreatedDate { get; init; } - - /// - /// Date the work item was started (entered In Progress or equivalent). - /// - public DateTime? StartedDate { get; init; } - - /// - /// Date the work item was completed. - /// - public DateTime? CompletedDate { get; init; } - - /// - /// Lead time in days (created to completed). - /// - public double? LeadTimeDays { get; init; } - - /// - /// Cycle time in days (started to completed). - /// - public double? CycleTimeDays { get; init; } - - /// - /// Area path of the work item. - /// - public string? AreaPath { get; init; } - - /// - /// Iteration path of the work item. - /// - public string? IterationPath { get; init; } -} diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Models/WipAnalysisDto.cs b/src/Viamus.Azure.Devops.Mcp.Server/Models/WipAnalysisDto.cs deleted file mode 100644 index 5308ac9..0000000 --- a/src/Viamus.Azure.Devops.Mcp.Server/Models/WipAnalysisDto.cs +++ /dev/null @@ -1,463 +0,0 @@ -namespace Viamus.Azure.Devops.Mcp.Server.Models; - -/// -/// Work in Progress (WIP) analysis results. -/// -public sealed record WipAnalysisDto -{ - /// - /// Analysis timestamp. - /// - public DateTime AnalysisDate { get; init; } = DateTime.UtcNow; - - /// - /// Total number of items in progress (not completed). - /// - public int TotalWip { get; init; } - - /// - /// WIP breakdown by state. - /// - public List ByState { get; init; } = []; - - /// - /// WIP breakdown by area path (team/squad). - /// - public List ByArea { get; init; } = []; - - /// - /// WIP breakdown by assigned person. - /// - public List ByPerson { get; init; } = []; - - /// - /// WIP breakdown by work item type. - /// - public Dictionary ByType { get; init; } = new(); - - /// - /// Items that have been in progress for too long (aging items). - /// - public List AgingItems { get; init; } = []; - - /// - /// Summary insights about the WIP status. - /// - public List Insights { get; init; } = []; -} - -/// -/// WIP count by state. -/// -public sealed record WipByStateDto -{ - /// - /// State name. - /// - public string State { get; init; } = string.Empty; - - /// - /// Number of items in this state. - /// - public int Count { get; init; } - - /// - /// Percentage of total WIP. - /// - public double Percentage { get; init; } - - /// - /// Average age in days for items in this state. - /// - public double AverageAgeDays { get; init; } -} - -/// -/// WIP count by area path (team/squad). -/// -public sealed record WipByAreaDto -{ - /// - /// Area path. - /// - public string AreaPath { get; init; } = string.Empty; - - /// - /// Number of items in this area. - /// - public int Count { get; init; } - - /// - /// Number of unique assignees in this area. - /// - public int UniqueAssignees { get; init; } - - /// - /// Average items per person in this area. - /// - public double ItemsPerPerson { get; init; } - - /// - /// Whether this area appears overloaded (high items per person). - /// - public bool IsOverloaded { get; init; } -} - -/// -/// WIP count by person. -/// -public sealed record WipByPersonDto -{ - /// - /// Person's display name. - /// - public string AssignedTo { get; init; } = string.Empty; - - /// - /// Number of items assigned to this person. - /// - public int Count { get; init; } - - /// - /// Breakdown by state for this person. - /// - public Dictionary ByState { get; init; } = new(); - - /// - /// Whether this person appears overloaded. - /// - public bool IsOverloaded { get; init; } - - /// - /// Oldest item age in days assigned to this person. - /// - public double OldestItemAgeDays { get; init; } -} - -/// -/// Work item that has been in progress for too long. -/// -public sealed record AgingWorkItemDto -{ - /// - /// Work item ID. - /// - public int Id { get; init; } - - /// - /// Work item title. - /// - public string? Title { get; init; } - - /// - /// Work item type. - /// - public string? WorkItemType { get; init; } - - /// - /// Current state. - /// - public string? State { get; init; } - - /// - /// Person assigned to this item. - /// - public string? AssignedTo { get; init; } - - /// - /// Area path. - /// - public string? AreaPath { get; init; } - - /// - /// Date when the item entered current state. - /// - public DateTime? StateChangedDate { get; init; } - - /// - /// Days in current state. - /// - public double DaysInState { get; init; } - - /// - /// Days since creation. - /// - public double DaysSinceCreation { get; init; } - - /// - /// Priority of the item. - /// - public string? Priority { get; init; } - - /// - /// Reason for aging classification. - /// - public string AgingReason { get; init; } = string.Empty; -} - -/// -/// Bottleneck analysis results. -/// -public sealed record BottleneckAnalysisDto -{ - /// - /// Analysis timestamp. - /// - public DateTime AnalysisDate { get; init; } = DateTime.UtcNow; - - /// - /// Identified bottlenecks ordered by severity. - /// - public List Bottlenecks { get; init; } = []; - - /// - /// Recommendations for addressing bottlenecks. - /// - public List Recommendations { get; init; } = []; -} - -/// -/// A single bottleneck in the flow. -/// -public sealed record BottleneckDto -{ - /// - /// Type of bottleneck (State, Person, Area, Type). - /// - public string Type { get; init; } = string.Empty; - - /// - /// Name/identifier of the bottleneck location. - /// - public string Location { get; init; } = string.Empty; - - /// - /// Number of items blocked/waiting. - /// - public int ItemCount { get; init; } - - /// - /// Severity score (1-10). - /// - public int Severity { get; init; } - - /// - /// Description of the bottleneck. - /// - public string Description { get; init; } = string.Empty; - - /// - /// Sample work item IDs affected. - /// - public List SampleItemIds { get; init; } = []; -} - -/// -/// Aging report analysis results. -/// -public sealed record AgingReportDto -{ - /// - /// Analysis timestamp. - /// - public DateTime AnalysisDate { get; init; } = DateTime.UtcNow; - - /// - /// Total number of aging items. - /// - public int TotalAgingItems { get; init; } - - /// - /// Summary statistics about aging. - /// - public AgingSummaryDto Summary { get; init; } = new(); - - /// - /// Items grouped by urgency level. - /// - public AgingByUrgencyDto ByUrgency { get; init; } = new(); - - /// - /// Aging items grouped by state. - /// - public List ByState { get; init; } = []; - - /// - /// Aging items grouped by assignee. - /// - public List ByAssignee { get; init; } = []; - - /// - /// Aging items grouped by area. - /// - public List ByArea { get; init; } = []; - - /// - /// Detailed list of aging items with recommendations. - /// - public List Items { get; init; } = []; - - /// - /// Overall recommendations for addressing aging work. - /// - public List Recommendations { get; init; } = []; -} - -/// -/// Summary statistics for aging analysis. -/// -public sealed record AgingSummaryDto -{ - /// - /// Average age of all aging items in days. - /// - public double AverageAgeDays { get; init; } - - /// - /// Median age in days. - /// - public double MedianAgeDays { get; init; } - - /// - /// Maximum age in days. - /// - public double MaxAgeDays { get; init; } - - /// - /// Percentage of total WIP that is aging. - /// - public double PercentageOfWip { get; init; } - - /// - /// States with most aging items. - /// - public string TopAgingState { get; init; } = string.Empty; - - /// - /// Person with most aging items. - /// - public string TopAgingAssignee { get; init; } = string.Empty; -} - -/// -/// Aging items grouped by urgency level. -/// -public sealed record AgingByUrgencyDto -{ - /// - /// Critical items (highest priority, very old). - /// - public List Critical { get; init; } = []; - - /// - /// High urgency items. - /// - public List High { get; init; } = []; - - /// - /// Medium urgency items. - /// - public List Medium { get; init; } = []; - - /// - /// Low urgency items. - /// - public List Low { get; init; } = []; -} - -/// -/// Aging statistics for a group (state, person, area). -/// -public sealed record AgingByGroupDto -{ - /// - /// Group name (state name, person name, or area path). - /// - public string Name { get; init; } = string.Empty; - - /// - /// Number of aging items in this group. - /// - public int Count { get; init; } - - /// - /// Average age in days for this group. - /// - public double AverageAgeDays { get; init; } - - /// - /// Oldest item age in this group. - /// - public double MaxAgeDays { get; init; } - - /// - /// List of work item IDs in this group. - /// - public List ItemIds { get; init; } = []; -} - -/// -/// Detailed aging item with recommendation. -/// -public sealed record AgingItemDetailDto -{ - /// - /// Work item ID. - /// - public int Id { get; init; } - - /// - /// Work item title. - /// - public string? Title { get; init; } - - /// - /// Work item type. - /// - public string? WorkItemType { get; init; } - - /// - /// Current state. - /// - public string? State { get; init; } - - /// - /// Assigned person. - /// - public string? AssignedTo { get; init; } - - /// - /// Area path. - /// - public string? AreaPath { get; init; } - - /// - /// Priority (1=highest). - /// - public string? Priority { get; init; } - - /// - /// Days since last update. - /// - public double DaysSinceUpdate { get; init; } - - /// - /// Days since creation. - /// - public double DaysSinceCreation { get; init; } - - /// - /// Urgency classification (Critical, High, Medium, Low). - /// - public string Urgency { get; init; } = string.Empty; - - /// - /// Urgency score (1-10, higher is more urgent). - /// - public int UrgencyScore { get; init; } - - /// - /// Specific recommendation for this item. - /// - public string Recommendation { get; init; } = string.Empty; -} diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemDto.cs b/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemDto.cs index 56338a0..03c5d5b 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemDto.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemDto.cs @@ -17,6 +17,8 @@ public sealed record WorkItemDto public string? Severity { get; init; } public DateTime? CreatedDate { get; init; } public DateTime? ChangedDate { get; init; } + public DateTime? ActivatedDate { get; init; } + public DateTime? ClosedDate { get; init; } public string? CreatedBy { get; init; } public string? ChangedBy { get; init; } public string? Reason { get; init; } diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemSummaryDto.cs b/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemSummaryDto.cs index ba9a8c5..7a6f8ed 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemSummaryDto.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemSummaryDto.cs @@ -13,5 +13,7 @@ public sealed record WorkItemSummaryDto public string? AssignedTo { get; init; } public string? Priority { get; init; } public DateTime? ChangedDate { get; init; } + public DateTime? ActivatedDate { get; init; } + public DateTime? ClosedDate { get; init; } public int? ParentId { 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 c13ef83..38b9704 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs @@ -39,6 +39,8 @@ public sealed class AzureDevOpsService : IAzureDevOpsService, IDisposable "Microsoft.VSTS.Common.Severity", "System.CreatedDate", "System.ChangedDate", + "Microsoft.VSTS.Common.ActivatedDate", + "Microsoft.VSTS.Common.ClosedDate", "System.CreatedBy", "System.ChangedBy", "System.Reason", @@ -54,6 +56,8 @@ public sealed class AzureDevOpsService : IAzureDevOpsService, IDisposable "System.AssignedTo", "Microsoft.VSTS.Common.Priority", "System.ChangedDate", + "Microsoft.VSTS.Common.ActivatedDate", + "Microsoft.VSTS.Common.ClosedDate", "System.Parent" ]; @@ -294,6 +298,8 @@ private static WorkItemSummaryDto MapToSummaryDto(WorkItem workItem) AssignedTo = GetIdentityFieldValue(fields, "System.AssignedTo"), Priority = GetFieldValue(fields, "Microsoft.VSTS.Common.Priority")?.ToString(), ChangedDate = GetFieldValue(fields, "System.ChangedDate"), + ActivatedDate = GetFieldValue(fields, "Microsoft.VSTS.Common.ActivatedDate"), + ClosedDate = GetFieldValue(fields, "Microsoft.VSTS.Common.ClosedDate"), ParentId = GetFieldValue(fields, "System.Parent") }; } @@ -316,6 +322,8 @@ private static WorkItemDto MapToDto(WorkItem workItem, bool includeAllFields = f Severity = GetFieldValue(fields, "Microsoft.VSTS.Common.Severity"), CreatedDate = GetFieldValue(fields, "System.CreatedDate"), ChangedDate = GetFieldValue(fields, "System.ChangedDate"), + ActivatedDate = GetFieldValue(fields, "Microsoft.VSTS.Common.ActivatedDate"), + ClosedDate = GetFieldValue(fields, "Microsoft.VSTS.Common.ClosedDate"), CreatedBy = GetIdentityFieldValue(fields, "System.CreatedBy"), ChangedBy = GetIdentityFieldValue(fields, "System.ChangedBy"), Reason = GetFieldValue(fields, "System.Reason"), diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Tools/AnalyticsTools.cs b/src/Viamus.Azure.Devops.Mcp.Server/Tools/AnalyticsTools.cs deleted file mode 100644 index aa7ec6a..0000000 --- a/src/Viamus.Azure.Devops.Mcp.Server/Tools/AnalyticsTools.cs +++ /dev/null @@ -1,1334 +0,0 @@ -using System.ComponentModel; -using System.Text.Json; -using Microsoft.Extensions.Options; -using Microsoft.TeamFoundation.WorkItemTracking.WebApi; -using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; -using Microsoft.VisualStudio.Services.Common; -using Microsoft.VisualStudio.Services.WebApi; -using ModelContextProtocol.Server; -using Viamus.Azure.Devops.Mcp.Server.Configuration; -using Viamus.Azure.Devops.Mcp.Server.Models; -using Viamus.Azure.Devops.Mcp.Server.Services; - -namespace Viamus.Azure.Devops.Mcp.Server.Tools; - -/// -/// MCP tools for Azure DevOps Analytics operations. -/// Provides flow metrics, throughput analysis, and delivery insights. -/// -[McpServerToolType] -public sealed class AnalyticsTools -{ - private readonly IAzureDevOpsService _azureDevOpsService; - private readonly AzureDevOpsOptions _options; - private readonly ILogger _logger; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - // States that indicate work has started - private static readonly HashSet InProgressStates = new(StringComparer.OrdinalIgnoreCase) - { - "Active", "In Progress", "Committed", "Doing", "In Development", "Development", - "In Review", "In Test", "Testing", "Code Review", "Resolved" - }; - - // States that indicate work is completed - private static readonly HashSet CompletedStates = new(StringComparer.OrdinalIgnoreCase) - { - "Done", "Closed", "Completed", "Resolved", "Removed" - }; - - public AnalyticsTools( - IAzureDevOpsService azureDevOpsService, - IOptions options, - ILogger logger) - { - _azureDevOpsService = azureDevOpsService; - _options = options.Value; - _logger = logger; - } - - [McpServerTool(Name = "get_flow_metrics")] - [Description(@"Calculates flow metrics (Lead Time, Cycle Time, Throughput) for completed work items in a given period. - -Lead Time: Time from work item creation to completion (measures total delivery time) -Cycle Time: Time from work started to completion (measures active work time) -Throughput: Number of items completed per period - -Returns statistical analysis including average, median, percentiles (85th, 95th), and breakdown by type and period. -Use this to answer questions like 'How fast are we delivering?' or 'Is our delivery speed improving?'")] - public async Task GetFlowMetrics( - [Description("The project name (required)")] string project, - [Description("Number of days to analyze (default: 30, max: 90)")] int daysBack = 30, - [Description("Work item types to include, comma-separated (e.g., 'Bug,User Story'). Leave empty for all types.")] string? workItemTypes = null, - [Description("Area path filter (optional, e.g., 'Project\\Team')")] string? areaPath = null, - [Description("Grouping period for throughput: 'day' or 'week' (default: week)")] string groupBy = "week", - [Description("Include individual work item details in response (default: false)")] bool includeItems = false, - CancellationToken cancellationToken = default) - { - try - { - daysBack = Math.Clamp(daysBack, 1, 90); - var periodEnd = DateTime.UtcNow.Date; - var periodStart = periodEnd.AddDays(-daysBack); - - _logger.LogInformation("Calculating flow metrics for project {Project} from {Start} to {End}", - project, periodStart, periodEnd); - - // Build WIQL query for completed items - var typeFilter = BuildTypeFilter(workItemTypes); - var areaFilter = string.IsNullOrWhiteSpace(areaPath) - ? string.Empty - : $" AND [System.AreaPath] UNDER '{EscapeWiqlString(areaPath)}'"; - - // Query items that were completed (state changed to Done/Closed) in the period - // We look for items changed in the period and filter by completed state - var wiqlQuery = $@" - SELECT [System.Id] - FROM WorkItems - WHERE [System.TeamProject] = '{EscapeWiqlString(project)}' - AND [System.State] IN ('Done', 'Closed', 'Completed', 'Resolved') - AND [System.ChangedDate] >= '{periodStart:yyyy-MM-dd}' - AND [System.ChangedDate] <= '{periodEnd:yyyy-MM-dd}'{typeFilter}{areaFilter} - ORDER BY [System.ChangedDate] DESC"; - - var completedItems = await _azureDevOpsService.QueryWorkItemsAsync(wiqlQuery, project, 500, cancellationToken); - - if (completedItems.Count == 0) - { - return JsonSerializer.Serialize(new - { - message = "No completed work items found in the specified period", - periodStart, - periodEnd, - throughput = 0 - }, JsonOptions); - } - - // Get flow data for each item (including state change history) - var flowDataList = await GetFlowDataForItemsAsync(completedItems, project, periodStart, cancellationToken); - - // Filter to only items actually completed in the period - var itemsInPeriod = flowDataList - .Where(f => f.CompletedDate >= periodStart && f.CompletedDate <= periodEnd) - .ToList(); - - if (itemsInPeriod.Count == 0) - { - return JsonSerializer.Serialize(new - { - message = "No work items were completed in the specified period (items may have been completed earlier)", - periodStart, - periodEnd, - throughput = 0 - }, JsonOptions); - } - - // Calculate metrics - var leadTimes = itemsInPeriod - .Where(f => f.LeadTimeDays.HasValue) - .Select(f => f.LeadTimeDays!.Value) - .ToList(); - - var cycleTimes = itemsInPeriod - .Where(f => f.CycleTimeDays.HasValue) - .Select(f => f.CycleTimeDays!.Value) - .ToList(); - - var throughputByType = itemsInPeriod - .GroupBy(f => f.WorkItemType ?? "Unknown") - .ToDictionary(g => g.Key, g => g.Count()); - - var throughputByPeriod = CalculateThroughputByPeriod(itemsInPeriod, periodStart, periodEnd, groupBy); - - var result = new FlowMetricsDto - { - PeriodStart = periodStart, - PeriodEnd = periodEnd, - Throughput = itemsInPeriod.Count, - LeadTime = CalculateStatistics(leadTimes), - CycleTime = CalculateStatistics(cycleTimes), - ThroughputByType = throughputByType, - ThroughputByPeriod = throughputByPeriod, - Items = includeItems ? itemsInPeriod : [] - }; - - return JsonSerializer.Serialize(result, JsonOptions); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error calculating flow metrics"); - return JsonSerializer.Serialize(new { error = $"Error calculating flow metrics: {ex.Message}" }, JsonOptions); - } - } - - [McpServerTool(Name = "compare_flow_metrics")] - [Description(@"Compares flow metrics between two periods to identify trends. -Shows whether delivery is improving or degrading by comparing Lead Time, Cycle Time, and Throughput. -Use this to answer 'Are we getting faster or slower?' or 'How does this sprint compare to last sprint?'")] - public async Task CompareFlowMetrics( - [Description("The project name (required)")] string project, - [Description("Days in each period to compare (default: 14)")] int periodDays = 14, - [Description("Work item types to include, comma-separated. Leave empty for all types.")] string? workItemTypes = null, - [Description("Area path filter (optional)")] string? areaPath = null, - CancellationToken cancellationToken = default) - { - try - { - periodDays = Math.Clamp(periodDays, 7, 45); - - var currentEnd = DateTime.UtcNow.Date; - var currentStart = currentEnd.AddDays(-periodDays); - var previousEnd = currentStart.AddDays(-1); - var previousStart = previousEnd.AddDays(-periodDays); - - // Get metrics for both periods - var currentMetricsJson = await GetFlowMetrics(project, periodDays, workItemTypes, areaPath, "week", false, cancellationToken); - var currentMetrics = JsonSerializer.Deserialize(currentMetricsJson, JsonOptions); - - // Temporarily adjust to get previous period - var previousMetricsJson = await GetFlowMetricsForPeriod(project, previousStart, previousEnd, workItemTypes, areaPath, cancellationToken); - var previousMetrics = JsonSerializer.Deserialize(previousMetricsJson, JsonOptions); - - var comparison = new - { - currentPeriod = new { start = currentStart, end = currentEnd }, - previousPeriod = new { start = previousStart, end = previousEnd }, - throughput = new - { - current = currentMetrics?.Throughput ?? 0, - previous = previousMetrics?.Throughput ?? 0, - change = (currentMetrics?.Throughput ?? 0) - (previousMetrics?.Throughput ?? 0), - changePercent = CalculateChangePercent(previousMetrics?.Throughput ?? 0, currentMetrics?.Throughput ?? 0), - trend = GetTrend((previousMetrics?.Throughput ?? 0), (currentMetrics?.Throughput ?? 0), true) - }, - leadTime = new - { - currentMedian = currentMetrics?.LeadTime.Median ?? 0, - previousMedian = previousMetrics?.LeadTime.Median ?? 0, - changePercent = CalculateChangePercent(previousMetrics?.LeadTime.Median ?? 0, currentMetrics?.LeadTime.Median ?? 0), - trend = GetTrend(previousMetrics?.LeadTime.Median ?? 0, currentMetrics?.LeadTime.Median ?? 0, false) - }, - cycleTime = new - { - currentMedian = currentMetrics?.CycleTime.Median ?? 0, - previousMedian = previousMetrics?.CycleTime.Median ?? 0, - changePercent = CalculateChangePercent(previousMetrics?.CycleTime.Median ?? 0, currentMetrics?.CycleTime.Median ?? 0), - trend = GetTrend(previousMetrics?.CycleTime.Median ?? 0, currentMetrics?.CycleTime.Median ?? 0, false) - }, - summary = GenerateComparisonSummary(currentMetrics, previousMetrics) - }; - - return JsonSerializer.Serialize(comparison, JsonOptions); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error comparing flow metrics"); - return JsonSerializer.Serialize(new { error = $"Error comparing flow metrics: {ex.Message}" }, JsonOptions); - } - } - - private async Task GetFlowMetricsForPeriod( - string project, - DateTime periodStart, - DateTime periodEnd, - string? workItemTypes, - string? areaPath, - CancellationToken cancellationToken) - { - var typeFilter = BuildTypeFilter(workItemTypes); - var areaFilter = string.IsNullOrWhiteSpace(areaPath) - ? string.Empty - : $" AND [System.AreaPath] UNDER '{EscapeWiqlString(areaPath)}'"; - - var wiqlQuery = $@" - SELECT [System.Id] - FROM WorkItems - WHERE [System.TeamProject] = '{EscapeWiqlString(project)}' - AND [System.State] IN ('Done', 'Closed', 'Completed', 'Resolved') - AND [System.ChangedDate] >= '{periodStart:yyyy-MM-dd}' - AND [System.ChangedDate] <= '{periodEnd:yyyy-MM-dd}'{typeFilter}{areaFilter} - ORDER BY [System.ChangedDate] DESC"; - - var completedItems = await _azureDevOpsService.QueryWorkItemsAsync(wiqlQuery, project, 500, cancellationToken); - - if (completedItems.Count == 0) - { - return JsonSerializer.Serialize(new FlowMetricsDto - { - PeriodStart = periodStart, - PeriodEnd = periodEnd, - Throughput = 0 - }, JsonOptions); - } - - var flowDataList = await GetFlowDataForItemsAsync(completedItems, project, periodStart, cancellationToken); - var itemsInPeriod = flowDataList - .Where(f => f.CompletedDate >= periodStart && f.CompletedDate <= periodEnd) - .ToList(); - - var leadTimes = itemsInPeriod.Where(f => f.LeadTimeDays.HasValue).Select(f => f.LeadTimeDays!.Value).ToList(); - var cycleTimes = itemsInPeriod.Where(f => f.CycleTimeDays.HasValue).Select(f => f.CycleTimeDays!.Value).ToList(); - - return JsonSerializer.Serialize(new FlowMetricsDto - { - PeriodStart = periodStart, - PeriodEnd = periodEnd, - Throughput = itemsInPeriod.Count, - LeadTime = CalculateStatistics(leadTimes), - CycleTime = CalculateStatistics(cycleTimes) - }, JsonOptions); - } - - private async Task> GetFlowDataForItemsAsync( - IReadOnlyList items, - string project, - DateTime periodStart, - CancellationToken cancellationToken) - { - var flowDataList = new List(); - - // Create connection for revision queries - var credentials = new VssBasicCredential(string.Empty, _options.PersonalAccessToken); - using var connection = new VssConnection(new Uri(_options.OrganizationUrl), credentials); - using var witClient = connection.GetClient(); - - foreach (var item in items) - { - try - { - // Get work item revisions to find state transitions - var revisions = await witClient.GetRevisionsAsync( - project: project ?? _options.DefaultProject, - id: item.Id, - cancellationToken: cancellationToken); - - DateTime? startedDate = null; - DateTime? completedDate = null; - - // Find when the item first entered an "in progress" state - // and when it first entered a "completed" state - foreach (var revision in revisions.OrderBy(r => r.Rev)) - { - if (revision.Fields.TryGetValue("System.State", out var stateObj) && - revision.Fields.TryGetValue("System.ChangedDate", out var dateObj)) - { - var state = stateObj?.ToString(); - var changedDate = dateObj is DateTime dt ? dt : DateTime.TryParse(dateObj?.ToString(), out var parsed) ? parsed : (DateTime?)null; - - if (state != null && changedDate.HasValue) - { - if (startedDate == null && InProgressStates.Contains(state)) - { - startedDate = changedDate.Value; - } - - if (CompletedStates.Contains(state)) - { - completedDate = changedDate.Value; - } - } - } - } - - // Calculate times - double? leadTimeDays = null; - double? cycleTimeDays = null; - - if (item.CreatedDate.HasValue && completedDate.HasValue) - { - leadTimeDays = (completedDate.Value - item.CreatedDate.Value).TotalDays; - } - - if (startedDate.HasValue && completedDate.HasValue) - { - cycleTimeDays = (completedDate.Value - startedDate.Value).TotalDays; - } - - flowDataList.Add(new WorkItemFlowDataDto - { - Id = item.Id, - Title = item.Title, - WorkItemType = item.WorkItemType, - CreatedDate = item.CreatedDate, - StartedDate = startedDate, - CompletedDate = completedDate, - LeadTimeDays = leadTimeDays, - CycleTimeDays = cycleTimeDays, - AreaPath = item.AreaPath, - IterationPath = item.IterationPath - }); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to get revisions for work item {WorkItemId}", item.Id); - // Add item with basic data only - flowDataList.Add(new WorkItemFlowDataDto - { - Id = item.Id, - Title = item.Title, - WorkItemType = item.WorkItemType, - CreatedDate = item.CreatedDate, - AreaPath = item.AreaPath, - IterationPath = item.IterationPath - }); - } - } - - return flowDataList; - } - - private static MetricStatistics CalculateStatistics(List values) - { - if (values.Count == 0) - { - return new MetricStatistics { Count = 0 }; - } - - var sorted = values.OrderBy(v => v).ToList(); - var count = sorted.Count; - var sum = sorted.Sum(); - var average = sum / count; - - var squaredDiffs = sorted.Select(v => Math.Pow(v - average, 2)).Sum(); - var stdDev = Math.Sqrt(squaredDiffs / count); - - return new MetricStatistics - { - Average = Math.Round(average, 2), - Median = Math.Round(GetPercentile(sorted, 50), 2), - Percentile85 = Math.Round(GetPercentile(sorted, 85), 2), - Percentile95 = Math.Round(GetPercentile(sorted, 95), 2), - Min = Math.Round(sorted.First(), 2), - Max = Math.Round(sorted.Last(), 2), - StdDev = Math.Round(stdDev, 2), - Count = count - }; - } - - private static double GetPercentile(List sortedValues, double percentile) - { - if (sortedValues.Count == 0) return 0; - if (sortedValues.Count == 1) return sortedValues[0]; - - var index = (percentile / 100.0) * (sortedValues.Count - 1); - var lower = (int)Math.Floor(index); - var upper = (int)Math.Ceiling(index); - - if (lower == upper) return sortedValues[lower]; - - var fraction = index - lower; - return sortedValues[lower] + fraction * (sortedValues[upper] - sortedValues[lower]); - } - - private static List CalculateThroughputByPeriod( - List items, - DateTime periodStart, - DateTime periodEnd, - string groupBy) - { - var periods = new List(); - var isWeekly = groupBy.Equals("week", StringComparison.OrdinalIgnoreCase); - var periodLength = isWeekly ? 7 : 1; - - var currentStart = periodStart; - while (currentStart < periodEnd) - { - var currentEnd = currentStart.AddDays(periodLength); - if (currentEnd > periodEnd) currentEnd = periodEnd; - - var count = items.Count(i => - i.CompletedDate >= currentStart && i.CompletedDate < currentEnd); - - periods.Add(new ThroughputPeriod - { - PeriodStart = currentStart, - PeriodEnd = currentEnd, - Count = count - }); - - currentStart = currentEnd; - } - - return periods; - } - - private static double CalculateChangePercent(double previous, double current) - { - if (previous == 0) return current > 0 ? 100 : 0; - return Math.Round(((current - previous) / previous) * 100, 1); - } - - private static string GetTrend(double previous, double current, bool higherIsBetter) - { - if (Math.Abs(current - previous) < 0.01) return "stable"; - - var isHigher = current > previous; - if (higherIsBetter) - { - return isHigher ? "improving" : "degrading"; - } - else - { - return isHigher ? "degrading" : "improving"; - } - } - - private static string GenerateComparisonSummary(FlowMetricsDto? current, FlowMetricsDto? previous) - { - if (current == null || previous == null) return "Insufficient data for comparison"; - - var insights = new List(); - - // Throughput analysis - if (current.Throughput > previous.Throughput) - { - insights.Add($"Throughput increased by {current.Throughput - previous.Throughput} items ({CalculateChangePercent(previous.Throughput, current.Throughput):+0.#}%)"); - } - else if (current.Throughput < previous.Throughput) - { - insights.Add($"Throughput decreased by {previous.Throughput - current.Throughput} items ({CalculateChangePercent(previous.Throughput, current.Throughput):0.#}%)"); - } - - // Lead time analysis - if (current.LeadTime.Median < previous.LeadTime.Median) - { - insights.Add($"Lead time improved (median: {previous.LeadTime.Median:0.#}d -> {current.LeadTime.Median:0.#}d)"); - } - else if (current.LeadTime.Median > previous.LeadTime.Median) - { - insights.Add($"Lead time degraded (median: {previous.LeadTime.Median:0.#}d -> {current.LeadTime.Median:0.#}d)"); - } - - // Cycle time analysis - if (current.CycleTime.Median < previous.CycleTime.Median) - { - insights.Add($"Cycle time improved (median: {previous.CycleTime.Median:0.#}d -> {current.CycleTime.Median:0.#}d)"); - } - else if (current.CycleTime.Median > previous.CycleTime.Median) - { - insights.Add($"Cycle time degraded (median: {previous.CycleTime.Median:0.#}d -> {current.CycleTime.Median:0.#}d)"); - } - - return insights.Count > 0 ? string.Join(". ", insights) : "Metrics are stable between periods"; - } - - private static string BuildTypeFilter(string? workItemTypes) - { - if (string.IsNullOrWhiteSpace(workItemTypes)) - { - return string.Empty; - } - - var types = workItemTypes - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(t => $"'{EscapeWiqlString(t)}'"); - - return $" AND [System.WorkItemType] IN ({string.Join(", ", types)})"; - } - - private static string EscapeWiqlString(string value) - { - return value.Replace("'", "''"); - } - - #region WIP Analysis - - [McpServerTool(Name = "get_wip_analysis")] - [Description(@"Analyzes Work in Progress (WIP) to identify bottlenecks and overload. - -Returns: -- Total WIP count -- WIP breakdown by state (shows where work is accumulating) -- WIP by area/team (shows which teams are overloaded) -- WIP by person (shows who has too much on their plate) -- Aging items (work that's been stuck too long) - -Use this to answer 'Where is work piling up?', 'Who is overloaded?', or 'What needs attention?'")] - public async Task GetWipAnalysis( - [Description("The project name (required)")] string project, - [Description("Work item types to include, comma-separated. Leave empty for all types.")] string? workItemTypes = null, - [Description("Area path filter (optional)")] string? areaPath = null, - [Description("Threshold in days to consider an item as aging (default: 14)")] int agingThresholdDays = 14, - [Description("Maximum items per person before flagging as overloaded (default: 5)")] int overloadThreshold = 5, - CancellationToken cancellationToken = default) - { - try - { - _logger.LogInformation("Analyzing WIP for project {Project}", project); - - // Query active/in-progress items (not completed) - var typeFilter = BuildTypeFilter(workItemTypes); - var areaFilter = string.IsNullOrWhiteSpace(areaPath) - ? string.Empty - : $" AND [System.AreaPath] UNDER '{EscapeWiqlString(areaPath)}'"; - - var wiqlQuery = $@" - SELECT [System.Id] - FROM WorkItems - WHERE [System.TeamProject] = '{EscapeWiqlString(project)}' - AND [System.State] NOT IN ('Done', 'Closed', 'Completed', 'Removed', 'Cut') - {typeFilter}{areaFilter} - ORDER BY [System.ChangedDate] DESC"; - - var wipItems = await _azureDevOpsService.QueryWorkItemsAsync(wiqlQuery, project, 500, cancellationToken); - - if (wipItems.Count == 0) - { - return JsonSerializer.Serialize(new - { - message = "No work items in progress found", - totalWip = 0 - }, JsonOptions); - } - - var now = DateTime.UtcNow; - - // Calculate WIP by state - var byState = wipItems - .GroupBy(w => w.State ?? "Unknown") - .Select(g => new WipByStateDto - { - State = g.Key, - Count = g.Count(), - Percentage = Math.Round((double)g.Count() / wipItems.Count * 100, 1), - AverageAgeDays = Math.Round(g.Average(w => - w.ChangedDate.HasValue ? (now - w.ChangedDate.Value).TotalDays : 0), 1) - }) - .OrderByDescending(s => s.Count) - .ToList(); - - // Calculate WIP by area - var byArea = wipItems - .GroupBy(w => GetLastAreaSegment(w.AreaPath)) - .Select(g => - { - var uniqueAssignees = g.Select(w => w.AssignedTo).Where(a => !string.IsNullOrEmpty(a)).Distinct().Count(); - var itemsPerPerson = uniqueAssignees > 0 ? (double)g.Count() / uniqueAssignees : g.Count(); - return new WipByAreaDto - { - AreaPath = g.Key, - Count = g.Count(), - UniqueAssignees = uniqueAssignees, - ItemsPerPerson = Math.Round(itemsPerPerson, 1), - IsOverloaded = itemsPerPerson > overloadThreshold - }; - }) - .OrderByDescending(a => a.Count) - .ToList(); - - // Calculate WIP by person - var byPerson = wipItems - .Where(w => !string.IsNullOrEmpty(w.AssignedTo)) - .GroupBy(w => w.AssignedTo!) - .Select(g => - { - var stateBreakdown = g.GroupBy(w => w.State ?? "Unknown") - .ToDictionary(sg => sg.Key, sg => sg.Count()); - var oldestAge = g.Max(w => w.ChangedDate.HasValue ? (now - w.ChangedDate.Value).TotalDays : 0); - return new WipByPersonDto - { - AssignedTo = g.Key, - Count = g.Count(), - ByState = stateBreakdown, - IsOverloaded = g.Count() > overloadThreshold, - OldestItemAgeDays = Math.Round(oldestAge, 1) - }; - }) - .OrderByDescending(p => p.Count) - .ToList(); - - // Calculate WIP by type - var byType = wipItems - .GroupBy(w => w.WorkItemType ?? "Unknown") - .ToDictionary(g => g.Key, g => g.Count()); - - // Find aging items - var agingItems = wipItems - .Where(w => w.ChangedDate.HasValue && (now - w.ChangedDate.Value).TotalDays > agingThresholdDays) - .Select(w => new AgingWorkItemDto - { - Id = w.Id, - Title = w.Title, - WorkItemType = w.WorkItemType, - State = w.State, - AssignedTo = w.AssignedTo, - AreaPath = w.AreaPath, - StateChangedDate = w.ChangedDate, - DaysInState = Math.Round((now - w.ChangedDate!.Value).TotalDays, 1), - DaysSinceCreation = w.CreatedDate.HasValue - ? Math.Round((now - w.CreatedDate.Value).TotalDays, 1) - : 0, - Priority = w.Priority, - AgingReason = $"No updates for {Math.Round((now - w.ChangedDate!.Value).TotalDays, 0)} days" - }) - .OrderByDescending(a => a.DaysInState) - .Take(20) - .ToList(); - - // Generate insights - var insights = GenerateWipInsights(wipItems.Count, byState, byPerson, byArea, agingItems, overloadThreshold); - - var result = new WipAnalysisDto - { - AnalysisDate = now, - TotalWip = wipItems.Count, - ByState = byState, - ByArea = byArea, - ByPerson = byPerson, - ByType = byType, - AgingItems = agingItems, - Insights = insights - }; - - return JsonSerializer.Serialize(result, JsonOptions); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error analyzing WIP"); - return JsonSerializer.Serialize(new { error = $"Error analyzing WIP: {ex.Message}" }, JsonOptions); - } - } - - [McpServerTool(Name = "get_bottlenecks")] - [Description(@"Identifies bottlenecks in the workflow by analyzing where work is accumulating. - -Analyzes: -- States with abnormally high item counts -- People with too many assignments -- Areas/teams with work piling up -- Long-running items blocking flow - -Returns prioritized list of bottlenecks with recommendations. -Use this to answer 'Where is our flow blocked?' or 'What's causing delays?'")] - public async Task GetBottlenecks( - [Description("The project name (required)")] string project, - [Description("Work item types to include, comma-separated. Leave empty for all types.")] string? workItemTypes = null, - [Description("Area path filter (optional)")] string? areaPath = null, - CancellationToken cancellationToken = default) - { - try - { - _logger.LogInformation("Identifying bottlenecks for project {Project}", project); - - // Get WIP data first - var wipJson = await GetWipAnalysis(project, workItemTypes, areaPath, 14, 5, cancellationToken); - var wipData = JsonSerializer.Deserialize(wipJson, JsonOptions); - - if (wipData == null || wipData.TotalWip == 0) - { - return JsonSerializer.Serialize(new - { - message = "No work in progress found - no bottlenecks to analyze", - bottlenecks = Array.Empty() - }, JsonOptions); - } - - var bottlenecks = new List(); - var recommendations = new List(); - - // Analyze state bottlenecks - states with disproportionate accumulation - var avgItemsPerState = (double)wipData.TotalWip / Math.Max(wipData.ByState.Count, 1); - foreach (var state in wipData.ByState.Where(s => s.Count > avgItemsPerState * 1.5)) - { - var severity = Math.Min(10, (int)(state.Count / avgItemsPerState * 3)); - bottlenecks.Add(new BottleneckDto - { - Type = "State", - Location = state.State, - ItemCount = state.Count, - Severity = severity, - Description = $"{state.Count} items ({state.Percentage}%) accumulated in '{state.State}' state. Average age: {state.AverageAgeDays} days." - }); - } - - // Analyze person bottlenecks - overloaded team members - foreach (var person in wipData.ByPerson.Where(p => p.IsOverloaded)) - { - var severity = Math.Min(10, person.Count); - bottlenecks.Add(new BottleneckDto - { - Type = "Person", - Location = person.AssignedTo, - ItemCount = person.Count, - Severity = severity, - Description = $"{person.AssignedTo} has {person.Count} items assigned. Oldest item is {person.OldestItemAgeDays} days old." - }); - } - - // Analyze area bottlenecks - overloaded teams - foreach (var area in wipData.ByArea.Where(a => a.IsOverloaded)) - { - var severity = Math.Min(10, (int)(area.ItemsPerPerson * 2)); - bottlenecks.Add(new BottleneckDto - { - Type = "Area", - Location = area.AreaPath, - ItemCount = area.Count, - Severity = severity, - Description = $"Team '{area.AreaPath}' has {area.Count} items for {area.UniqueAssignees} people ({area.ItemsPerPerson} items/person)." - }); - } - - // Analyze aging items as bottlenecks - if (wipData.AgingItems.Count > 5) - { - var avgAge = wipData.AgingItems.Average(a => a.DaysInState); - bottlenecks.Add(new BottleneckDto - { - Type = "Aging", - Location = "Multiple items", - ItemCount = wipData.AgingItems.Count, - Severity = Math.Min(10, wipData.AgingItems.Count / 2), - Description = $"{wipData.AgingItems.Count} items have been stale for an extended period. Average age: {Math.Round(avgAge, 1)} days.", - SampleItemIds = wipData.AgingItems.Take(5).Select(a => a.Id).ToList() - }); - } - - // Sort by severity - bottlenecks = bottlenecks.OrderByDescending(b => b.Severity).ToList(); - - // Generate recommendations - if (bottlenecks.Any(b => b.Type == "State")) - { - var stateBottleneck = bottlenecks.First(b => b.Type == "State"); - recommendations.Add($"Focus on moving items out of '{stateBottleneck.Location}' state - consider adding capacity or reviewing blockers."); - } - - if (bottlenecks.Any(b => b.Type == "Person")) - { - recommendations.Add("Redistribute work among team members to balance load and reduce single points of dependency."); - } - - if (bottlenecks.Any(b => b.Type == "Aging")) - { - recommendations.Add("Review aging items for blockers, unclear requirements, or dependency issues. Consider breaking down or deprioritizing."); - } - - var result = new BottleneckAnalysisDto - { - AnalysisDate = DateTime.UtcNow, - Bottlenecks = bottlenecks, - Recommendations = recommendations - }; - - return JsonSerializer.Serialize(result, JsonOptions); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error identifying bottlenecks"); - return JsonSerializer.Serialize(new { error = $"Error identifying bottlenecks: {ex.Message}" }, JsonOptions); - } - } - - [McpServerTool(Name = "get_team_workload")] - [Description(@"Analyzes workload distribution across team members. - -Shows: -- Items assigned to each person -- Balance of workload across the team -- Who might need help (overloaded) -- Who has capacity (underloaded) - -Use this to answer 'Who is overloaded?', 'Is work evenly distributed?', or 'Who has capacity for more work?'")] - public async Task GetTeamWorkload( - [Description("The project name (required)")] string project, - [Description("Area path to filter by team (recommended)")] string? areaPath = null, - [Description("Work item types to include, comma-separated. Leave empty for all types.")] string? workItemTypes = null, - [Description("Target items per person for comparison (default: 3)")] int targetItemsPerPerson = 3, - CancellationToken cancellationToken = default) - { - try - { - _logger.LogInformation("Analyzing team workload for project {Project}", project); - - var typeFilter = BuildTypeFilter(workItemTypes); - var areaFilter = string.IsNullOrWhiteSpace(areaPath) - ? string.Empty - : $" AND [System.AreaPath] UNDER '{EscapeWiqlString(areaPath)}'"; - - var wiqlQuery = $@" - SELECT [System.Id] - FROM WorkItems - WHERE [System.TeamProject] = '{EscapeWiqlString(project)}' - AND [System.State] NOT IN ('Done', 'Closed', 'Completed', 'Removed', 'Cut') - {typeFilter}{areaFilter} - ORDER BY [System.AssignedTo] ASC"; - - var items = await _azureDevOpsService.QueryWorkItemsAsync(wiqlQuery, project, 500, cancellationToken); - - if (items.Count == 0) - { - return JsonSerializer.Serialize(new - { - message = "No active work items found", - totalItems = 0 - }, JsonOptions); - } - - var now = DateTime.UtcNow; - - // Group by assignee - var workloadByPerson = items - .GroupBy(w => w.AssignedTo ?? "[Unassigned]") - .Select(g => - { - var stateBreakdown = g.GroupBy(w => w.State ?? "Unknown") - .ToDictionary(sg => sg.Key, sg => sg.Count()); - - var typeBreakdown = g.GroupBy(w => w.WorkItemType ?? "Unknown") - .ToDictionary(tg => tg.Key, tg => tg.Count()); - - var priorityBreakdown = g.GroupBy(w => w.Priority ?? "Unset") - .ToDictionary(pg => pg.Key, pg => pg.Count()); - - var avgAge = g.Average(w => w.ChangedDate.HasValue - ? (now - w.ChangedDate.Value).TotalDays : 0); - - var deviation = g.Count() - targetItemsPerPerson; - var status = deviation > 2 ? "overloaded" : (deviation < -1 ? "has capacity" : "balanced"); - - return new - { - assignedTo = g.Key, - itemCount = g.Count(), - deviation = deviation, - status = status, - byState = stateBreakdown, - byType = typeBreakdown, - byPriority = priorityBreakdown, - averageItemAgeDays = Math.Round(avgAge, 1), - items = g.Select(w => new - { - id = w.Id, - title = w.Title, - type = w.WorkItemType, - state = w.State, - priority = w.Priority, - ageDays = w.ChangedDate.HasValue - ? Math.Round((now - w.ChangedDate.Value).TotalDays, 1) : 0 - }).OrderBy(w => w.priority).ThenByDescending(w => w.ageDays).ToList() - }; - }) - .OrderByDescending(p => p.itemCount) - .ToList(); - - var unassignedCount = workloadByPerson.FirstOrDefault(p => p.assignedTo == "[Unassigned]")?.itemCount ?? 0; - var assignedPeople = workloadByPerson.Where(p => p.assignedTo != "[Unassigned]").ToList(); - var avgItemsPerPerson = assignedPeople.Count > 0 - ? Math.Round((double)assignedPeople.Sum(p => p.itemCount) / assignedPeople.Count, 1) - : 0; - - var summary = new - { - totalItems = items.Count, - unassignedItems = unassignedCount, - teamSize = assignedPeople.Count, - averageItemsPerPerson = avgItemsPerPerson, - targetItemsPerPerson = targetItemsPerPerson, - overloadedCount = assignedPeople.Count(p => p.status == "overloaded"), - hasCapacityCount = assignedPeople.Count(p => p.status == "has capacity"), - balancedCount = assignedPeople.Count(p => p.status == "balanced") - }; - - var insights = new List(); - - if (unassignedCount > 0) - { - insights.Add($"{unassignedCount} items are unassigned and need attention."); - } - - if (summary.overloadedCount > 0) - { - var overloaded = assignedPeople.Where(p => p.status == "overloaded").Select(p => p.assignedTo); - insights.Add($"{summary.overloadedCount} team member(s) appear overloaded: {string.Join(", ", overloaded)}"); - } - - if (summary.hasCapacityCount > 0 && summary.overloadedCount > 0) - { - insights.Add("Consider redistributing work from overloaded members to those with capacity."); - } - - return JsonSerializer.Serialize(new - { - analysisDate = now, - summary, - insights, - workload = workloadByPerson - }, JsonOptions); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error analyzing team workload"); - return JsonSerializer.Serialize(new { error = $"Error analyzing team workload: {ex.Message}" }, JsonOptions); - } - } - - private static string GetLastAreaSegment(string? areaPath) - { - if (string.IsNullOrWhiteSpace(areaPath)) return "Unknown"; - var segments = areaPath.Split('\\'); - return segments.Length > 0 ? segments[^1] : areaPath; - } - - private static List GenerateWipInsights( - int totalWip, - List byState, - List byPerson, - List byArea, - List agingItems, - int overloadThreshold) - { - var insights = new List(); - - // Overall WIP insight - insights.Add($"Total WIP: {totalWip} items across {byState.Count} states."); - - // State accumulation - var topState = byState.FirstOrDefault(); - if (topState != null && topState.Percentage > 40) - { - insights.Add($"Warning: {topState.Percentage}% of WIP is in '{topState.State}' state - potential bottleneck."); - } - - // Overloaded people - var overloadedCount = byPerson.Count(p => p.IsOverloaded); - if (overloadedCount > 0) - { - insights.Add($"{overloadedCount} team member(s) have more than {overloadThreshold} items assigned - consider rebalancing."); - } - - // Aging items - if (agingItems.Count > 0) - { - var avgAge = agingItems.Average(a => a.DaysInState); - insights.Add($"{agingItems.Count} items are aging (avg {Math.Round(avgAge, 0)} days) - review for blockers."); - } - - // Unbalanced teams - var overloadedAreas = byArea.Count(a => a.IsOverloaded); - if (overloadedAreas > 0) - { - insights.Add($"{overloadedAreas} team(s) appear overloaded based on items per person ratio."); - } - - return insights; - } - - [McpServerTool(Name = "get_aging_report")] - [Description(@"Generates a detailed report of aging work items that need attention. - -Provides: -- Items classified by urgency (Critical, High, Medium, Low) -- Breakdown by state, person, and area to identify patterns -- Specific recommendations for each aging item -- Statistics about aging patterns - -Urgency is calculated based on: -- Priority (P1 items age faster than P4) -- Time since last update -- Current state (items stuck in certain states are more urgent) - -Use this to answer 'What needs attention now?', 'Which items are stuck?', or 'What are we forgetting about?'")] - public async Task GetAgingReport( - [Description("The project name (required)")] string project, - [Description("Threshold in days to consider an item aging (default: 7)")] int agingThresholdDays = 7, - [Description("Critical threshold multiplier (default: 3x aging threshold)")] int criticalMultiplier = 3, - [Description("Work item types to include, comma-separated. Leave empty for all types.")] string? workItemTypes = null, - [Description("Area path filter (optional)")] string? areaPath = null, - [Description("Maximum number of items to return (default: 50)")] int maxItems = 50, - CancellationToken cancellationToken = default) - { - try - { - _logger.LogInformation("Generating aging report for project {Project}", project); - - var typeFilter = BuildTypeFilter(workItemTypes); - var areaFilter = string.IsNullOrWhiteSpace(areaPath) - ? string.Empty - : $" AND [System.AreaPath] UNDER '{EscapeWiqlString(areaPath)}'"; - - // Query all active items - var wiqlQuery = $@" - SELECT [System.Id] - FROM WorkItems - WHERE [System.TeamProject] = '{EscapeWiqlString(project)}' - AND [System.State] NOT IN ('Done', 'Closed', 'Completed', 'Removed', 'Cut') - {typeFilter}{areaFilter} - ORDER BY [System.ChangedDate] ASC"; - - var allItems = await _azureDevOpsService.QueryWorkItemsAsync(wiqlQuery, project, 500, cancellationToken); - - if (allItems.Count == 0) - { - return JsonSerializer.Serialize(new - { - message = "No active work items found", - totalAgingItems = 0 - }, JsonOptions); - } - - var now = DateTime.UtcNow; - var criticalThreshold = agingThresholdDays * criticalMultiplier; - var highThreshold = agingThresholdDays * 2; - - // Filter to aging items and calculate urgency - var agingItemDetails = allItems - .Where(w => w.ChangedDate.HasValue && (now - w.ChangedDate.Value).TotalDays >= agingThresholdDays) - .Select(w => - { - var daysSinceUpdate = (now - w.ChangedDate!.Value).TotalDays; - var daysSinceCreation = w.CreatedDate.HasValue ? (now - w.CreatedDate.Value).TotalDays : daysSinceUpdate; - - // Calculate urgency score (1-10) - var urgencyScore = CalculateUrgencyScore(w, daysSinceUpdate, agingThresholdDays, criticalThreshold); - var urgency = urgencyScore >= 8 ? "Critical" : - urgencyScore >= 6 ? "High" : - urgencyScore >= 4 ? "Medium" : "Low"; - - var recommendation = GenerateItemRecommendation(w, daysSinceUpdate, urgency); - - return new AgingItemDetailDto - { - Id = w.Id, - Title = w.Title, - WorkItemType = w.WorkItemType, - State = w.State, - AssignedTo = w.AssignedTo, - AreaPath = w.AreaPath, - Priority = w.Priority, - DaysSinceUpdate = Math.Round(daysSinceUpdate, 1), - DaysSinceCreation = Math.Round(daysSinceCreation, 1), - Urgency = urgency, - UrgencyScore = urgencyScore, - Recommendation = recommendation - }; - }) - .OrderByDescending(a => a.UrgencyScore) - .ThenByDescending(a => a.DaysSinceUpdate) - .ToList(); - - if (agingItemDetails.Count == 0) - { - return JsonSerializer.Serialize(new - { - message = $"No items older than {agingThresholdDays} days found - looking good!", - totalAgingItems = 0, - totalWip = allItems.Count - }, JsonOptions); - } - - // Group by urgency - var byUrgency = new AgingByUrgencyDto - { - Critical = agingItemDetails.Where(a => a.Urgency == "Critical").Select(a => a.Id).ToList(), - High = agingItemDetails.Where(a => a.Urgency == "High").Select(a => a.Id).ToList(), - Medium = agingItemDetails.Where(a => a.Urgency == "Medium").Select(a => a.Id).ToList(), - Low = agingItemDetails.Where(a => a.Urgency == "Low").Select(a => a.Id).ToList() - }; - - // Group by state - var byState = agingItemDetails - .GroupBy(a => a.State ?? "Unknown") - .Select(g => new AgingByGroupDto - { - Name = g.Key, - Count = g.Count(), - AverageAgeDays = Math.Round(g.Average(a => a.DaysSinceUpdate), 1), - MaxAgeDays = Math.Round(g.Max(a => a.DaysSinceUpdate), 1), - ItemIds = g.Select(a => a.Id).ToList() - }) - .OrderByDescending(g => g.Count) - .ToList(); - - // Group by assignee - var byAssignee = agingItemDetails - .GroupBy(a => a.AssignedTo ?? "[Unassigned]") - .Select(g => new AgingByGroupDto - { - Name = g.Key, - Count = g.Count(), - AverageAgeDays = Math.Round(g.Average(a => a.DaysSinceUpdate), 1), - MaxAgeDays = Math.Round(g.Max(a => a.DaysSinceUpdate), 1), - ItemIds = g.Select(a => a.Id).ToList() - }) - .OrderByDescending(g => g.Count) - .ToList(); - - // Group by area - var byArea = agingItemDetails - .GroupBy(a => GetLastAreaSegment(a.AreaPath)) - .Select(g => new AgingByGroupDto - { - Name = g.Key, - Count = g.Count(), - AverageAgeDays = Math.Round(g.Average(a => a.DaysSinceUpdate), 1), - MaxAgeDays = Math.Round(g.Max(a => a.DaysSinceUpdate), 1), - ItemIds = g.Select(a => a.Id).ToList() - }) - .OrderByDescending(g => g.Count) - .ToList(); - - // Summary statistics - var summary = new AgingSummaryDto - { - AverageAgeDays = Math.Round(agingItemDetails.Average(a => a.DaysSinceUpdate), 1), - MedianAgeDays = Math.Round(GetMedian(agingItemDetails.Select(a => a.DaysSinceUpdate).ToList()), 1), - MaxAgeDays = Math.Round(agingItemDetails.Max(a => a.DaysSinceUpdate), 1), - PercentageOfWip = Math.Round((double)agingItemDetails.Count / allItems.Count * 100, 1), - TopAgingState = byState.FirstOrDefault()?.Name ?? "N/A", - TopAgingAssignee = byAssignee.FirstOrDefault()?.Name ?? "N/A" - }; - - // Generate overall recommendations - var recommendations = GenerateAgingRecommendations(agingItemDetails, byState, byAssignee, byUrgency); - - var result = new AgingReportDto - { - AnalysisDate = now, - TotalAgingItems = agingItemDetails.Count, - Summary = summary, - ByUrgency = byUrgency, - ByState = byState, - ByAssignee = byAssignee, - ByArea = byArea, - Items = agingItemDetails.Take(maxItems).ToList(), - Recommendations = recommendations - }; - - return JsonSerializer.Serialize(result, JsonOptions); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating aging report"); - return JsonSerializer.Serialize(new { error = $"Error generating aging report: {ex.Message}" }, JsonOptions); - } - } - - private static int CalculateUrgencyScore(WorkItemDto item, double daysSinceUpdate, int agingThreshold, int criticalThreshold) - { - var score = 0; - - // Age factor (0-4 points) - if (daysSinceUpdate >= criticalThreshold) score += 4; - else if (daysSinceUpdate >= agingThreshold * 2) score += 3; - else if (daysSinceUpdate >= agingThreshold * 1.5) score += 2; - else score += 1; - - // Priority factor (0-3 points) - var priority = item.Priority ?? "4"; - score += priority switch - { - "1" => 3, - "2" => 2, - "3" => 1, - _ => 0 - }; - - // State factor (0-2 points) - items in certain states are more urgent - var state = item.State?.ToLowerInvariant() ?? ""; - if (state.Contains("blocked") || state.Contains("impediment")) - score += 2; - else if (state.Contains("review") || state.Contains("test")) - score += 1; - - // Unassigned penalty (0-1 point) - if (string.IsNullOrEmpty(item.AssignedTo)) - score += 1; - - return Math.Min(10, score); - } - - private static string GenerateItemRecommendation(WorkItemDto item, double daysSinceUpdate, string urgency) - { - var state = item.State?.ToLowerInvariant() ?? ""; - var hasAssignee = !string.IsNullOrEmpty(item.AssignedTo); - - if (!hasAssignee) - return "Assign this item to a team member to ensure ownership."; - - if (state.Contains("blocked") || state.Contains("impediment")) - return "Identify and resolve the blocker. Consider escalating if external dependency."; - - if (state.Contains("review") || state.Contains("code review")) - return "Ensure reviewer availability. Consider pairing or distributing review load."; - - if (state.Contains("test")) - return "Check test environment availability. Verify test cases are ready."; - - if (state.Contains("new") || state.Contains("proposed")) - return "Item needs refinement or prioritization decision. Consider grooming session."; - - if (urgency == "Critical") - return "Immediate attention needed. Schedule sync with assignee to unblock."; - - if (urgency == "High") - return "Review with assignee this week. Check for hidden blockers or unclear requirements."; - - if (daysSinceUpdate > 30) - return "Consider if this item is still relevant. May need re-prioritization or removal."; - - return "Follow up with assignee on progress and any obstacles."; - } - - private static List GenerateAgingRecommendations( - List items, - List byState, - List byAssignee, - AgingByUrgencyDto byUrgency) - { - var recommendations = new List(); - - if (byUrgency.Critical.Count > 0) - { - recommendations.Add($"URGENT: {byUrgency.Critical.Count} critical items need immediate attention. Review IDs: {string.Join(", ", byUrgency.Critical.Take(5))}"); - } - - if (byState.FirstOrDefault() is { } topState && topState.Count > items.Count * 0.3) - { - recommendations.Add($"'{topState.Name}' state has {topState.Count} aging items ({Math.Round(topState.Count * 100.0 / items.Count)}%). Focus on clearing this queue."); - } - - var unassigned = byAssignee.FirstOrDefault(a => a.Name == "[Unassigned]"); - if (unassigned != null && unassigned.Count > 0) - { - recommendations.Add($"{unassigned.Count} aging items are unassigned. Assign owners to ensure accountability."); - } - - var overloadedPeople = byAssignee.Where(a => a.Name != "[Unassigned]" && a.Count > 5).ToList(); - if (overloadedPeople.Count > 0) - { - var names = string.Join(", ", overloadedPeople.Select(p => p.Name)); - recommendations.Add($"Consider redistributing work from: {names} (each has >5 aging items)."); - } - - var veryOldItems = items.Where(i => i.DaysSinceUpdate > 30).ToList(); - if (veryOldItems.Count > 0) - { - recommendations.Add($"{veryOldItems.Count} items are over 30 days old. Review for relevance - consider closing or breaking down."); - } - - if (recommendations.Count == 0) - { - recommendations.Add("Aging levels are manageable. Continue regular review cadence."); - } - - return recommendations; - } - - private static double GetMedian(List values) - { - if (values.Count == 0) return 0; - var sorted = values.OrderBy(v => v).ToList(); - var mid = sorted.Count / 2; - return sorted.Count % 2 == 0 - ? (sorted[mid - 1] + sorted[mid]) / 2 - : sorted[mid]; - } - - #endregion -} diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Viamus.Azure.Devops.Mcp.Server.csproj b/src/Viamus.Azure.Devops.Mcp.Server/Viamus.Azure.Devops.Mcp.Server.csproj index c83fb6b..1c3d01e 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Viamus.Azure.Devops.Mcp.Server.csproj +++ b/src/Viamus.Azure.Devops.Mcp.Server/Viamus.Azure.Devops.Mcp.Server.csproj @@ -8,7 +8,7 @@ ..\.. - 1.2.0 + 1.3.0 Viamus Viamus Azure DevOps MCP Server diff --git a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/FlowMetricsDtoTests.cs b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/FlowMetricsDtoTests.cs deleted file mode 100644 index 4f7ba2c..0000000 --- a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/FlowMetricsDtoTests.cs +++ /dev/null @@ -1,351 +0,0 @@ -using System.Text.Json; -using Viamus.Azure.Devops.Mcp.Server.Models; - -namespace Viamus.Azure.Devops.Mcp.Server.Tests.Models; - -public class FlowMetricsDtoTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void FlowMetricsDto_ShouldSerializeToJson() - { - var dto = new FlowMetricsDto - { - PeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), - PeriodEnd = new DateTime(2024, 1, 31, 0, 0, 0, DateTimeKind.Utc), - Throughput = 25, - LeadTime = new MetricStatistics - { - Average = 5.5, - Median = 4.0, - Percentile85 = 8.0, - Percentile95 = 12.0, - Min = 1.0, - Max = 15.0, - StdDev = 2.5, - Count = 25 - }, - CycleTime = new MetricStatistics - { - Average = 3.2, - Median = 2.5, - Percentile85 = 5.0, - Percentile95 = 7.0, - Min = 0.5, - Max = 10.0, - StdDev = 1.8, - Count = 25 - } - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"throughput\":25", json); - Assert.Contains("\"leadTime\":", json); - Assert.Contains("\"cycleTime\":", json); - } - - [Fact] - public void FlowMetricsDto_ShouldDeserializeFromJson() - { - var json = """ - { - "periodStart": "2024-01-01T00:00:00Z", - "periodEnd": "2024-01-31T00:00:00Z", - "throughput": 30, - "leadTime": { - "average": 5.5, - "median": 4.0, - "percentile85": 8.0, - "percentile95": 12.0, - "min": 1.0, - "max": 15.0, - "stdDev": 2.5, - "count": 30 - }, - "cycleTime": { - "average": 3.0, - "median": 2.0, - "percentile85": 5.0, - "percentile95": 7.0, - "min": 0.5, - "max": 8.0, - "stdDev": 1.5, - "count": 30 - } - } - """; - - var dto = JsonSerializer.Deserialize(json, JsonOptions); - - Assert.NotNull(dto); - Assert.Equal(30, dto.Throughput); - Assert.Equal(5.5, dto.LeadTime.Average); - Assert.Equal(3.0, dto.CycleTime.Average); - } - - [Fact] - public void FlowMetricsDto_WithThroughputByType_ShouldSerializeCorrectly() - { - var dto = new FlowMetricsDto - { - PeriodStart = DateTime.UtcNow.AddDays(-30), - PeriodEnd = DateTime.UtcNow, - Throughput = 15, - ThroughputByType = new Dictionary - { - { "Bug", 5 }, - { "User Story", 7 }, - { "Task", 3 } - } - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"throughputByType\":", json); - Assert.Contains("\"Bug\":5", json); - Assert.Contains("\"User Story\":7", json); - Assert.Contains("\"Task\":3", json); - } - - [Fact] - public void FlowMetricsDto_WithThroughputByPeriod_ShouldSerializeCorrectly() - { - var dto = new FlowMetricsDto - { - PeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), - PeriodEnd = new DateTime(2024, 1, 14, 0, 0, 0, DateTimeKind.Utc), - Throughput = 10, - ThroughputByPeriod = - [ - new ThroughputPeriod - { - PeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), - PeriodEnd = new DateTime(2024, 1, 7, 0, 0, 0, DateTimeKind.Utc), - Count = 4 - }, - new ThroughputPeriod - { - PeriodStart = new DateTime(2024, 1, 8, 0, 0, 0, DateTimeKind.Utc), - PeriodEnd = new DateTime(2024, 1, 14, 0, 0, 0, DateTimeKind.Utc), - Count = 6 - } - ] - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"throughputByPeriod\":", json); - Assert.Contains("\"count\":4", json); - Assert.Contains("\"count\":6", json); - } - - [Fact] - public void FlowMetricsDto_PrimitiveEquality_ShouldWork() - { - var dto1 = new FlowMetricsDto - { - PeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), - PeriodEnd = new DateTime(2024, 1, 31, 0, 0, 0, DateTimeKind.Utc), - Throughput = 10 - }; - - var dto2 = new FlowMetricsDto - { - PeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), - PeriodEnd = new DateTime(2024, 1, 31, 0, 0, 0, DateTimeKind.Utc), - Throughput = 10 - }; - - // Records with collections compare by reference, so we compare primitive properties - Assert.Equal(dto1.PeriodStart, dto2.PeriodStart); - Assert.Equal(dto1.PeriodEnd, dto2.PeriodEnd); - Assert.Equal(dto1.Throughput, dto2.Throughput); - } -} - -public class MetricStatisticsTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void MetricStatistics_ShouldSerializeAllProperties() - { - var stats = new MetricStatistics - { - Average = 5.5, - Median = 4.0, - Percentile85 = 8.0, - Percentile95 = 12.0, - Min = 1.0, - Max = 15.0, - StdDev = 2.5, - Count = 25 - }; - - var json = JsonSerializer.Serialize(stats, JsonOptions); - - Assert.Contains("\"average\":5.5", json); - Assert.Contains("\"median\":4", json); - Assert.Contains("\"percentile85\":8", json); - Assert.Contains("\"percentile95\":12", json); - Assert.Contains("\"min\":1", json); - Assert.Contains("\"max\":15", json); - Assert.Contains("\"stdDev\":2.5", json); - Assert.Contains("\"count\":25", json); - } - - [Fact] - public void MetricStatistics_Default_ShouldHaveZeroValues() - { - var stats = new MetricStatistics(); - - Assert.Equal(0, stats.Average); - Assert.Equal(0, stats.Median); - Assert.Equal(0, stats.Percentile85); - Assert.Equal(0, stats.Percentile95); - Assert.Equal(0, stats.Min); - Assert.Equal(0, stats.Max); - Assert.Equal(0, stats.StdDev); - Assert.Equal(0, stats.Count); - } -} - -public class WorkItemFlowDataDtoTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void WorkItemFlowDataDto_ShouldSerializeToJson() - { - var dto = new WorkItemFlowDataDto - { - Id = 123, - Title = "Test Bug", - WorkItemType = "Bug", - CreatedDate = new DateTime(2024, 1, 1, 10, 0, 0, DateTimeKind.Utc), - StartedDate = new DateTime(2024, 1, 5, 9, 0, 0, DateTimeKind.Utc), - CompletedDate = new DateTime(2024, 1, 10, 16, 0, 0, DateTimeKind.Utc), - LeadTimeDays = 9.25, - CycleTimeDays = 5.29, - AreaPath = "Project\\Team", - IterationPath = "Project\\Sprint 1" - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"id\":123", json); - Assert.Contains("\"title\":\"Test Bug\"", json); - Assert.Contains("\"workItemType\":\"Bug\"", json); - Assert.Contains("\"leadTimeDays\":9.25", json); - Assert.Contains("\"cycleTimeDays\":5.29", json); - } - - [Fact] - public void WorkItemFlowDataDto_ShouldDeserializeFromJson() - { - var json = """ - { - "id": 456, - "title": "User Story", - "workItemType": "User Story", - "createdDate": "2024-01-01T00:00:00Z", - "startedDate": "2024-01-03T00:00:00Z", - "completedDate": "2024-01-08T00:00:00Z", - "leadTimeDays": 7.0, - "cycleTimeDays": 5.0, - "areaPath": "Project\\Team A", - "iterationPath": "Project\\Sprint 2" - } - """; - - var dto = JsonSerializer.Deserialize(json, JsonOptions); - - Assert.NotNull(dto); - Assert.Equal(456, dto.Id); - Assert.Equal("User Story", dto.Title); - Assert.Equal(7.0, dto.LeadTimeDays); - Assert.Equal(5.0, dto.CycleTimeDays); - } - - [Fact] - public void WorkItemFlowDataDto_NullableDates_ShouldAllowNull() - { - var dto = new WorkItemFlowDataDto - { - Id = 789, - Title = "Incomplete Item", - WorkItemType = "Task", - CreatedDate = DateTime.UtcNow, - StartedDate = null, - CompletedDate = null, - LeadTimeDays = null, - CycleTimeDays = null - }; - - Assert.NotNull(dto.CreatedDate); - Assert.Null(dto.StartedDate); - Assert.Null(dto.CompletedDate); - Assert.Null(dto.LeadTimeDays); - Assert.Null(dto.CycleTimeDays); - } -} - -public class ThroughputPeriodTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void ThroughputPeriod_ShouldSerializeToJson() - { - var dto = new ThroughputPeriod - { - PeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), - PeriodEnd = new DateTime(2024, 1, 7, 0, 0, 0, DateTimeKind.Utc), - Count = 8 - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"count\":8", json); - Assert.Contains("\"periodStart\":", json); - Assert.Contains("\"periodEnd\":", json); - } - - [Fact] - public void ThroughputPeriod_RecordEquality_ShouldWork() - { - var period1 = new ThroughputPeriod - { - PeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), - PeriodEnd = new DateTime(2024, 1, 7, 0, 0, 0, DateTimeKind.Utc), - Count = 5 - }; - - var period2 = new ThroughputPeriod - { - PeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), - PeriodEnd = new DateTime(2024, 1, 7, 0, 0, 0, DateTimeKind.Utc), - Count = 5 - }; - - Assert.Equal(period1, period2); - } -} diff --git a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/WipAnalysisDtoTests.cs b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/WipAnalysisDtoTests.cs deleted file mode 100644 index 9efd701..0000000 --- a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/WipAnalysisDtoTests.cs +++ /dev/null @@ -1,536 +0,0 @@ -using System.Text.Json; -using Viamus.Azure.Devops.Mcp.Server.Models; - -namespace Viamus.Azure.Devops.Mcp.Server.Tests.Models; - -public class WipAnalysisDtoTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void WipAnalysisDto_ShouldSerializeToJson() - { - var dto = new WipAnalysisDto - { - AnalysisDate = new DateTime(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc), - TotalWip = 42, - ByState = - [ - new WipByStateDto { State = "Active", Count = 20, Percentage = 47.6, AverageAgeDays = 5.2 }, - new WipByStateDto { State = "In Progress", Count = 15, Percentage = 35.7, AverageAgeDays = 3.1 } - ], - ByType = new Dictionary - { - { "Bug", 15 }, - { "User Story", 20 }, - { "Task", 7 } - }, - Insights = ["Total WIP: 42 items", "Warning: high accumulation in Active state"] - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"totalWip\":42", json); - Assert.Contains("\"byState\":", json); - Assert.Contains("\"byType\":", json); - Assert.Contains("\"insights\":", json); - } - - [Fact] - public void WipAnalysisDto_ShouldDeserializeFromJson() - { - var json = """ - { - "analysisDate": "2024-01-15T10:30:00Z", - "totalWip": 30, - "byState": [ - { "state": "Active", "count": 15, "percentage": 50.0, "averageAgeDays": 4.5 } - ], - "byArea": [], - "byPerson": [], - "byType": { "Bug": 10, "Task": 20 }, - "agingItems": [], - "insights": ["Test insight"] - } - """; - - var dto = JsonSerializer.Deserialize(json, JsonOptions); - - Assert.NotNull(dto); - Assert.Equal(30, dto.TotalWip); - Assert.Single(dto.ByState); - Assert.Equal("Active", dto.ByState[0].State); - Assert.Equal(15, dto.ByState[0].Count); - } - - [Fact] - public void WipAnalysisDto_DefaultCollections_ShouldBeEmpty() - { - var dto = new WipAnalysisDto { TotalWip = 0 }; - - Assert.Empty(dto.ByState); - Assert.Empty(dto.ByArea); - Assert.Empty(dto.ByPerson); - Assert.Empty(dto.ByType); - Assert.Empty(dto.AgingItems); - Assert.Empty(dto.Insights); - } -} - -public class WipByStateDtoTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void WipByStateDto_ShouldSerializeAllProperties() - { - var dto = new WipByStateDto - { - State = "In Progress", - Count = 25, - Percentage = 41.7, - AverageAgeDays = 6.3 - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"state\":\"In Progress\"", json); - Assert.Contains("\"count\":25", json); - Assert.Contains("\"percentage\":41.7", json); - Assert.Contains("\"averageAgeDays\":6.3", json); - } - - [Fact] - public void WipByStateDto_RecordEquality_ShouldWork() - { - var dto1 = new WipByStateDto { State = "Active", Count = 10, Percentage = 50.0 }; - var dto2 = new WipByStateDto { State = "Active", Count = 10, Percentage = 50.0 }; - - Assert.Equal(dto1, dto2); - } -} - -public class WipByAreaDtoTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void WipByAreaDto_ShouldSerializeAllProperties() - { - var dto = new WipByAreaDto - { - AreaPath = "Project\\Team Alpha", - Count = 18, - UniqueAssignees = 3, - ItemsPerPerson = 6.0, - IsOverloaded = true - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"areaPath\":\"Project\\\\Team Alpha\"", json); - Assert.Contains("\"count\":18", json); - Assert.Contains("\"uniqueAssignees\":3", json); - Assert.Contains("\"itemsPerPerson\":6", json); - Assert.Contains("\"isOverloaded\":true", json); - } -} - -public class WipByPersonDtoTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void WipByPersonDto_ShouldSerializeAllProperties() - { - var dto = new WipByPersonDto - { - AssignedTo = "John Doe", - Count = 8, - ByState = new Dictionary - { - { "Active", 3 }, - { "In Progress", 5 } - }, - IsOverloaded = true, - OldestItemAgeDays = 15.5 - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"assignedTo\":\"John Doe\"", json); - Assert.Contains("\"count\":8", json); - Assert.Contains("\"byState\":", json); - Assert.Contains("\"isOverloaded\":true", json); - Assert.Contains("\"oldestItemAgeDays\":15.5", json); - } - - [Fact] - public void WipByPersonDto_DefaultByState_ShouldBeEmpty() - { - var dto = new WipByPersonDto { AssignedTo = "Test User", Count = 1 }; - - Assert.Empty(dto.ByState); - } -} - -public class AgingWorkItemDtoTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void AgingWorkItemDto_ShouldSerializeToJson() - { - var dto = new AgingWorkItemDto - { - Id = 123, - Title = "Old bug", - WorkItemType = "Bug", - State = "Active", - AssignedTo = "Jane Doe", - AreaPath = "Project\\Team", - StateChangedDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), - DaysInState = 20.5, - DaysSinceCreation = 45.0, - Priority = "2", - AgingReason = "No updates for 20 days" - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"id\":123", json); - Assert.Contains("\"title\":\"Old bug\"", json); - Assert.Contains("\"daysInState\":20.5", json); - Assert.Contains("\"agingReason\":\"No updates for 20 days\"", json); - } - - [Fact] - public void AgingWorkItemDto_NullableProperties_ShouldAllowNull() - { - var dto = new AgingWorkItemDto - { - Id = 456, - Title = null, - AssignedTo = null, - StateChangedDate = null, - DaysInState = 10.0 - }; - - Assert.Null(dto.Title); - Assert.Null(dto.AssignedTo); - Assert.Null(dto.StateChangedDate); - Assert.Equal(10.0, dto.DaysInState); - } -} - -public class BottleneckAnalysisDtoTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void BottleneckAnalysisDto_ShouldSerializeToJson() - { - var dto = new BottleneckAnalysisDto - { - AnalysisDate = new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc), - Bottlenecks = - [ - new BottleneckDto - { - Type = "State", - Location = "Code Review", - ItemCount = 15, - Severity = 8, - Description = "15 items stuck in Code Review" - } - ], - Recommendations = ["Add more reviewers", "Consider pair programming"] - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"bottlenecks\":", json); - Assert.Contains("\"recommendations\":", json); - Assert.Contains("\"severity\":8", json); - } - - [Fact] - public void BottleneckAnalysisDto_DefaultCollections_ShouldBeEmpty() - { - var dto = new BottleneckAnalysisDto(); - - Assert.Empty(dto.Bottlenecks); - Assert.Empty(dto.Recommendations); - } -} - -public class BottleneckDtoTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void BottleneckDto_ShouldSerializeAllProperties() - { - var dto = new BottleneckDto - { - Type = "Person", - Location = "John Doe", - ItemCount = 12, - Severity = 7, - Description = "John has too many items assigned", - SampleItemIds = [101, 102, 103] - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"type\":\"Person\"", json); - Assert.Contains("\"location\":\"John Doe\"", json); - Assert.Contains("\"itemCount\":12", json); - Assert.Contains("\"severity\":7", json); - Assert.Contains("\"sampleItemIds\":[101,102,103]", json); - } - - [Fact] - public void BottleneckDto_RecordEquality_ShouldWorkForPrimitives() - { - var dto1 = new BottleneckDto { Type = "State", Location = "Active", ItemCount = 10, Severity = 5 }; - var dto2 = new BottleneckDto { Type = "State", Location = "Active", ItemCount = 10, Severity = 5 }; - - Assert.Equal(dto1.Type, dto2.Type); - Assert.Equal(dto1.Location, dto2.Location); - Assert.Equal(dto1.ItemCount, dto2.ItemCount); - Assert.Equal(dto1.Severity, dto2.Severity); - } -} - -public class AgingReportDtoTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void AgingReportDto_ShouldSerializeToJson() - { - var dto = new AgingReportDto - { - AnalysisDate = new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc), - TotalAgingItems = 15, - Summary = new AgingSummaryDto - { - AverageAgeDays = 12.5, - MedianAgeDays = 10.0, - MaxAgeDays = 45.0, - PercentageOfWip = 25.0, - TopAgingState = "In Review", - TopAgingAssignee = "John Doe" - }, - Recommendations = ["Review aging items", "Add more reviewers"] - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"totalAgingItems\":15", json); - Assert.Contains("\"summary\":", json); - Assert.Contains("\"recommendations\":", json); - } - - [Fact] - public void AgingReportDto_DefaultCollections_ShouldBeEmpty() - { - var dto = new AgingReportDto(); - - Assert.Empty(dto.ByState); - Assert.Empty(dto.ByAssignee); - Assert.Empty(dto.ByArea); - Assert.Empty(dto.Items); - Assert.Empty(dto.Recommendations); - } -} - -public class AgingSummaryDtoTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void AgingSummaryDto_ShouldSerializeAllProperties() - { - var dto = new AgingSummaryDto - { - AverageAgeDays = 15.5, - MedianAgeDays = 12.0, - MaxAgeDays = 60.0, - PercentageOfWip = 30.5, - TopAgingState = "Blocked", - TopAgingAssignee = "Jane Doe" - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"averageAgeDays\":15.5", json); - Assert.Contains("\"medianAgeDays\":12", json); - Assert.Contains("\"maxAgeDays\":60", json); - Assert.Contains("\"percentageOfWip\":30.5", json); - Assert.Contains("\"topAgingState\":\"Blocked\"", json); - Assert.Contains("\"topAgingAssignee\":\"Jane Doe\"", json); - } -} - -public class AgingByUrgencyDtoTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void AgingByUrgencyDto_ShouldSerializeAllLevels() - { - var dto = new AgingByUrgencyDto - { - Critical = [101, 102], - High = [103, 104, 105], - Medium = [106], - Low = [107, 108, 109, 110] - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"critical\":[101,102]", json); - Assert.Contains("\"high\":[103,104,105]", json); - Assert.Contains("\"medium\":[106]", json); - Assert.Contains("\"low\":[107,108,109,110]", json); - } - - [Fact] - public void AgingByUrgencyDto_DefaultLists_ShouldBeEmpty() - { - var dto = new AgingByUrgencyDto(); - - Assert.Empty(dto.Critical); - Assert.Empty(dto.High); - Assert.Empty(dto.Medium); - Assert.Empty(dto.Low); - } -} - -public class AgingByGroupDtoTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void AgingByGroupDto_ShouldSerializeAllProperties() - { - var dto = new AgingByGroupDto - { - Name = "In Review", - Count = 8, - AverageAgeDays = 14.2, - MaxAgeDays = 35.0, - ItemIds = [101, 102, 103] - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"name\":\"In Review\"", json); - Assert.Contains("\"count\":8", json); - Assert.Contains("\"averageAgeDays\":14.2", json); - Assert.Contains("\"maxAgeDays\":35", json); - Assert.Contains("\"itemIds\":[101,102,103]", json); - } -} - -public class AgingItemDetailDtoTests -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - [Fact] - public void AgingItemDetailDto_ShouldSerializeAllProperties() - { - var dto = new AgingItemDetailDto - { - Id = 123, - Title = "Old Bug", - WorkItemType = "Bug", - State = "Active", - AssignedTo = "John Doe", - AreaPath = "Project\\Team", - Priority = "2", - DaysSinceUpdate = 25.5, - DaysSinceCreation = 45.0, - Urgency = "High", - UrgencyScore = 7, - Recommendation = "Review with assignee this week" - }; - - var json = JsonSerializer.Serialize(dto, JsonOptions); - - Assert.Contains("\"id\":123", json); - Assert.Contains("\"urgency\":\"High\"", json); - Assert.Contains("\"urgencyScore\":7", json); - Assert.Contains("\"recommendation\":\"Review with assignee this week\"", json); - } - - [Fact] - public void AgingItemDetailDto_NullableProperties_ShouldAllowNull() - { - var dto = new AgingItemDetailDto - { - Id = 456, - Title = null, - AssignedTo = null, - Priority = null, - DaysSinceUpdate = 10.0, - Urgency = "Medium", - UrgencyScore = 5 - }; - - Assert.Null(dto.Title); - Assert.Null(dto.AssignedTo); - Assert.Null(dto.Priority); - Assert.Equal("Medium", dto.Urgency); - } -} diff --git a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/WorkItemSummaryDtoTests.cs b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/WorkItemSummaryDtoTests.cs index b8e3ea9..7ae7d48 100644 --- a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/WorkItemSummaryDtoTests.cs +++ b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/WorkItemSummaryDtoTests.cs @@ -121,6 +121,8 @@ public void WorkItemSummaryDto_NullableProperties_ShouldAllowNull() AssignedTo = null, Priority = null, ChangedDate = null, + ActivatedDate = null, + ClosedDate = null, ParentId = null }; @@ -129,6 +131,8 @@ public void WorkItemSummaryDto_NullableProperties_ShouldAllowNull() Assert.Null(dto.AssignedTo); Assert.Null(dto.Priority); Assert.Null(dto.ChangedDate); + Assert.Null(dto.ActivatedDate); + Assert.Null(dto.ClosedDate); Assert.Null(dto.ParentId); } @@ -153,6 +157,8 @@ public void WorkItemSummaryDto_ContainsEssentialProperties() Assert.Contains("AssignedTo", properties); Assert.Contains("Priority", properties); Assert.Contains("ChangedDate", properties); + Assert.Contains("ActivatedDate", properties); + Assert.Contains("ClosedDate", properties); Assert.Contains("ParentId", properties); } }