Skip to content

Commit 376e8b9

Browse files
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.
1 parent 6172309 commit 376e8b9

6 files changed

Lines changed: 85 additions & 18 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## 0.8.2 — 2026-10-18
4+
5+
Fewer false alarms in sprints and checks.
6+
7+
- `ado_sprint`: no "fits, N h spare" when most open tasks have no Remaining Work - it says how many have it instead;
8+
the attention count says how many are items and how many their tasks.
9+
- "no acceptance criteria" only for types that have the field (a process that keeps criteria in the description is not
10+
flagged, in `ado_sprint` and `ado_trace` alike); "no estimate" and "no acceptance criteria" only for items not
11+
started or in the first state of the work, not for items in test or waiting for production.
12+
- `ado_create` / `ado_update`: for a type without a criteria field the criteria are part of the description, so the
13+
length hint allows twice as many words and no "no acceptance criteria given" note.
14+
315
## 0.8.1 — 2026-10-18
416

517
Fixes from testing on a real project.

‎src/AdoLens/AdoLens.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
<AssemblyTitle>ADO Lens for Azure DevOps</AssemblyTitle>
2424
<Description>Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP</Description>
2525
<Copyright>Copyright © 2026 WillowDev</Copyright>
26-
<Version>0.8.1</Version>
26+
<Version>0.8.2</Version>
2727
</PropertyGroup>
2828

2929
<ItemGroup>

‎src/AdoLens/Boards.cs‎

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,23 @@ static string Category(Snapshot s, JsonElement wi)
210210
_ => "InProgress",
211211
};
212212

