From 01373b8b63ff93bbf9eece81b22834f6688c667e Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:07:45 +0200 Subject: [PATCH] ADO Lens 0.9.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes and additions from testing 0.9 on a real project. - `ado_create`: fields with a default of the process (Value Area, Severity, Triage…) and fields Azure DevOps fills itself (Area ID, Iteration ID, State, Work Item Type) are no longer asked for - the rule check of 0.9.0 took the system rules of the process for fields to give. A rule "required while State was empty" is checked as "on create" instead of ending in a 400 from Azure DevOps. - `ado_fields type=`: fields required by rules are named as REQUIRED, and fields that are required but filled by Azure DevOps say so. - `ado_trace` much faster on items with many pull requests: the files of a PR come from its last iteration instead of a diff of its merge commit, merge commits of linked PRs are not read twice, a completed PR into a branch answers "is it there" without asking, and only the newest change of each source -> target pair is checked. Reading the changes gets half of the time, so builds and deployments are still read. - `ado_trace` with no code linked looks for configuration words in every text field of the item and its tasks. - `ado_sprint`: a "Done" state that the process puts in the Resolved category counts as done. - `ado_work_item`: names of up to 40 linked releases (was 10); a release deleted by retention or not readable says so. - Numbered lists keep their numbering (`
    `). - `ado_pr_update`: `update` adds reviewers, `requiredReviewers` (on create and update) makes them required, `autocomplete` (merge strategy, delete source branch, text = merge message) and `cancel_autocomplete`. - A parameter a tool does not have is reported with the tool's parameters instead of being dropped (a conversation started before an update still sees the old tool descriptions). --- CHANGELOG.md | 23 +++++++++++ README.md | 2 +- README.pl.md | 2 +- src/AdoLens/AdoLens.csproj | 2 +- src/AdoLens/Boards.cs | 5 ++- src/AdoLens/Formatting.cs | 2 +- src/AdoLens/Html.cs | 11 +++++- src/AdoLens/McpTools.cs | 7 ++-- src/AdoLens/PullRequests.cs | 47 ++++++++++++++++++++--- src/AdoLens/Queries.cs | 2 +- src/AdoLens/ToolArguments.cs | 20 ++++++++++ src/AdoLens/Trace.cs | 51 ++++++++++++++++++++----- src/AdoLens/WorkItemFormat.cs | 17 +++++++-- src/AdoLens/WorkItems.cs | 51 +++++++++++++++++-------- tests/AdoLens.Tests/ImprovementTests.cs | 21 ++++++++++ tests/AdoLens.Tests/PrPipelineTests.cs | 12 ++++++ 16 files changed, 230 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02d5351..ba05e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.9.2 — 2026-10-19 + +Fixes and additions from testing 0.9 on a real project. + +- `ado_create`: fields with a default of the process (Value Area, Severity, Triage…) and fields Azure DevOps fills + itself (Area ID, Iteration ID, State, Work Item Type) are no longer asked for - the rule check of 0.9.0 took the + system rules of the process for fields to give. A rule "required while State was empty" is checked as "on create" + instead of ending in a 400 from Azure DevOps. +- `ado_fields type=`: fields required by rules are named as REQUIRED, and fields that are required but filled by + Azure DevOps say so. +- `ado_trace` much faster on items with many pull requests: the files of a PR come from its last iteration instead of + a diff of its merge commit, merge commits of linked PRs are not read twice, a completed PR into a branch answers + "is it there" without asking, and only the newest change of each source -> target pair is checked. Reading the + changes gets half of the time, so builds and deployments are still read. +- `ado_trace` with no code linked looks for configuration words in every text field of the item and its tasks. +- `ado_sprint`: a "Done" state that the process puts in the Resolved category counts as done. +- `ado_work_item`: names of up to 40 linked releases (was 10); a release deleted by retention or not readable says so. +- Numbered lists keep their numbering (`
      `). +- `ado_pr_update`: `update` adds reviewers, `requiredReviewers` (on create and update) makes them required, + `autocomplete` (merge strategy, delete source branch, text = merge message) and `cancel_autocomplete`. +- A parameter a tool does not have is reported with the tool's parameters instead of being dropped (a conversation + started before an update still sees the old tool descriptions). + ## 0.9.1 — 2026-10-19 - `ado_trace` always answers: on an item with many links (pull requests, commits, builds, releases) the slow parts diff --git a/README.md b/README.md index f8771a1..39641c3 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ What each MCP tool answers, when Claude should reach for it, and the CLI command | `ado_trace` | was it done as asked, and where is it: the acceptance criteria (from the parent for a task) and whether they changed after coding started; changesets, commits and pull requests linked to the item and its tasks with the X++ objects they touch; builds (also those deleted by retention); every release stage of the code's branches with **YES / NOT YET**, labelled by the environment map (`staging` → UAT); for Git, whether the change is merged into each mapped branch; tests, tasks and warning signals | "did they do what I asked", "is it on UAT yet" | `adolens trace 123` | | `ado_changes` | the files of a changeset (`523`), pull request (`PR 482`) or commit (sha + `repo`) with X++ objects and their added / changed / removed methods; `file` gives the diff of one file — for AOT XML per method plus the metadata (fields, controls, properties) without raw XML noise | reading what exactly changed | `adolens changes 523`, `adolens changes 523 --file AxClass/SalesTable_Extension.xml` | | `ado_pr` | without id: pull requests waiting for my review and mine (`list=active`: all); `merged=`: those completed into a branch (`since`, `top`, `repo`) with their work items; with id: status, reviewers and votes, checks (build, work item linking, reviewers), linked work items, open comment threads written by people (not system notes) | code review, "what is blocking my PR" | `adolens pr`, `adolens pr 482` | -| `ado_pr_update` | `create` (repo, source, target, title, markdown text, work items, reviewers, draft, tags), `update` (title, description, link work items - for branch policies), `comment` (general or on a file and line), `reply`, `resolve`, `vote`, `tag`, `publish`, `complete`, `abandon`; `@[name]` notifies | reviewing, opening and finishing PRs | — | +| `ado_pr_update` | `create` (repo, source, target, title, markdown text, work items, reviewers, draft, tags), `update` (title, description, reviewers, link work items - for branch policies), `autocomplete` / `cancel_autocomplete` (merge strategy, delete the source branch, merge message), reviewers optional or required, `comment` (general or on a file and line), `reply`, `resolve`, `vote`, `tag`, `publish`, `complete`, `abandon`; `@[name]` notifies | reviewing, opening and finishing PRs | — | | `ado_pipeline` | recent builds (of a pipeline, a branch); with `build`: the failed stage, job and steps, their errors, and the lines of the log that report errors (MSBuild noise cut); `artifacts=true` lists the build's artifacts, `artifact=` the files in one (`part=` reads a file), `save=true` saves it to disk | "why is the build red", getting a package or a log | `adolens pipeline 900`, `adolens pipeline 900 --artifact drop --save` | | `ado_pipeline_run` | queues a pipeline by name or id, optionally on a branch, with variables | re-running a build | — | | `ado_wiki` | the project wiki: page tree (`depth`), one page as markdown with its version and subpages, or search across the project's wikis with snippets; long pages are cut unless `detail=full` | "how does X work", finding documentation | `adolens wiki`, `adolens wiki "Team/Setup"`, `adolens wiki --search "dual write"` | diff --git a/README.pl.md b/README.pl.md index 52e20cc..9938254 100644 --- a/README.pl.md +++ b/README.pl.md @@ -219,7 +219,7 @@ Na co odpowiada każde narzędzie MCP, kiedy Claude powinien po nie sięgnąć i | `ado_trace` | czy zrobiono to, o co proszono, i gdzie to jest: kryteria akceptacji (dla zadania — z elementu nadrzędnego) i to, czy zmieniły się po rozpoczęciu prac; changesety, commity i pull requesty podpięte pod element i jego zadania, z obiektami X++, których dotyczą; buildy (także usunięte przez retencję); każdy etap release'u gałęzi tego kodu z **TAK / JESZCZE NIE** i etykietą z mapy środowisk (`staging` → UAT); dla Gita — czy zmiana jest scalona do każdej zmapowanej gałęzi; testy, zadania i sygnały ostrzegawcze | „czy zrobili to, o co prosiłem”, „czy to już jest na UAT” | `adolens trace 123` | | `ado_changes` | pliki changesetu (`523`), pull requesta (`PR 482`) albo commita (sha + `repo`) z obiektami X++ i ich dodanymi / zmienionymi / usuniętymi metodami; `file` daje diff jednego pliku — dla AOT XML per metoda plus metadane (pola, kontrolki, właściwości) bez szumu surowego XML-a | czytanie, co dokładnie się zmieniło | `adolens changes 523`, `adolens changes 523 --file AxClass/SalesTable_Extension.xml` | | `ado_pr` | `merged=`: pull requesty scalone do gałęzi (`since`, `top`, `repo`) z ich work itemami; bez id: pull requesty czekające na mój przegląd i moje (`list=active`: wszystkie); z id: stan, recenzenci i głosy, kontrole (build, podpięty work item, recenzenci), podpięte work itemy, otwarte wątki pisane przez ludzi (bez wpisów systemowych) | code review, „co blokuje mój PR” | `adolens pr`, `adolens pr 482` | -| `ado_pr_update` | `create` (repo, źródło, cel, tytuł, opis w markdownie, work itemy, recenzenci, szkic, tagi), `update` (tytuł, opis, podpięcie work itemów - pod polityki gałęzi), `comment` (ogólny albo na pliku i linii), `reply`, `resolve`, `vote`, `tag`, `publish`, `complete`, `abandon`; `@[imię]` wysyła powiadomienie | przegląd, zakładanie i kończenie PR-ów | — | +| `ado_pr_update` | `create` (repo, źródło, cel, tytuł, opis w markdownie, work itemy, recenzenci, szkic, tagi), `update` (tytuł, opis, recenzenci, podpięcie work itemów - pod polityki gałęzi), `autocomplete` / `cancel_autocomplete` (strategia scalenia, usunięcie gałęzi źródłowej, komunikat), recenzenci opcjonalni albo wymagani, `comment` (ogólny albo na pliku i linii), `reply`, `resolve`, `vote`, `tag`, `publish`, `complete`, `abandon`; `@[imię]` wysyła powiadomienie | przegląd, zakładanie i kończenie PR-ów | — | | `ado_pipeline` | ostatnie buildy (pipeline'u, gałęzi); z `build`: nieudany etap, zadanie i kroki, ich błędy i linie logu zgłaszające błędy (bez szumu MSBuild); `artifacts=true` lista artefaktów buildu, `artifact=` pliki w nim (`part=` czyta plik), `save=true` zapisuje go na dysk | „czemu build jest czerwony”, pobranie paczki lub logu | `adolens pipeline 900`, `adolens pipeline 900 --artifact drop --save` | | `ado_pipeline_run` | uruchamia pipeline po nazwie albo id, opcjonalnie na gałęzi, ze zmiennymi | ponowne puszczenie buildu | — | | `ado_wiki` | wiki projektu: drzewo stron (`depth`), strona jako markdown z wersją i podstronami albo wyszukiwanie we wszystkich wiki projektu z fragmentami; długie strony są ucinane, chyba że `detail=full` | „jak działa X”, szukanie dokumentacji | `adolens wiki`, `adolens wiki "Team/Setup"`, `adolens wiki --search "dual write"` | diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index fc83764..1a131a9 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.9.1 + 0.9.2 diff --git a/src/AdoLens/Boards.cs b/src/AdoLens/Boards.cs index 73385c9..2c32eae 100644 --- a/src/AdoLens/Boards.cs +++ b/src/AdoLens/Boards.cs @@ -194,7 +194,10 @@ static string Category(Snapshot s, JsonElement wi) { var type = s.FindType(WorkItems.Field(wi, "System.WorkItemType") ?? ""); var state = WorkItems.Field(wi, "System.State"); - return type?.States.FirstOrDefault(x => x.Name.Equals(state, StringComparison.OrdinalIgnoreCase))?.Category + var category = type?.States.FirstOrDefault(x => x.Name.Equals(state, StringComparison.OrdinalIgnoreCase))?.Category; + // Some processes put "Done" in the Resolved category (waiting for a check); for the people it is done. + if (category == "Resolved" && GuessCategory(state) == "Completed") return "Completed"; + return category // A type or state this project does not know (an item of another project in the sprint): any type with that state. ?? s.Types.SelectMany(t => t.States).FirstOrDefault(x => x.Name.Equals(state, StringComparison.OrdinalIgnoreCase))?.Category ?? GuessCategory(state); diff --git a/src/AdoLens/Formatting.cs b/src/AdoLens/Formatting.cs index 688d696..2ceb60d 100644 --- a/src/AdoLens/Formatting.cs +++ b/src/AdoLens/Formatting.cs @@ -86,7 +86,7 @@ static string FieldLine(Snapshot s, TypeField f) { var def = s.Fields.GetValueOrDefault(f.Ref); var parts = new List { $"{f.Name} ({f.Ref})", TypeName(def) }; - if (f.Required) parts.Add("required"); + if (f.Required) parts.Add(WorkItems.FilledByAzureDevOps(f.Ref) ? "required, filled by Azure DevOps" : "required"); if (def?.ReadOnly == true) parts.Add("read-only"); if (!string.IsNullOrEmpty(f.Default)) parts.Add($"default {f.Default}"); var line = string.Join(", ", parts); diff --git a/src/AdoLens/Html.cs b/src/AdoLens/Html.cs index 2a8392f..9e324f1 100644 --- a/src/AdoLens/Html.cs +++ b/src/AdoLens/Html.cs @@ -32,6 +32,9 @@ public static partial class Html private static partial Regex BlankLines(); /// HTML → markdown: headings, lists, bold, links, code, tables as "a | b"; images become "[image]". + [GeneratedRegex(@"\bstart\s*=\s*[""']?(\d+)", RegexOptions.IgnoreCase)] + private static partial Regex OlStart(); + public static string ToMarkdown(string? html) { if (string.IsNullOrWhiteSpace(html)) return ""; @@ -59,7 +62,13 @@ public static string ToMarkdown(string? html) case "ul" or "ol": // Only the outermost list is separated from the text; nested ones continue under their item. if (closing) { if (lists.Count > 0) lists.Pop(); if (lists.Count == 0) sb.Append('\n'); } - else { if (lists.Count == 0) sb.Append('\n'); lists.Push((name == "ol", 0)); } + else + { + if (lists.Count == 0) sb.Append('\n'); + //
        continues a numbering broken by text in between. + var first = name == "ol" && OlStart().Match(attrs) is { Success: true } st && int.TryParse(st.Groups[1].Value, out var n) ? n - 1 : 0; + lists.Push((name == "ol", first)); + } break; case "li" when !closing: var depth = Math.Max(0, lists.Count - 1); diff --git a/src/AdoLens/McpTools.cs b/src/AdoLens/McpTools.cs index 21cec5b..775303d 100644 --- a/src/AdoLens/McpTools.cs +++ b/src/AdoLens/McpTools.cs @@ -366,7 +366,7 @@ public static Task Pr(Lens lens, public static class PrTools { [McpServerTool(Name = "ado_pr_update")] - [Description("Pull request actions: create (repo, source, target, title, text, workItems, reviewers, draft), update (title, text, workItems: links them), comment (text, file, line), reply (thread, text), resolve (thread, status), vote (approve|suggestions|wait|reject|reset), tag, publish, complete, abandon. @[name] notifies.")] + [Description("Pull request actions: create (repo, source, target, title, text, workItems, reviewers, draft), update (title, text, workItems, reviewers), comment (text, file, line), reply (thread, text), resolve (thread, status), vote (approve|suggestions|wait|reject|reset), tag, publish, autocomplete (mergeStrategy, deleteSourceBranch, text = merge message), cancel_autocomplete, complete, abandon. @[name] notifies.")] public static Task PrUpdate(Lens lens, string action, int? id = null, @@ -382,17 +382,18 @@ public static Task PrUpdate(Lens lens, string? title = null, [Description("Work item ids: 123;456.")] string? workItems = null, [Description("Names or e-mails separated by ';'.")] string? reviewers = null, + [Description("Reviewers who must approve.")] string? requiredReviewers = null, bool draft = false, [Description("'a; b' adds, '-c' removes.")] string? tags = null, bool deleteSourceBranch = false, [Description("noFastForward | squash | rebase | rebaseMerge.")] string? mergeStrategy = null, string? connection = null, CancellationToken ct = default) - => McpTools.Run("ado_pr_update", new { action, id, repo, thread, file, line, vote, status, source, target, title, workItems, reviewers, draft, tags, deleteSourceBranch, mergeStrategy, connection }, + => McpTools.Run("ado_pr_update", new { action, id, repo, thread, file, line, vote, status, source, target, title, workItems, reviewers, requiredReviewers, draft, tags, deleteSourceBranch, mergeStrategy, connection }, () => PullRequestQueries.ActAsync(lens, connection, new PullRequestQueries.PrAction { Action = action, Id = id, Repo = repo, Text = text, Thread = thread, File = file, Line = line, Vote = vote, Status = status, - Source = source, Target = target, Title = title, WorkItems = workItems, Reviewers = reviewers, Draft = draft, + Source = source, Target = target, Title = title, WorkItems = workItems, Reviewers = reviewers, RequiredReviewers = requiredReviewers, Draft = draft, DeleteSourceBranch = deleteSourceBranch, MergeStrategy = mergeStrategy, Tags = tags, }, ct)); } diff --git a/src/AdoLens/PullRequests.cs b/src/AdoLens/PullRequests.cs index d19808b..06ac610 100644 --- a/src/AdoLens/PullRequests.cs +++ b/src/AdoLens/PullRequests.cs @@ -8,7 +8,20 @@ namespace AdoLens; /// Pull requests: lists, one PR with reviewers, policies, work items and human comment threads, and actions. public static partial class PullRequestQueries { - public static readonly string[] Actions = ["create", "update", "comment", "reply", "resolve", "vote", "tag", "publish", "complete", "abandon"]; + public static readonly string[] Actions = ["create", "update", "comment", "reply", "resolve", "vote", "tag", "publish", "autocomplete", "cancel_autocomplete", "complete", "abandon"]; + + /// The people of "a; b" as reviewers, required ones marked. + static async Task> ReviewersAsync(Session session, string? optional, string? required, CancellationToken ct) + { + var list = new List<(Person, bool)>(); + foreach (var (names, isRequired) in new[] { (required, true), (optional, false) }) + foreach (var r in (names ?? "").Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var who = await session.People.ResolveAsync(r, ct); + if (!list.Any(x => x.Item1.Id == who.Id)) list.Add((who, isRequired)); + } + return list; + } static readonly Dictionary Votes = new(StringComparer.OrdinalIgnoreCase) { @@ -219,6 +232,7 @@ public sealed record PrAction public int? Thread { get; init; } public string? File { get; init; } public int? Line { get; init; } + public string? RequiredReviewers { get; init; } public string? Vote { get; init; } public string? Status { get; init; } public string? Source { get; init; } @@ -269,9 +283,7 @@ public static async Task ActAsync(Lens lens, string? connection, PrActio if (a.Repo is null || a.Source is null || a.Target is null || string.IsNullOrWhiteSpace(a.Title)) throw new ArgumentException("create needs repo, source, target and title"); var repo = await session.Code.RepoAsync(a.Repo, session.Config.Project, ct); - var reviewers = new List(); - foreach (var r in (a.Reviewers ?? "").Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - reviewers.Add(new { id = (await session.People.ResolveAsync(r, ct)).Id }); + var reviewers = (await ReviewersAsync(session, a.Reviewers, a.RequiredReviewers, ct)).Select(r => (object)new { id = r.Who.Id, isRequired = r.Required }).ToList(); var body = new Dictionary { ["sourceRefName"] = "refs/heads/" + a.Source.Replace("refs/heads/", ""), @@ -302,8 +314,14 @@ public static async Task ActAsync(Lens lens, string? connection, PrActio if (!string.IsNullOrWhiteSpace(a.Title)) changes["title"] = a.Title.Trim(); if (a.Text is not null) changes["description"] = await MentionsAsync(session.People, a.Text, ct); var link = WorkQueries.ParseIds(a.WorkItems); - if (changes.Count == 0 && link.Count == 0) throw new ArgumentException("update needs title, text or workItems"); + var people = await ReviewersAsync(session, a.Reviewers, a.RequiredReviewers, ct); + if (changes.Count == 0 && link.Count == 0 && people.Count == 0) + throw new ArgumentException("update needs title, text, workItems, reviewers or requiredReviewers"); if (changes.Count > 0) await client.SendAsync(HttpMethod.Patch, baseUrl, changes, "application/json", ct); + // Adding a reviewer who is already there keeps their vote (only "required" changes). + var votes = Json.Arr(current, "reviewers").ToDictionary(x => Json.Str(x, "id") ?? "", x => x.TryGetProperty("vote", out var v) ? v.GetInt32() : 0); + foreach (var (who, required) in people) + await client.SendAsync(HttpMethod.Put, $"{baseUrl}/reviewers/{who.Id}", new { vote = votes.GetValueOrDefault(who.Id), isRequired = required }, "application/json", ct); // A work item is linked to a PR by an artifact link on the work item (what the web does). var projectId = Json.Str(Json.Obj(Json.Obj(current, "repository"), "project"), "id"); var already = (await client.GetListAsync($"{baseUrl}/workitems", ct)).Select(w => Json.Str(w, "id")).ToHashSet(); @@ -320,6 +338,7 @@ await session.Work.PatchAsync(wi, [new var parts = new List(); if (changes.ContainsKey("title")) parts.Add("title"); if (changes.ContainsKey("description")) parts.Add("description"); + if (people.Count > 0) parts.Add("reviewers " + string.Join(", ", people.Select(p => p.Who.DisplayName + (p.Required ? " (required)" : "")))); if (linked.Count > 0) parts.Add("linked " + string.Join(", ", linked.Select(i => "#" + i))); if (link.Count > linked.Count) parts.Add("already linked " + string.Join(", ", link.Except(linked).Select(i => "#" + i))); var words = Html.Words(a.Text); @@ -369,6 +388,24 @@ await session.Work.PatchAsync(wi, [new var now = await SetTagsAsync(client, baseUrl, a.Tags, ct); return $"PR {id} tags: {(now.Count == 0 ? "none" : string.Join("; ", now))}"; } + case "autocomplete": + { + // Completes by itself once the policies pass, with these options; text is the merge commit message. + var (me, _) = await new Boards(session).MeAsync(ct); + var options = new Dictionary + { + ["deleteSourceBranch"] = a.DeleteSourceBranch, + ["transitionWorkItems"] = false, + }; + if (a.MergeStrategy is not null) options["mergeStrategy"] = a.MergeStrategy; + if (!string.IsNullOrWhiteSpace(a.Text)) options["mergeCommitMessage"] = a.Text.Trim(); + await client.SendAsync(HttpMethod.Patch, baseUrl, new { autoCompleteSetBy = new { id = me }, completionOptions = options }, "application/json", ct); + return $"PR {id} completes automatically when its policies pass" + + $"{(a.MergeStrategy is null ? "" : $", {a.MergeStrategy}")}{(a.DeleteSourceBranch ? ", source branch deleted" : "")}"; + } + case "cancel_autocomplete": + await client.SendAsync(HttpMethod.Patch, baseUrl, new { autoCompleteSetBy = new { id = "00000000-0000-0000-0000-000000000000" } }, "application/json", ct); + return $"PR {id}: auto-complete cancelled"; case "publish": await client.SendAsync(HttpMethod.Patch, baseUrl, new { isDraft = false }, "application/json", ct); return $"PR {id} is published (no longer a draft)"; diff --git a/src/AdoLens/Queries.cs b/src/AdoLens/Queries.cs index 9a053ff..8fe2eef 100644 --- a/src/AdoLens/Queries.cs +++ b/src/AdoLens/Queries.cs @@ -124,7 +124,7 @@ static async Task RulesAsync(Session session, Snapshot snap, TypeInfo t, { var rules = await session.Work.RequiredRulesAsync(snap, t, ct); if (rules.Count == 0) return ""; - return "required by rules: " + string.Join("; ", rules.Select(r => $"{(snap.Fields.TryGetValue(r.Field, out var f) ? f.Name : r.Field)} ({r.When})")) + "\n"; + return "also REQUIRED, by rules of the process (the list above does not mark them; ado_create and ado_update check them): " + string.Join("; ", rules.Select(r => $"{(snap.Fields.TryGetValue(r.Field, out var f) ? f.Name : r.Field)} ({r.When})")) + "\n"; } public static async Task FieldsAsync(Lens lens, string? connection, string? project, string? type, string? field, diff --git a/src/AdoLens/ToolArguments.cs b/src/AdoLens/ToolArguments.cs index ee11ecf..2400968 100644 --- a/src/AdoLens/ToolArguments.cs +++ b/src/AdoLens/ToolArguments.cs @@ -81,12 +81,32 @@ sealed class LenientBool : JsonConverter public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) => writer.WriteBooleanValue(value); } + static IEnumerable Parameters(JsonElement schema) => + schema.TryGetProperty("properties", out var p) && p.ValueKind == JsonValueKind.Object ? p.EnumerateObject().Select(x => x.Name) : []; + + internal static List Unknown(JsonElement schema, IEnumerable given) + { + var known = Parameters(schema).ToHashSet(StringComparer.OrdinalIgnoreCase); + return known.Count == 0 ? [] : given.Where(g => !known.Contains(g)).ToList(); + } + /// /// A failure outside the tool's own code (an argument that cannot be read at all) comes back as text Claude can act /// on instead of the SDK's bare "An error occurred". /// public static McpRequestFilter Errors => next => async (context, ct) => { + // A parameter the tool does not have would be dropped without a word (a session started before an update + // still has the old descriptions): say so with the parameters there are. + if (context.Params is { Name: { } name, Arguments: { Count: > 0 } args } + && context.Server.ServerOptions.ToolCollection?.TryGetPrimitive(name, out var called) == true + && Unknown(called.ProtocolTool.InputSchema, args.Keys) is { Count: > 0 } unknown) + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = $"{name}: no parameter {string.Join(", ", unknown)}; its parameters: {string.Join(", ", Parameters(called.ProtocolTool.InputSchema))}. " + + "If ADO Lens was updated during this conversation, a new conversation (or restarting Claude) shows the current ones." }], + }; try { return await next(context, ct); } catch (Exception ex) when (ex is ArgumentException or JsonException or FormatException or InvalidOperationException or NotSupportedException) { diff --git a/src/AdoLens/Trace.cs b/src/AdoLens/Trace.cs index 100661e..b9db80e 100644 --- a/src/AdoLens/Trace.cs +++ b/src/AdoLens/Trace.cs @@ -125,10 +125,12 @@ string Category(JsonElement x) => var changes = new List<(CodeChange Change, int From)>(); var failed = new List(); var toRead = links.Where(l => l.Kind != "build/build" && l.Kind != "git/ref").ToList(); - try + // Reading the changes gets part of the time, so builds and deployments are still read when it runs out. + using var readDeadline = CancellationTokenSource.CreateLinkedTokenSource(dt); + readDeadline.CancelAfter(Budget * 0.5); + async Task ReadAsync(IEnumerable batch) { - await Parallel.ForEachAsync(toRead, - new ParallelOptions { MaxDegreeOfParallelism = 6, CancellationToken = dt }, async (l, token) => + await Parallel.ForEachAsync(batch, new ParallelOptions { MaxDegreeOfParallelism = 6, CancellationToken = readDeadline.Token }, async (l, token) => { try { @@ -137,16 +139,28 @@ await Parallel.ForEachAsync(toRead, { "versioncontrol/changeset" => await code.ChangesetAsync(int.Parse(parts[^1], CultureInfo.InvariantCulture), token), "git/commit" => await code.CommitAsync(parts[1], parts[2], token), - _ => await code.PullRequestAsync(parts[1], int.Parse(parts[2], CultureInfo.InvariantCulture), withFiles: false, token), + // The files of a PR from its last iteration: cheap, where the files of its merge commit are a + // diff against the target branch (seconds each in a large repository). + _ => await code.PullRequestAsync(parts[1], int.Parse(parts[2], CultureInfo.InvariantCulture), withFiles: !summary, token), }; lock (changes) changes.Add((c, l.From)); } catch (AdoApiException ex) { lock (failed) failed.Add($"{l.Kind.Split('/')[^1]} {l.Value.Split('/')[^1]}: {Formatting.Collapse(ex.Message, 100)}"); } }); } - catch (Exception e) when (OutOfTime(e)) + var skipped = 0; + try + { + // Pull requests first: the merge commits Azure DevOps links next to them are then known and not read again. + await ReadAsync(toRead.Where(l => l.Kind == "git/pullrequestid")); + var merges = changes.Select(x => x.Change.MergeCommit).Where(m => m is not null).ToHashSet(StringComparer.OrdinalIgnoreCase); + var rest = toRead.Where(l => l.Kind != "git/pullrequestid").ToList(); + skipped = rest.RemoveAll(l => l.Kind == "git/commit" && merges.Contains(l.Value.Split('/')[^1])); + await ReadAsync(rest); + } + catch (Exception e) when (OutOfTime(e) || e is OperationCanceledException && readDeadline.IsCancellationRequested && !ct.IsCancellationRequested) { - missing.Add($"{toRead.Count - changes.Count - failed.Count} of {toRead.Count} linked changes were not read in time"); + missing.Add($"{toRead.Count - skipped - changes.Count - failed.Count} of {toRead.Count - skipped} linked changes were not read in time"); } Mark($"{changes.Count} changes"); changes = Combine(changes.OrderBy(c => c.Change.Date).ToList()); @@ -206,8 +220,13 @@ bool IsPromotion(CodeChange x) => signals.Add($"acceptance criteria changed after the first code change ({string.Join(", ", late.Select(x => $"{Date(x.When)} {x.Who}"))}) - check the code against the current version"); if (changes.Count == 0) { - var said = string.Join("\n", new[] { WorkItems.DescriptionRef, WorkItems.ReproStepsRef, "Microsoft.VSTS.CMMI.ProposedFix", "Microsoft.VSTS.Common.Resolution", - "Microsoft.VSTS.TCM.SystemInfo" }.Select(f => Html.ToMarkdown(WorkItems.Field(wi, f)))) + // Every text field (Proposed Fix, Resolution and custom ones), the item's tasks and the comments. + static IEnumerable Texts(JsonElement x) => Json.Obj(x, "fields") is { ValueKind: JsonValueKind.Object } f + ? f.EnumerateObject().Where(p => p.Value.ValueKind == JsonValueKind.String && !p.Name.StartsWith("System.", StringComparison.Ordinal) + || p.Name is "System.Description" or "System.Title" or "System.History") + .Select(p => Html.ToMarkdown(p.Value.GetString())) + : []; + var said = string.Join("\n", Texts(wi).Concat(children.SelectMany(Texts))) + "\n" + string.Join("\n", (await TryCommentsAsync(work, project, id, dt)).Select(c => Html.ToMarkdown(Json.Str(c, "text")))); var hints = ConfigWords().Matches(said).Select(m => m.Value.ToLowerInvariant()).Distinct().Take(4).ToList(); signals.Add(hints.Count > 0 @@ -279,11 +298,23 @@ string Label(string? branch, string def, string stage) => async Task<(List In, List NotYet, bool Known)> GitStatus(string repo, string target, bool isBranch, DateTime? deployedAt) { var git = changes.Select(x => x.Change).Where(x => x.Kind != "changeset" && string.Equals(x.RepoId, repo, StringComparison.OrdinalIgnoreCase)).ToList(); - // A deployment that finished before a change was merged cannot contain it: no need to ask. + // Only the newest change of each source -> target pair is asked about: the older ones of the pair are in + // the target branch before it, so they are in or out together with it. + var newest = git.Where(g => g.LandedCommit is not null).GroupBy(g => (g.Branch, g.TargetBranch, g.Kind == "commit" ? g.Id : null)) + .Select(grp => grp.OrderByDescending(g => g.Date).First()).ToHashSet(); var answers = await Task.WhenAll(git.Select(async g => g.LandedCommit is not { } commit ? (bool?)null + // A completed PR into the branch itself: it is there, nothing to ask. + : isBranch && g.Kind == "pr" && string.Equals(g.TargetBranch, target, StringComparison.OrdinalIgnoreCase) ? true + // A deployment that finished before a change was merged cannot contain it: no need to ask. : deployedAt is { } at && g.Date is { } merged && at < merged ? false + : !newest.Contains(g) ? (bool?)null : await Contains(repo, commit, target, isBranch))); + // The older changes of a pair follow the answer for its newest one. + for (var i = 0; i < git.Count; i++) + if (answers[i] is null && git[i].LandedCommit is not null + && git.Select((g, k) => (g, k)).FirstOrDefault(x => newest.Contains(x.g) && x.g.Branch == git[i].Branch && x.g.TargetBranch == git[i].TargetBranch && x.g.Kind == git[i].Kind) is { g: not null } n) + answers[i] = answers[n.k]; var inside = git.Where((g, i) => answers[i] == true).ToList(); var known = answers.Any(a => a is not null); var notYet = git.Where(g => !inside.Contains(g) && !IsPromotion(g) @@ -417,7 +448,7 @@ static string Names(IEnumerable list) => } /// Words of a change made in configuration rather than code (D365 and general, English and Polish). - [GeneratedRegex(@"\b(?:electronic reporting|ER configuration|konfiguracj\w*|configuration|parametr\w*|parameters?|setup|ustawie\w*|data management|DMF|workflow|number sequence|sekwencj\w* numer\w*|batch job)\b", RegexOptions.IgnoreCase)] + [GeneratedRegex(@"\b(?:electronic reporting|ER configuration|ER|format\w* ER|mapowani\w*|model mapping|konfiguracj\w*|configuration|parametr\w*|parameters?|setup|ustawie\w*|data management|DMF|workflow|number sequence|sekwencj\w* numer\w*|batch job)\b", RegexOptions.IgnoreCase)] private static partial Regex ConfigWords(); static async Task> TryCommentsAsync(WorkItems work, string project, int id, CancellationToken ct) diff --git a/src/AdoLens/WorkItemFormat.cs b/src/AdoLens/WorkItemFormat.cs index f8f77f3..719da07 100644 --- a/src/AdoLens/WorkItemFormat.cs +++ b/src/AdoLens/WorkItemFormat.cs @@ -187,15 +187,18 @@ static void Add(Dictionary> groups, string key, string valu return parts.Length == 3 ? (parts[0], parts[1], parts[2]) : null; } - /// Looks up the release stages the items are linked to (at most 10 releases), for . + /// Looks up the release stages the items are linked to (at most 40 releases), for . public static async Task ReleaseStagesAsync(AdoClient client, IEnumerable items, CancellationToken ct) { var wanted = items.SelectMany(w => Json.Arr(w, "relations")).Where(r => Json.Str(r, "rel") == "ArtifactLink") .Select(r => ReleaseStage(Json.Str(r, "url") ?? "")).Where(x => x is not null).Select(x => x!.Value) .Where(x => !ReleaseStages.ContainsKey($"{x.Release}:{x.Stage}")) - .GroupBy(x => x.Release).Take(10).ToList(); + .Where(x => !ReleaseStages.ContainsKey($"{x.Release}:*")) + .GroupBy(x => x.Release).Take(40).ToList(); + using var gate = new SemaphoreSlim(6); await Task.WhenAll(wanted.Select(async g => { + await gate.WaitAsync(ct); try { var release = await client.GetAsync(client.ServiceUrl("vsrm") + $"{g.First().Project}/_apis/release/releases/{g.Key}", ct); @@ -209,7 +212,12 @@ await Task.WhenAll(wanted.Select(async g => $"{Json.Str(release, "name")} / {Json.Str(env, "name")}: {Json.Str(env, "status")}{date}"; } } - catch (AdoApiException) { } + // Old releases are deleted by retention; other failures (permission) are named instead of hidden. + catch (AdoApiException ex) + { + ReleaseStages[$"{g.Key}:*"] = ex.Status == System.Net.HttpStatusCode.NotFound ? "release no longer kept" : $"release not readable ({(int?)ex.Status})"; + } + finally { gate.Release(); } })); } @@ -224,7 +232,8 @@ _ when url.Contains("/VersionControl/Changeset/", StringComparison.OrdinalIgnore _ when url.Contains("/Git/PullRequestId/", StringComparison.OrdinalIgnoreCase) => $"PR {last}", _ when url.Contains("/Git/Commit/", StringComparison.OrdinalIgnoreCase) => $"commit {last}", _ when ReleaseStage(url) is { } rs => ReleaseStages.TryGetValue($"{rs.Release}:{rs.Stage}", out var known) - ? $"release {known}" : $"release {rs.Release}, stage {rs.Stage}", + ? $"release {known}" + : $"release {rs.Release}, stage {rs.Stage}" + (ReleaseStages.TryGetValue($"{rs.Release}:*", out var why) ? $" ({why})" : ""), _ when url.Contains("/Git/Ref/", StringComparison.OrdinalIgnoreCase) && plain.IndexOf("/GB", StringComparison.Ordinal) is var gb and >= 0 => $"branch {plain[(gb + 3)..]}", _ when url.Contains("/Build/Build/", StringComparison.OrdinalIgnoreCase) => $"build {last}", _ when url.Contains("/Wiki/", StringComparison.OrdinalIgnoreCase) => "wiki page", diff --git a/src/AdoLens/WorkItems.cs b/src/AdoLens/WorkItems.cs index 8cd9205..defa15d 100644 --- a/src/AdoLens/WorkItems.cs +++ b/src/AdoLens/WorkItems.cs @@ -237,8 +237,9 @@ public async Task> BuildOpsAsync(Snapshot s, TypeInfo type, WriteRe { // Fields Azure DevOps fills itself (ids from the paths, the creator, dates) are "required" in some processes // but cannot be given: never ask for them. + var defaults = _defaulted.GetValueOrDefault(s.Project.Id + "|" + type.Ref) ?? []; var missing = s.FieldsOf(type.Name) - .Where(f => f.Required && string.IsNullOrEmpty(f.Default) && !set.ContainsKey(f.Ref) && !FilledByAzureDevOps(f.Ref) + .Where(f => f.Required && string.IsNullOrEmpty(f.Default) && !defaults.Contains(f.Ref) && !set.ContainsKey(f.Ref) && !FilledByAzureDevOps(f.Ref) && !(s.Fields.TryGetValue(f.Ref, out var def) && def.ReadOnly)) .Select(f => $"{f.Name} [{f.Ref}]" + (f.Allowed.Count > 0 ? $" ({string.Join(" | ", f.Allowed.Take(10))}{(f.Allowed.Count > 10 ? " | …" : "")})" : "")) .ToList(); @@ -268,6 +269,8 @@ public sealed record RequiredRule(string Field, string When, List<(string Type, public bool Applies(IReadOnlyDictionary set, string? state, bool created) => Conditions.All(c => c.Type switch { "whenWorkItemIsCreated" => created, + // The value before this save: nothing on create. + "whenWas" => created ? string.IsNullOrEmpty(c.Value) : false, "whenStateChangedTo" => state is not null && string.Equals(state, c.Value, StringComparison.OrdinalIgnoreCase), "when" => c.Field is not null && set.TryGetValue(c.Field, out var v) && string.Equals(v?.ToString(), c.Value, StringComparison.OrdinalIgnoreCase), "whenNot" => c.Field is not null && set.TryGetValue(c.Field, out var v) && !string.Equals(v?.ToString(), c.Value, StringComparison.OrdinalIgnoreCase), @@ -277,6 +280,9 @@ public sealed record RequiredRule(string Field, string When, List<(string Type, readonly System.Collections.Concurrent.ConcurrentDictionary> _rules = new(); + /// Fields a rule of the process gives a default (or copies a value into), per project and type. + readonly System.Collections.Concurrent.ConcurrentDictionary> _defaulted = new(); + /// /// "Make required" rules of the type in an inherited process; empty for other processes or without permission. /// @@ -289,22 +295,35 @@ public async Task> RequiredRulesAsync(Snapshot s, TypeInfo ty { var props = await client.GetAsync($"_apis/projects/{s.Project.Id}/properties?api-version=7.1-preview.1", ct); var processId = Json.Arr(props, "value").Where(v => Json.Str(v, "name") == "System.ProcessTemplateType").Select(v => Json.Str(v, "value")).FirstOrDefault(); - if (processId is not null) - foreach (var rule in await client.GetListAsync($"_apis/work/processes/{processId}/workItemTypes/{Uri.EscapeDataString(type.Ref)}/rules", ct)) + var rules = processId is null ? [] + : (await client.GetListAsync($"_apis/work/processes/{processId}/workItemTypes/{Uri.EscapeDataString(type.Ref)}/rules", ct)) + .Where(rule => !Json.Bool(rule, "isDisabled")).ToList(); + // The list holds the system rules too ("Area ID required", "Severity required") next to the rules that give + // those fields their default ("Business", "3 - Medium"): a field with a default or filled by Azure DevOps + // is never asked for. + var defaulted = rules.SelectMany(rule => Json.Arr(rule, "actions")) + .Where(a => Json.Str(a, "actionType") is { } t && (t.StartsWith("setDefault", StringComparison.OrdinalIgnoreCase) || t.StartsWith("copy", StringComparison.OrdinalIgnoreCase))) + .Select(a => Json.Str(a, "targetField")).OfType().ToHashSet(StringComparer.OrdinalIgnoreCase); + defaulted.UnionWith(s.FieldsOf(type.Name).Where(f => !string.IsNullOrEmpty(f.Default)).Select(f => f.Ref)); + _defaulted[key] = defaulted; + foreach (var rule in rules) + { + var conditions = Json.Arr(rule, "conditions").Select(c => (Json.Str(c, "conditionType") ?? "", Json.Str(c, "field"), Json.Str(c, "value"))).ToList(); + var when = conditions.Count == 0 ? "always" : string.Join(" and ", conditions.Select(c => c.Item1 switch { - if (Json.Bool(rule, "isDisabled")) continue; - var conditions = Json.Arr(rule, "conditions").Select(c => (Json.Str(c, "conditionType") ?? "", Json.Str(c, "field"), Json.Str(c, "value"))).ToList(); - var when = conditions.Count == 0 ? "always" : string.Join(" and ", conditions.Select(c => c.Item1 switch - { - "whenStateChangedTo" => $"on moving to {c.Item3}", - "whenWorkItemIsCreated" => "on create", - "when" => $"when {c.Item2} = {c.Item3}", - "whenNot" => $"when {c.Item2} is not {c.Item3}", - _ => $"{c.Item1} {c.Item2} {c.Item3}".Trim(), - })); - foreach (var a in Json.Arr(rule, "actions").Where(a => Json.Str(a, "actionType") == "makeRequired")) - if (Json.Str(a, "targetField") is { } field) list.Add(new RequiredRule(field, when, conditions)); - } + "whenStateChangedTo" => $"on moving to {c.Item3}", + "whenWorkItemIsCreated" => "on create", + "when" => $"when {c.Item2} = {c.Item3}", + "whenNot" => $"when {c.Item2} is not {c.Item3}", + // "whenWas State = (empty)": before the item had any state, i.e. when it is created. + "whenWas" when string.IsNullOrEmpty(c.Item3) => c.Item2 == "System.State" ? "on create" : $"while {c.Item2} was empty", + "whenWas" => $"when {c.Item2} was {c.Item3}", + _ => $"{c.Item1} {c.Item2} {c.Item3}".Trim(), + })); + foreach (var a in Json.Arr(rule, "actions").Where(a => Json.Str(a, "actionType") == "makeRequired")) + if (Json.Str(a, "targetField") is { } field && !FilledByAzureDevOps(field) && !defaulted.Contains(field)) + list.Add(new RequiredRule(field, when, conditions)); + } } catch (AdoApiException) { } _rules[key] = list; diff --git a/tests/AdoLens.Tests/ImprovementTests.cs b/tests/AdoLens.Tests/ImprovementTests.cs index 104297b..6b621c6 100644 --- a/tests/AdoLens.Tests/ImprovementTests.cs +++ b/tests/AdoLens.Tests/ImprovementTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Xml.Linq; namespace AdoLens.Tests; @@ -11,6 +12,21 @@ public class ImprovementTests public void Display_names_are_compared_without_a_company_suffix(string name, string plain) => Assert.Equal(plain, Identities.Plain(name)); + [Fact] + public void Numbered_lists_keep_their_start() + { + var md = Html.ToMarkdown("
        1. one
        2. two

        between

        1. three
        ").Replace("\r", ""); + Assert.Contains("1. one\n2. two", md); + Assert.Contains("3. three", md); + } + + [Fact] + public void Unknown_tool_parameters_are_named() + { + var schema = JsonDocument.Parse("""{"type":"object","properties":{"action":{},"id":{},"reviewers":{}}}""").RootElement; + Assert.Equal(["requiredReviewers"], ToolArguments.Unknown(schema, ["action", "ID", "requiredReviewers"])); + } + [Fact] public void A_table_is_shown_as_a_skeleton_of_fields_indexes_and_relations() { @@ -62,6 +78,11 @@ public void Process_rules_apply_only_when_their_conditions_hold() Assert.True(whenBug.Applies(new Dictionary { ["Custom.Kind"] = "bug" }, "New", created: true)); Assert.False(whenBug.Applies(new Dictionary(), "New", created: true)); + // "whenWas State = (empty)": before the item had a state, i.e. on create. + var onCreate = new WorkItems.RequiredRule("Custom.Area", "on create", [("whenWas", "System.State", "")]); + Assert.True(onCreate.Applies(new Dictionary(), "New", created: true)); + Assert.False(onCreate.Applies(new Dictionary(), "Active", created: false)); + // A condition that cannot be known before saving does not make the field required. var unknown = new WorkItems.RequiredRule("Custom.Area", "whenCurrentUserIsMemberOfGroup", [("whenCurrentUserIsMemberOfGroup", null, "g1")]); Assert.False(unknown.Applies(new Dictionary(), "New", created: true)); diff --git a/tests/AdoLens.Tests/PrPipelineTests.cs b/tests/AdoLens.Tests/PrPipelineTests.cs index 426971c..573ff68 100644 --- a/tests/AdoLens.Tests/PrPipelineTests.cs +++ b/tests/AdoLens.Tests/PrPipelineTests.cs @@ -134,6 +134,18 @@ public async Task Pull_request_actions_send_what_azure_devops_expects() Assert.Contains("vstfs:///Git/PullRequestId/p1%2Fr1%2F482", items.Patches.Single(x => x.Id == 2).Body); Assert.DoesNotContain(items.Patches, x => x.Id == 1 && x.Body.Contains("PullRequestId")); + // A required reviewer added later; Anna is already a reviewer, so her vote is kept. + Assert.Equal("PR 482 updated: reviewers Anna Nowak (required)", + await PullRequestQueries.ActAsync(lens, null, new() { Action = "update", Id = 482, RequiredReviewers = "Anna" })); + Assert.Contains("\"isRequired\":true", writes.Last(w => w.Method == "PUT").Body); + + Assert.StartsWith("PR 482 completes automatically when its policies pass, squash", + await PullRequestQueries.ActAsync(lens, null, new() { Action = "autocomplete", Id = 482, MergeStrategy = "squash", Text = "Login form (#1)" })); + var auto = writes.Last(w => w.Method == "PATCH").Body; + Assert.Contains("\"autoCompleteSetBy\":{\"id\":\"me-1\"}", auto); + Assert.Contains("\"mergeCommitMessage\":\"Login form (#1)\"", auto); + Assert.Equal("PR 482: auto-complete cancelled", await PullRequestQueries.ActAsync(lens, null, new() { Action = "cancel_autocomplete", Id = 482 })); + await Assert.ThrowsAsync(() => PullRequestQueries.ActAsync(lens, null, new() { Action = "merge", Id = 482 })); }