diff --git a/.env.example b/.env.example deleted file mode 100644 index 1f76893..0000000 --- a/.env.example +++ /dev/null @@ -1,49 +0,0 @@ -# ============================================ -# MCP Azure DevOps Server - Configuration -# ============================================ -# Copy this file to .env and fill in your values: -# cp .env.example .env -# -# IMPORTANT: Never commit the .env file to version control! -# ============================================ - -# -------------------------------------------- -# REQUIRED: Azure DevOps Organization URL -# -------------------------------------------- -# Format: https://dev.azure.com/{organization} -# Example: https://dev.azure.com/contoso -# -AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization - -# -------------------------------------------- -# REQUIRED: Personal Access Token (PAT) -# -------------------------------------------- -# Create at: https://dev.azure.com/{organization}/_usersSettings/tokens -# -# Required scopes: -# - Work Items: Read & Write -# - Code: Read -# - Build: Read -# -AZURE_DEVOPS_PAT=your-personal-access-token - -# -------------------------------------------- -# OPTIONAL: Default Project -# -------------------------------------------- -# If set, this project will be used when no project is specified -# Must match exact project name in Azure DevOps -# -AZURE_DEVOPS_DEFAULT_PROJECT=your-project-name - -# -------------------------------------------- -# OPTIONAL: Server Security -# -------------------------------------------- -# API key for authenticating requests to the MCP server -# Generate a strong random key (e.g., openssl rand -base64 32) -# -MCP_API_KEY= - -# Enable/disable API key authentication (true/false) -# When enabled, all requests (except /health) require a valid API key -# -MCP_REQUIRE_API_KEY=false diff --git a/README.md b/README.md index f7f04f1..0f51ed5 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,40 @@ 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 @@ -368,6 +402,19 @@ 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 @@ -459,8 +506,19 @@ mcp-azure-devops/ │ ├── Configuration/ # App configuration classes │ ├── Middleware/ # HTTP middleware (authentication, etc.) │ ├── Models/ # DTOs and data models +│ │ ├── 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 │ ├── 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 │ ├── Program.cs # Entry point │ ├── appsettings.json # App settings │ └── Dockerfile # Container definition @@ -471,7 +529,6 @@ mcp-azure-devops/ │ ├── Models/ # DTO tests │ └── Tools/ # Tool behavior tests ├── .github/ # GitHub templates -├── .env.example # Environment template (Docker) ├── docker-compose.yml # Docker orchestration ├── CONTRIBUTING.md # Contributor guide ├── CODE_OF_CONDUCT.md # Community guidelines @@ -545,6 +602,44 @@ 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 new file mode 100644 index 0000000..d3aaaeb --- /dev/null +++ b/src/Viamus.Azure.Devops.Mcp.Server/Models/FlowMetricsDto.cs @@ -0,0 +1,170 @@ +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 new file mode 100644 index 0000000..5308ac9 --- /dev/null +++ b/src/Viamus.Azure.Devops.Mcp.Server/Models/WipAnalysisDto.cs @@ -0,0 +1,463 @@ +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/Tools/AnalyticsTools.cs b/src/Viamus.Azure.Devops.Mcp.Server/Tools/AnalyticsTools.cs new file mode 100644 index 0000000..aa7ec6a --- /dev/null +++ b/src/Viamus.Azure.Devops.Mcp.Server/Tools/AnalyticsTools.cs @@ -0,0 +1,1334 @@ +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 98d3db9..c83fb6b 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.1.0 + 1.2.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 new file mode 100644 index 0000000..4f7ba2c --- /dev/null +++ b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/FlowMetricsDtoTests.cs @@ -0,0 +1,351 @@ +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 new file mode 100644 index 0000000..9efd701 --- /dev/null +++ b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/WipAnalysisDtoTests.cs @@ -0,0 +1,536 @@ +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); + } +}