213+
static bool HasField(Snapshot s, string type, string reference) =>
214+
s.FieldsOf(type) is { Count: > 0 } f ? f.Any(x => x.Ref == reference) : s.Fields.ContainsKey(reference);
215+
216+
/// <summary>
217+
/// Not started, or in the first in-progress state of its type. Later (in test, waiting for production) a missing
218+
/// estimate or criteria is no longer worth a flag.
219+
/// </summary>
220+
static bool Early(Snapshot s, JsonElement wi)
221+
{
222+
var category = Category(s, wi);
223+
if (category == "Proposed") return true;
224+
if (category != "InProgress") return false;
225+
var type = s.FindType(WorkItems.Field(wi, "System.WorkItemType") ?? "");
226+
var first = type?.States.FirstOrDefault(x => x.Category == "InProgress")?.Name;
227+
return first is null || first.Equals(WorkItems.Field(wi, "System.State"), StringComparison.OrdinalIgnoreCase);
228+
}
229+
213230
static string Group(string category) => category switch
214231
{
215232
"Proposed" => "not started",
@@ -287,17 +304,28 @@ public static async Task<string> SprintAsync(Lens lens, string? connection, stri
287304
}
288305
sb.AppendLine();
289306

290-
var remaining = items.Where(w => Group(Category(s, w)) is not ("done" or "removed")).Sum(w => Number(w, RemainingRef) ?? 0);
307+
var open = items.Where(w => Group(Category(s, w)) is not ("done" or "removed")).ToList();
308+
var remaining = open.Sum(w => Number(w, RemainingRef) ?? 0);
309+
// Remaining Work is kept on tasks; when most open tasks have none, a "fits" would only be a guess.
310+
var openTasks = open.Where(w => Level(s, w) == Levels.Task).ToList();
311+
var kept = openTasks.Count(w => Number(w, RemainingRef) is not null);
291312
if (it.Finish is { } end && day <= end && s.Fields.ContainsKey(RemainingRef))
292313
{
293-
var cap = await TryAsync(() => boards.CapacityLeftAsync(name, t, it, it.Start is { } st && st > day ? st : day, working, ct));
294-
sb.AppendLine(cap is { } c
295-
? $"remaining work {Num(remaining)} h, capacity left {Num(c.Hours)} h ({c.People} people) - " +
296-
(remaining <= c.Hours ? $"fits, {Num(c.Hours - remaining)} h spare" : $"OVER by {Num(remaining - c.Hours)} h")
297-
: $"remaining work {Num(remaining)} h; no capacity set for the team");
314+
if (openTasks.Count > 0 && kept * 2 < openTasks.Count)
315+
sb.AppendLine($"remaining work is filled on only {kept} of {openTasks.Count} open tasks ({Num(remaining)} h) - too little to compare with capacity");
316+
else
317+
{
318+
var cap = await TryAsync(() => boards.CapacityLeftAsync(name, t, it, it.Start is { } st && st > day ? st : day, working, ct));
319+
sb.AppendLine(cap is { } c
320+
? $"remaining work {Num(remaining)} h, capacity left {Num(c.Hours)} h ({c.People} people) - " +
321+
(remaining <= c.Hours ? $"fits, {Num(c.Hours - remaining)} h spare" : $"OVER by {Num(remaining - c.Hours)} h")
322+
: $"remaining work {Num(remaining)} h; no capacity set for the team");
323+
}
298324
}
299325

300326
var attention = new List<string>();
327+
var attentionTop = 0;
328+
var topIds = live.Select(WorkItems.Id).ToHashSet();
301329
foreach (var w in live.Concat(children.Values.SelectMany(x => x)).Where(w => Group(Category(s, w)) is not ("done" or "removed")))
302330
{
303331
var why = new List<string>();
@@ -306,15 +334,23 @@ public static async Task<string> SprintAsync(Lens lens, string? connection, stri
306334
why.Add($"no change for {(day - ch.ToLocalTime().Date).Days} days");
307335
if ((WorkItems.Field(w, "System.Tags") ?? "").Split(';').Any(x => x.Trim().Equals("blocked", StringComparison.OrdinalIgnoreCase))
308336
|| WorkItems.Field(w, BlockedRef) is "Yes") why.Add("blocked");
309-
if (level == Levels.Requirement && estimate is not null && Number(w, estimate) is null) why.Add("no estimate");
310-
if (level == Levels.Requirement && fields.Contains(WorkItems.AcceptanceRef) && Html.ToMarkdown(WorkItems.Field(w, WorkItems.AcceptanceRef)).Length == 0)
311-
why.Add("no acceptance criteria");
337+
var typeName = WorkItems.Field(w, "System.WorkItemType") ?? "";
338+
if (level == Levels.Requirement && Early(s, w))
339+
{
340+
if (estimate is not null && HasField(s, typeName, estimate) && Number(w, estimate) is null) why.Add("no estimate");
341+
if (fields.Contains(WorkItems.AcceptanceRef) && HasField(s, typeName, WorkItems.AcceptanceRef)
342+
&& Html.ToMarkdown(WorkItems.Field(w, WorkItems.AcceptanceRef)).Length == 0)
343+
why.Add("no acceptance criteria");
344+
}
312345
if (WorkItems.Field(w, "System.AssignedTo") is null) why.Add("unassigned");
313-
if (why.Count > 0) attention.Add($"- {WorkItemFormat.Header(w)}{Who(w)}: {string.Join("; ", why)}");
346+
if (why.Count == 0) continue;
347+
attention.Add($"- {WorkItemFormat.Header(w)}{Who(w)}: {string.Join("; ", why)}");
348+
if (topIds.Contains(WorkItems.Id(w))) attentionTop++;
314349
}
315350
if (attention.Count > 0)
316351
{
317-
sb.AppendLine($"attention ({attention.Count}):");
352+
var taskCount = attention.Count - attentionTop;
353+
sb.AppendLine(taskCount > 0 ? $"attention ({attention.Count}: {attentionTop} items, {taskCount} of their tasks):" : $"attention ({attention.Count}):");
318354
foreach (var a in attention.Take(detail == "full" ? 200 : 15)) sb.AppendLine(a);
319355
if (attention.Count > 15 && detail != "full") sb.AppendLine($"- … {attention.Count - 15} more (detail=full)");
320356
}

‎src/AdoLens/Trace.cs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,12 @@ string Category(JsonElement x) =>
8585
{
8686
sb.AppendLine("acceptance criteria: none in their field" + (description.Length > 0 ? "; description:" : ""));
8787
if (description.Length > 0) sb.AppendLine(description.Length > 1500 && !full ? description[..1500] + "…" : description);
88-
signals.Add(description.Length > 0 ? "no acceptance criteria: the check can only go by the description"
89-
: "no acceptance criteria and no description: nothing to check the code against (look at the parent item)");
88+
// A process without the criteria field keeps them in the description: nothing to flag then.
89+
var criteriaField = s.FindType(WorkItems.Field(wi, "System.WorkItemType") ?? "") is not { } wt
90+
|| s.FieldsOf(wt.Name) is not { Count: > 0 } wf || wf.Any(f => f.Ref == WorkItems.AcceptanceRef);
91+
if (description.Length == 0 || criteriaField)
92+
signals.Add(description.Length > 0 ? "no acceptance criteria: the check can only go by the description"
93+
: "no acceptance criteria and no description: nothing to check the code against (look at the parent item)");
9094
}
9195
var criteriaChanges = (await work.UpdatesAsync(project, id, ct))
9296
.Where(u => Json.Obj(Json.Obj(u, "fields"), WorkItems.AcceptanceRef).ValueKind == JsonValueKind.Object)

‎src/AdoLens/WorkItems.cs‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ public async Task<List<object>> BuildOpsAsync(Snapshot s, TypeInfo type, WriteRe
244244
if (r.Parent is { } parent)
245245
ops.Add(new { op = "add", path = "/relations/-", value = new { rel = "System.LinkTypes.Hierarchy-Reverse", url = $"{WebBase}_apis/wit/workItems/{parent}" } });
246246

247-
Lengths(type, r, notes);
247+
Lengths(type, r, notes, Has(s, type, AcceptanceRef));
248248
return ops;
249249
}
250250

@@ -334,15 +334,17 @@ internal static string MergeTags(string? current, string change)
334334
}
335335

336336
/// <summary>Soft limits of the writing rules: informative notes, the write still happens.</summary>
337-
static void Lengths(TypeInfo type, WriteRequest r, List<string> notes)
337+
static void Lengths(TypeInfo type, WriteRequest r, List<string> notes, bool criteriaField)
338338
{
339339
var limit = type.Level switch { Levels.Requirement => 120, Levels.Bug => 100, Levels.Task => 40, Levels.Portfolio => 150, _ => 0 };
340-
var words = Html.Words(r.Description);
340+
// Without a criteria field the criteria are part of the description, so it may be longer.
341+
if (!criteriaField) limit *= 2;
342+
var words = Html.Words(r.Description) + (criteriaField ? 0 : Html.Words(r.AcceptanceCriteria));
341343
if (limit > 0 && words > limit)
342344
notes.Add($"description has {words} words; {type.Level} items usually need ≤ {limit} - what to do, not how");
343345
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(". "))));
344346
if (criteria > 7) notes.Add($"{criteria} acceptance criteria; more than 7 usually means the item should be split");
345-
if (type.Level == Levels.Requirement && r.Description is not null && r.AcceptanceCriteria is null && words > 0)
347+
if (criteriaField && type.Level == Levels.Requirement && r.Description is not null && r.AcceptanceCriteria is null && words > 0)
346348
notes.Add("no acceptance criteria given");
347349
}
348350

