Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/AdoLens/AdoLens.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<AssemblyTitle>ADO Lens for Azure DevOps</AssemblyTitle>
<Description>Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP</Description>
<Copyright>Copyright © 2026 WillowDev</Copyright>
<Version>0.8.1</Version>
<Version>0.8.2</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
58 changes: 47 additions & 11 deletions src/AdoLens/Boards.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>
/// 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.
/// </summary>
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",
Expand Down Expand Up @@ -287,17 +304,28 @@ public static async Task<string> 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<string>();
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<string>();
Expand All @@ -306,15 +334,23 @@ public static async Task<string> 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)");
}
Expand Down
8 changes: 6 additions & 2 deletions src/AdoLens/Trace.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 6 additions & 4 deletions src/AdoLens/WorkItems.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ public async Task<List<object>> 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;
}

Expand Down Expand Up @@ -334,15 +334,17 @@ internal static string MergeTags(string? current, string change)
}

/// <summary>Soft limits of the writing rules: informative notes, the write still happens.</summary>
static void Lengths(TypeInfo type, WriteRequest r, List<string> notes)
static void Lengths(TypeInfo type, WriteRequest r, List<string> 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");
}

Expand Down
13 changes: 13 additions & 0 deletions tests/AdoLens.Tests/BoardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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":[]},
Expand Down Expand Up @@ -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")]
Expand Down