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")]