‎tests/AdoLens.Tests/BoardTests.cs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public BoardTests()
4343
{"rel":"System.LinkTypes.Hierarchy-Forward","source":{"id":1},"target":{"id":2}},
4444
{"rel":null,"source":null,"target":{"id":4}}]}
4545
""")
46+
.On(team + "teamsettings/iterations/s3/workitems", """{"workItemRelations":[]}""")
4647
.On(team + "teamsettings/iterations/s2/capacities", """
4748
{"teamMembers":[
4849
{"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()
102103
Assert.DoesNotContain("not started (1):\n #2", text);
103104
}
104105

106+
[Fact]
107+
public async Task Sprint_says_so_when_none_is_current_and_capacity_is_not_compared_without_remaining_work()
108+
{
109+
var after = await BoardQueries.SprintAsync(lens, null, null, null, null, null, new DateTime(2026, 11, 20));
110+
Assert.StartsWith("NO CURRENT SPRINT for the team today - this is the last one that ended:", after);
111+
112+
((JsonObject)items.Items[2]["fields"]!).Remove("Microsoft.VSTS.Scheduling.RemainingWork");
113+
var text = (await BoardQueries.SprintAsync(lens, null, null, null, null, null, Today)).Replace("\r", "");
114+
Assert.Contains("remaining work is filled on only 0 of 1 open tasks (0 h) - too little to compare with capacity", text);
115+
Assert.DoesNotContain("spare", text);
116+
}
117+
105118
[Theory]
106119
[InlineData("next", "Sprint 3")]
107120
[InlineData("previous", "Sprint 1")]

0 commit comments

Comments
 (0)