From 376e8b928835742f425fa29ce99578d20bf6a78d Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:07:16 +0200 Subject: [PATCH] ADO Lens 0.8.2 Fewer false alarms in sprints and checks. - `ado_sprint`: no "fits, N h spare" when most open tasks have no Remaining Work - it says how many have it instead; the attention count says how many are items and how many their tasks. - "no acceptance criteria" only for types that have the field (a process that keeps criteria in the description is not flagged, in `ado_sprint` and `ado_trace` alike); "no estimate" and "no acceptance criteria" only for items not started or in the first state of the work, not for items in test or waiting for production. - `ado_create` / `ado_update`: for a type without a criteria field the criteria are part of the description, so the length hint allows twice as many words and no "no acceptance criteria given" note. --- CHANGELOG.md | 12 +++++++ src/AdoLens/AdoLens.csproj | 2 +- src/AdoLens/Boards.cs | 58 +++++++++++++++++++++++++------ src/AdoLens/Trace.cs | 8 +++-- src/AdoLens/WorkItems.cs | 10 +++--- tests/AdoLens.Tests/BoardTests.cs | 13 +++++++ 6 files changed, 85 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b4f12a..9cb3aa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.8.2 — 2026-10-18 + +Fewer false alarms in sprints and checks. + +- `ado_sprint`: no "fits, N h spare" when most open tasks have no Remaining Work - it says how many have it instead; + the attention count says how many are items and how many their tasks. +- "no acceptance criteria" only for types that have the field (a process that keeps criteria in the description is not + flagged, in `ado_sprint` and `ado_trace` alike); "no estimate" and "no acceptance criteria" only for items not + started or in the first state of the work, not for items in test or waiting for production. +- `ado_create` / `ado_update`: for a type without a criteria field the criteria are part of the description, so the + length hint allows twice as many words and no "no acceptance criteria given" note. + ## 0.8.1 — 2026-10-18 Fixes from testing on a real project. diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index 8acb247..ebf276b 100644 --- a/src/AdoLens/AdoLens.csproj +++ b/src/AdoLens/AdoLens.csproj @@ -23,7 +23,7 @@ ADO Lens for Azure DevOps Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP Copyright © 2026 WillowDev - 0.8.1 + 0.8.2 diff --git a/src/AdoLens/Boards.cs b/src/AdoLens/Boards.cs index a3aad0a..4602e58 100644 --- a/src/AdoLens/Boards.cs +++ b/src/AdoLens/Boards.cs @@ -210,6 +210,23 @@ static string Category(Snapshot s, JsonElement wi) _ => "InProgress", }; + static bool HasField(Snapshot s, string type, string reference) => + s.FieldsOf(type) is { Count: > 0 } f ? f.Any(x => x.Ref == reference) : s.Fields.ContainsKey(reference); + + /// + /// Not started, or in the first in-progress state of its type. Later (in test, waiting for production) a missing + /// estimate or criteria is no longer worth a flag. + /// + static bool Early(Snapshot s, JsonElement wi) + { + var category = Category(s, wi); + if (category == "Proposed") return true; + if (category != "InProgress") return false; + var type = s.FindType(WorkItems.Field(wi, "System.WorkItemType") ?? ""); + var first = type?.States.FirstOrDefault(x => x.Category == "InProgress")?.Name; + return first is null || first.Equals(WorkItems.Field(wi, "System.State"), StringComparison.OrdinalIgnoreCase); + } + static string Group(string category) => category switch { "Proposed" => "not started", @@ -287,17 +304,28 @@ public static async Task SprintAsync(Lens lens, string? connection, stri } sb.AppendLine(); - var remaining = items.Where(w => Group(Category(s, w)) is not ("done" or "removed")).Sum(w => Number(w, RemainingRef) ?? 0); + var open = items.Where(w => Group(Category(s, w)) is not ("done" or "removed")).ToList(); + var remaining = open.Sum(w => Number(w, RemainingRef) ?? 0); + // Remaining Work is kept on tasks; when most open tasks have none, a "fits" would only be a guess. + var openTasks = open.Where(w => Level(s, w) == Levels.Task).ToList(); + var kept = openTasks.Count(w => Number(w, RemainingRef) is not null); if (it.Finish is { } end && day <= end && s.Fields.ContainsKey(RemainingRef)) { - var cap = await TryAsync(() => boards.CapacityLeftAsync(name, t, it, it.Start is { } st && st > day ? st : day, working, ct)); - sb.AppendLine(cap is { } c - ? $"remaining work {Num(remaining)} h, capacity left {Num(c.Hours)} h ({c.People} people) - " + - (remaining <= c.Hours ? $"fits, {Num(c.Hours - remaining)} h spare" : $"OVER by {Num(remaining - c.Hours)} h") - : $"remaining work {Num(remaining)} h; no capacity set for the team"); + if (openTasks.Count > 0 && kept * 2 < openTasks.Count) + sb.AppendLine($"remaining work is filled on only {kept} of {openTasks.Count} open tasks ({Num(remaining)} h) - too little to compare with capacity"); + else + { + var cap = await TryAsync(() => boards.CapacityLeftAsync(name, t, it, it.Start is { } st && st > day ? st : day, working, ct)); + sb.AppendLine(cap is { } c + ? $"remaining work {Num(remaining)} h, capacity left {Num(c.Hours)} h ({c.People} people) - " + + (remaining <= c.Hours ? $"fits, {Num(c.Hours - remaining)} h spare" : $"OVER by {Num(remaining - c.Hours)} h") + : $"remaining work {Num(remaining)} h; no capacity set for the team"); + } } var attention = new List(); + var attentionTop = 0; + var topIds = live.Select(WorkItems.Id).ToHashSet(); foreach (var w in live.Concat(children.Values.SelectMany(x => x)).Where(w => Group(Category(s, w)) is not ("done" or "removed"))) { var why = new List(); @@ -306,15 +334,23 @@ public static async Task SprintAsync(Lens lens, string? connection, stri why.Add($"no change for {(day - ch.ToLocalTime().Date).Days} days"); if ((WorkItems.Field(w, "System.Tags") ?? "").Split(';').Any(x => x.Trim().Equals("blocked", StringComparison.OrdinalIgnoreCase)) || WorkItems.Field(w, BlockedRef) is "Yes") why.Add("blocked"); - if (level == Levels.Requirement && estimate is not null && Number(w, estimate) is null) why.Add("no estimate"); - if (level == Levels.Requirement && fields.Contains(WorkItems.AcceptanceRef) && Html.ToMarkdown(WorkItems.Field(w, WorkItems.AcceptanceRef)).Length == 0) - why.Add("no acceptance criteria"); + var typeName = WorkItems.Field(w, "System.WorkItemType") ?? ""; + if (level == Levels.Requirement && Early(s, w)) + { + if (estimate is not null && HasField(s, typeName, estimate) && Number(w, estimate) is null) why.Add("no estimate"); + if (fields.Contains(WorkItems.AcceptanceRef) && HasField(s, typeName, WorkItems.AcceptanceRef) + && Html.ToMarkdown(WorkItems.Field(w, WorkItems.AcceptanceRef)).Length == 0) + why.Add("no acceptance criteria"); + } if (WorkItems.Field(w, "System.AssignedTo") is null) why.Add("unassigned"); - if (why.Count > 0) attention.Add($"- {WorkItemFormat.Header(w)}{Who(w)}: {string.Join("; ", why)}"); + if (why.Count == 0) continue; + attention.Add($"- {WorkItemFormat.Header(w)}{Who(w)}: {string.Join("; ", why)}"); + if (topIds.Contains(WorkItems.Id(w))) attentionTop++; } if (attention.Count > 0) { - sb.AppendLine($"attention ({attention.Count}):"); + var taskCount = attention.Count - attentionTop; + sb.AppendLine(taskCount > 0 ? $"attention ({attention.Count}: {attentionTop} items, {taskCount} of their tasks):" : $"attention ({attention.Count}):"); foreach (var a in attention.Take(detail == "full" ? 200 : 15)) sb.AppendLine(a); if (attention.Count > 15 && detail != "full") sb.AppendLine($"- … {attention.Count - 15} more (detail=full)"); } diff --git a/src/AdoLens/Trace.cs b/src/AdoLens/Trace.cs index 13e603a..cb23fa1 100644 --- a/src/AdoLens/Trace.cs +++ b/src/AdoLens/Trace.cs @@ -85,8 +85,12 @@ string Category(JsonElement x) => { sb.AppendLine("acceptance criteria: none in their field" + (description.Length > 0 ? "; description:" : "")); if (description.Length > 0) sb.AppendLine(description.Length > 1500 && !full ? description[..1500] + "…" : description); - signals.Add(description.Length > 0 ? "no acceptance criteria: the check can only go by the description" - : "no acceptance criteria and no description: nothing to check the code against (look at the parent item)"); + // A process without the criteria field keeps them in the description: nothing to flag then. + var criteriaField = s.FindType(WorkItems.Field(wi, "System.WorkItemType") ?? "") is not { } wt + || s.FieldsOf(wt.Name) is not { Count: > 0 } wf || wf.Any(f => f.Ref == WorkItems.AcceptanceRef); + if (description.Length == 0 || criteriaField) + signals.Add(description.Length > 0 ? "no acceptance criteria: the check can only go by the description" + : "no acceptance criteria and no description: nothing to check the code against (look at the parent item)"); } var criteriaChanges = (await work.UpdatesAsync(project, id, ct)) .Where(u => Json.Obj(Json.Obj(u, "fields"), WorkItems.AcceptanceRef).ValueKind == JsonValueKind.Object) diff --git a/src/AdoLens/WorkItems.cs b/src/AdoLens/WorkItems.cs index 8acaffe..e23bd65 100644 --- a/src/AdoLens/WorkItems.cs +++ b/src/AdoLens/WorkItems.cs @@ -244,7 +244,7 @@ public async Task> BuildOpsAsync(Snapshot s, TypeInfo type, WriteRe if (r.Parent is { } parent) ops.Add(new { op = "add", path = "/relations/-", value = new { rel = "System.LinkTypes.Hierarchy-Reverse", url = $"{WebBase}_apis/wit/workItems/{parent}" } }); - Lengths(type, r, notes); + Lengths(type, r, notes, Has(s, type, AcceptanceRef)); return ops; } @@ -334,15 +334,17 @@ internal static string MergeTags(string? current, string change) } /// Soft limits of the writing rules: informative notes, the write still happens. - static void Lengths(TypeInfo type, WriteRequest r, List notes) + static void Lengths(TypeInfo type, WriteRequest r, List notes, bool criteriaField) { var limit = type.Level switch { Levels.Requirement => 120, Levels.Bug => 100, Levels.Task => 40, Levels.Portfolio => 150, _ => 0 }; - var words = Html.Words(r.Description); + // Without a criteria field the criteria are part of the description, so it may be longer. + if (!criteriaField) limit *= 2; + var words = Html.Words(r.Description) + (criteriaField ? 0 : Html.Words(r.AcceptanceCriteria)); if (limit > 0 && words > limit) notes.Add($"description has {words} words; {type.Level} items usually need ≤ {limit} - what to do, not how"); var criteria = (r.AcceptanceCriteria ?? "").Split('\n').Count(l => l.TrimStart() is var t && (t.StartsWith("- ") || t.StartsWith("* ") || (t.Length > 2 && char.IsDigit(t[0]) && t.Contains(". ")))); if (criteria > 7) notes.Add($"{criteria} acceptance criteria; more than 7 usually means the item should be split"); - if (type.Level == Levels.Requirement && r.Description is not null && r.AcceptanceCriteria is null && words > 0) + if (criteriaField && type.Level == Levels.Requirement && r.Description is not null && r.AcceptanceCriteria is null && words > 0) notes.Add("no acceptance criteria given"); } diff --git a/tests/AdoLens.Tests/BoardTests.cs b/tests/AdoLens.Tests/BoardTests.cs index aa534a8..662e183 100644 --- a/tests/AdoLens.Tests/BoardTests.cs +++ b/tests/AdoLens.Tests/BoardTests.cs @@ -43,6 +43,7 @@ public BoardTests() {"rel":"System.LinkTypes.Hierarchy-Forward","source":{"id":1},"target":{"id":2}}, {"rel":null,"source":null,"target":{"id":4}}]} """) + .On(team + "teamsettings/iterations/s3/workitems", """{"workItemRelations":[]}""") .On(team + "teamsettings/iterations/s2/capacities", """ {"teamMembers":[ {"teamMember":{"displayName":"Anna Nowak"},"activities":[{"capacityPerDay":6,"name":"Development"}],"daysOff":[]}, @@ -102,6 +103,18 @@ public async Task Sprint_shows_progress_capacity_and_what_needs_attention() Assert.DoesNotContain("not started (1):\n #2", text); } + [Fact] + public async Task Sprint_says_so_when_none_is_current_and_capacity_is_not_compared_without_remaining_work() + { + var after = await BoardQueries.SprintAsync(lens, null, null, null, null, null, new DateTime(2026, 11, 20)); + Assert.StartsWith("NO CURRENT SPRINT for the team today - this is the last one that ended:", after); + + ((JsonObject)items.Items[2]["fields"]!).Remove("Microsoft.VSTS.Scheduling.RemainingWork"); + var text = (await BoardQueries.SprintAsync(lens, null, null, null, null, null, Today)).Replace("\r", ""); + Assert.Contains("remaining work is filled on only 0 of 1 open tasks (0 h) - too little to compare with capacity", text); + Assert.DoesNotContain("spare", text); + } + [Theory] [InlineData("next", "Sprint 3")] [InlineData("previous", "Sprint 1")]