diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cb3aa2..275adf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 0.9.0 — 2026-10-18 + +Improvements from testing on a real project. + +- `ado_pr_update action=update`: change the title or description of a pull request and link work items to it after it + was created (branch policies often require a linked work item), instead of abandoning it and opening a new one. +- People are found by name, e-mail or a display name with a company suffix ("Anna Nowak (Contoso)"); when the identity + search finds nobody, the people picker of the web is asked too. +- Fields required by rules of an inherited process ("required when moving to Resolved") are checked before writing + and listed in `ado_fields type=`, instead of a 400 from Azure DevOps. +- `ado_sprint detail=full`: who has what - open items, tasks and hours left per person. +- `ado_file` on a table, table extension, view or map: a skeleton of properties, fields (type, label, mandatory), + indexes, relations and field groups instead of only the methods. +- `ado_work_item`: links to release stages name the release, the stage, its status and date; commit links give the + full commit id. +- `ado_trace`: with no code linked, an item that speaks of configuration (ER, parameters, setup…) is reported as a + probable change in the system's setup that can be checked only there. + ## 0.8.2 — 2026-10-18 Fewer false alarms in sprints and checks. diff --git a/README.md b/README.md index 70f96f5..f8771a1 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), `comment` (general or on a file and line), `reply`, `resolve`, `vote`, `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, 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_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"` | @@ -232,7 +232,7 @@ What each MCP tool answers, when Claude should reach for it, and the CLI command | `ado_review` | checks of a change, or of every change of a work item and its tasks, before testing: work item linked, labels added or used exist in every required language (`config --label-languages`), X++ messages from labels, no TODO left | before moving to test, PR review | `adolens review 523`, `adolens review --item 123` | | `ado_my_day` | since the previous working day (or `since`): where you were mentioned (all projects), your items and followed items changed by others, pull requests waiting for your review | the start of the day | `adolens myday --since 3d` | | `ado_search` | one search over work items (full text, with the field that matched), wiki pages and code (Git and TFVC, X++ objects named), grouped; a part that is not available (e.g. Code Search not installed) is reported and the rest still answers | "how does X work", "where is Y" | `adolens search "credit limit" --scope work;wiki` | -| `ado_file` | a file by TFVC path, Git path + repo, or just its name (found through Code Search); X++ objects as their list of methods, `part` shows one method; `history=true`: who changed it, when, through which pull request and for which work items | "what does this object do", "why is it like this" | `adolens file CreditCheck.xml --part validate`, `adolens file CreditCheck.xml --history` | +| `ado_file` | a file by TFVC path, Git path + repo, or just its name (found through Code Search); X++ objects as their list of methods (tables, extensions, views also as fields, indexes and relations), `part` shows one method; without `version` it names the branch read; `history=true`: who changed it, when, through which pull request and for which work items | "what does this object do", "why is it like this" | `adolens file CreditCheck.xml --part validate`, `adolens file CreditCheck.xml --history` | | `ado_health` | backlog hygiene of a team's open items (newest first): no acceptance criteria, description, estimate or parent, no change for 14+ days while in progress, left in a finished sprint, look-alike titles (series like "Sprint 202 / 203" excluded); items done in the last 30 days without any linked code | refinement, keeping the backlog clean | `adolens health --team "Demo Team"` | | `ado_release_notes` | markdown release notes: requirements and bugs finished in a sprint or between dates, requirements grouped under their feature | sprint review, a release page on the wiki | `adolens release-notes previous`, `adolens release-notes --since 14d` | | `ado_undo` | reverts the last change ADO Lens made: field values back (fields someone changed since are left and named), comment deleted, link removed or added back, a created item set to its removed state; always a preview first; `list=true` shows recent changes | "undo that", a mistaken mass change | `adolens undo --list`, `adolens undo` | diff --git a/README.pl.md b/README.pl.md index d16313f..52e20cc 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), `comment` (ogólny albo na pliku i linii), `reply`, `resolve`, `vote`, `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, 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_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"` | @@ -233,7 +233,7 @@ Na co odpowiada każde narzędzie MCP, kiedy Claude powinien po nie sięgnąć i | `ado_review` | sprawdzenie zmiany albo wszystkich zmian work itemu i jego tasków przed testami: powiązany work item, etykiety we wszystkich wymaganych językach (`config --label-languages`), komunikaty X++ z etykiet, brak TODO | przed przekazaniem do testów, przegląd PR | `adolens review 523`, `adolens review --item 123` | | `ado_my_day` | od poprzedniego dnia roboczego (albo `since`): gdzie cię oznaczono (wszystkie projekty), twoje i obserwowane elementy zmienione przez innych, pull requesty czekające na twój przegląd | początek dnia | `adolens myday --since 3d` | | `ado_search` | jedno wyszukiwanie po work itemach (pełny tekst, z polem, w którym znaleziono), stronach wiki i kodzie (Git i TFVC, z nazwami obiektów X++), pogrupowane; niedostępna część (np. brak Code Search) jest zgłaszana, reszta odpowiada | „jak działa X”, „gdzie jest Y” | `adolens search "limit kredytowy" --scope work;wiki` | -| `ado_file` | plik po ścieżce TFVC, ścieżce Git + repo albo samej nazwie (szukanej przez Code Search); obiekty X++ jako lista metod, `part` pokazuje jedną metodę; `history=true`: kto zmieniał, kiedy, w którym pull requeście i dla jakich work itemów | „co robi ten obiekt”, „dlaczego tak działa” | `adolens file CreditCheck.xml --part validate`, `adolens file CreditCheck.xml --history` | +| `ado_file` | plik po ścieżce TFVC, ścieżce Git + repo albo samej nazwie (szukanej przez Code Search); obiekty X++ jako lista metod (tabele, rozszerzenia, widoki także jako pola, indeksy i relacje), `part` pokazuje jedną metodę; bez `version` podaje, z której gałęzi czyta; `history=true`: kto zmieniał, kiedy, w którym pull requeście i dla jakich work itemów | „co robi ten obiekt”, „dlaczego tak działa” | `adolens file CreditCheck.xml --part validate`, `adolens file CreditCheck.xml --history` | | `ado_health` | higiena otwartych elementów zespołu (od najnowszych): brak kryteriów akceptacji, opisu, szacunku albo rodzica, w toku bez zmian od 14+ dni, zostawione w zakończonym sprincie, podobne tytuły (serie typu „Sprint 202 / 203” pominięte); elementy zakończone w ostatnich 30 dniach bez powiązanego kodu | refinement, porządek w backlogu | `adolens health --team "Demo Team"` | | `ado_release_notes` | notatki wydania w markdownie: wymagania i błędy zakończone w sprincie albo między datami, wymagania pogrupowane pod feature'ami | przegląd sprintu, strona wydania na wiki | `adolens release-notes previous`, `adolens release-notes --since 14d` | | `ado_undo` | cofa ostatnią zmianę zrobioną przez ADO Lens: przywraca wartości pól (pola zmienione w międzyczasie przez innych zostają i są wymienione), usuwa komentarz, usuwa lub przywraca link, utworzony element przestawia w stan usunięty; zawsze najpierw podgląd; `list=true` pokazuje ostatnie zmiany | „cofnij to”, pomyłka przy zmianie wsadowej | `adolens undo --list`, `adolens undo` | diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index ebf276b..5516611 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.2 + 0.9.0 diff --git a/src/AdoLens/Boards.cs b/src/AdoLens/Boards.cs index 4602e58..73385c9 100644 --- a/src/AdoLens/Boards.cs +++ b/src/AdoLens/Boards.cs @@ -356,6 +356,21 @@ public static async Task SprintAsync(Lens lens, string? connection, stri } if (detail == "summary") return sb.ToString().TrimEnd(); + if (detail == "full") + { + // Per person: their open items and tasks and the hours left on them. + var people = open.GroupBy(w => WorkItems.Field(w, "System.AssignedTo") ?? "(unassigned)") + .Select(g => (Who: g.Key, Items: g.Count(w => Level(s, w) != Levels.Task), Tasks: g.Count(w => Level(s, w) == Levels.Task), + Left: g.Sum(w => Number(w, RemainingRef) ?? 0))) + .OrderByDescending(p => p.Items + p.Tasks).ToList(); + if (people.Count > 0) + { + sb.AppendLine("by person (open):"); + foreach (var p in people) + sb.AppendLine($" {p.Who}: {p.Items} item(s), {p.Tasks} task(s){(p.Left > 0 ? $", {Num(p.Left)} h left" : "")}"); + } + } + foreach (var g in new[] { "in progress", "not started", "done" }) { if (!groups.TryGetValue(g, out var list)) continue; diff --git a/src/AdoLens/Diff.cs b/src/AdoLens/Diff.cs index 42b5d91..4aeb2c5 100644 --- a/src/AdoLens/Diff.cs +++ b/src/AdoLens/Diff.cs @@ -166,6 +166,55 @@ public static Dictionary Methods(XDocument? doc) } /// The XML without method bodies: what is left are fields, controls and properties. + /// Collections shown in a skeleton, with the properties worth a word for their items. + static readonly Dictionary SkeletonParts = new(StringComparer.OrdinalIgnoreCase) + { + ["Fields"] = ["ExtendedDataType", "EnumType", "Label", "Mandatory", "StringSize"], + ["FieldModifications"] = ["Label", "Mandatory", "ExtendedDataType"], + ["FieldGroups"] = [], + ["FieldGroupExtensions"] = [], + ["Indexes"] = ["AllowDuplicates", "AlternateKey"], + ["Relations"] = ["RelatedTable", "Cardinality", "RelationshipType"], + ["RelationExtensions"] = [], + ["DataSources"] = ["Table"], + ["Mappings"] = [], + }; + + static readonly string[] SkeletonRoot = ["Label", "TableGroup", "TableType", "Extends", "ConfigurationKey", "PrimaryIndex", "ClusteredIndex", "TitleField1", "TitleField2"]; + + /// + /// The metadata of an AOT object in a few lines (table, table extension, view, map): properties, then each field with + /// its type and label, indexes with their fields, relations with their table - instead of the raw XML. + /// + public static string Skeleton(XDocument? doc) + { + if (doc?.Root is not { } root) return ""; + var sb = new StringBuilder(); + string? Value(XElement e, string name) => e.Elements().FirstOrDefault(x => x.Name.LocalName == name)?.Value.Trim() is { Length: > 0 } v ? v : null; + var props = SkeletonRoot.Select(p => (p, v: Value(root, p))).Where(x => x.v is not null).Select(x => $"{x.p} {x.v}").ToList(); + if (props.Count > 0) sb.AppendLine(string.Join("; ", props)); + foreach (var part in root.Elements().Where(e => SkeletonParts.ContainsKey(e.Name.LocalName))) + { + var items = part.Elements().ToList(); + if (items.Count == 0) continue; + var keys = SkeletonParts[part.Name.LocalName]; + sb.AppendLine($"{part.Name.LocalName} ({items.Count}):"); + foreach (var item in items.Take(80)) + { + var name = Value(item, "Name") ?? item.Name.LocalName; + var extra = keys.Select(k => (k, v: Value(item, k))).Where(x => x.v is not null) + .Select(x => x.k is "ExtendedDataType" or "EnumType" or "RelatedTable" or "Table" or "Label" ? x.v! : $"{x.k} {x.v}").ToList(); + // Index and group members, relation constraints: their field names. + var members = item.Descendants().Where(d => d.Name.LocalName is "DataField" or "Field" && d.Parent != item) + .Select(d => d.Value.Trim()).Where(v => v.Length > 0 && !v.Contains('\n')).Distinct().Take(8).ToList(); + if (members.Count > 0) extra.Add("(" + string.Join(", ", members) + ")"); + sb.AppendLine($"- {name}{(extra.Count > 0 ? ": " + string.Join(", ", extra) : "")}"); + } + if (items.Count > 80) sb.AppendLine($"- … {items.Count - 80} more (detail=full)"); + } + return sb.ToString().TrimEnd(); + } + public static string Metadata(XDocument? doc) { if (doc?.Root is null) return ""; diff --git a/src/AdoLens/Identities.cs b/src/AdoLens/Identities.cs index e44f2cc..f351d50 100644 --- a/src/AdoLens/Identities.cs +++ b/src/AdoLens/Identities.cs @@ -31,17 +31,40 @@ public async Task> SearchAsync(string text, CancellationToken ct = .Where(p => p.Id.Length > 0) .ToList(); // Active people first; an exact name or e-mail match wins over partial ones. + // The identity search misses some (guests, names with a company suffix): the people picker of the web finds them. + if (people.Count == 0) people = await PickerAsync(q, ct); people = people.OrderByDescending(p => p.Active).ToList(); _cache[q] = people; return people; } + async Task> PickerAsync(string q, CancellationToken ct) + { + try + { + var found = await client.PostAsync("_apis/IdentityPicker/Identities?api-version=7.1-preview.1", new + { + query = q, + identityTypes = new[] { "user" }, + operationScopes = new[] { "ims", "source" }, + options = new { MinResults = 5, MaxResults = 20 }, + properties = new[] { "DisplayName", "Mail", "SignInAddress", "Active" }, + }, ct); + return Json.Arr(found, "results").SelectMany(r => Json.Arr(r, "identities")) + .Select(i => new Person(Json.Str(i, "localId") ?? Json.Str(i, "originId") ?? "", Json.Str(i, "displayName") ?? q, + Json.Str(i, "mail"), Json.Str(i, "signInAddress"), !i.TryGetProperty("active", out var a) || a.ValueKind != System.Text.Json.JsonValueKind.False)) + .Where(p => p.Id.Length > 0).GroupBy(p => p.Id).Select(g => g.First()).ToList(); + } + catch (AdoApiException) { return []; } + } + /// Nobody or several people match. public async Task ResolveAsync(string text, CancellationToken ct = default) { var q = text.Trim().TrimStart('@').Trim('[', ']'); var people = await SearchAsync(q, ct); var exact = people.Where(p => p.DisplayName.Equals(q, StringComparison.OrdinalIgnoreCase) + || Plain(p.DisplayName).Equals(Plain(q), StringComparison.OrdinalIgnoreCase) || string.Equals(p.Mail, q, StringComparison.OrdinalIgnoreCase) || string.Equals(p.Account, q, StringComparison.OrdinalIgnoreCase)).ToList(); var candidates = exact.Count > 0 ? exact : people.Where(p => p.Active).ToList(); @@ -55,6 +78,9 @@ public async Task ResolveAsync(string text, CancellationToken ct = defau }; } + /// A display name without a trailing "(Company)": "Anna Nowak (Contoso)" → "Anna Nowak". + internal static string Plain(string name) => System.Text.RegularExpressions.Regex.Replace(name.Trim(), @"\s*\([^)]*\)$", ""); + static string? Prop(System.Text.Json.JsonElement identity, string name) { var props = Json.Obj(identity, "properties"); diff --git a/src/AdoLens/Knowledge.cs b/src/AdoLens/Knowledge.cs index b163fd8..baa2338 100644 --- a/src/AdoLens/Knowledge.cs +++ b/src/AdoLens/Knowledge.cs @@ -255,6 +255,7 @@ internal static string Show(string where, string path, string text, string? part foreach (var (n, src) in hit) sb.AppendLine($"--- {n}").AppendLine(src.Trim()); return sb.ToString().TrimEnd(); } + if (!full && AotDiff.Skeleton(doc) is { Length: > 0 } skeleton) sb.AppendLine(skeleton); sb.AppendLine($"{AotDiff.ObjectName(path)}: {methods.Count} methods (part= shows one)"); foreach (var (n, src) in methods) sb.AppendLine($"- {n} ({Diff.Split(src.Trim()).Length} lines)"); if (full) sb.AppendLine().AppendLine(AotDiff.Metadata(doc)); diff --git a/src/AdoLens/McpTools.cs b/src/AdoLens/McpTools.cs index 4203c1a..21cec5b 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), 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: links them), comment (text, file, line), reply (thread, text), resolve (thread, status), vote (approve|suggestions|wait|reject|reset), tag, publish, complete, abandon. @[name] notifies.")] public static Task PrUpdate(Lens lens, string action, int? id = null, diff --git a/src/AdoLens/PullRequests.cs b/src/AdoLens/PullRequests.cs index b3e9fb5..d19808b 100644 --- a/src/AdoLens/PullRequests.cs +++ b/src/AdoLens/PullRequests.cs @@ -8,7 +8,7 @@ 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", "comment", "reply", "resolve", "vote", "tag", "publish", "complete", "abandon"]; + public static readonly string[] Actions = ["create", "update", "comment", "reply", "resolve", "vote", "tag", "publish", "complete", "abandon"]; static readonly Dictionary Votes = new(StringComparer.OrdinalIgnoreCase) { @@ -296,6 +296,35 @@ public static async Task ActAsync(Lens lens, string? connection, PrActio var baseUrl = $"_apis/git/repositories/{repoId}/pullrequests/{id}"; switch (action) { + case "update": + { + var changes = new Dictionary(); + 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"); + if (changes.Count > 0) await client.SendAsync(HttpMethod.Patch, baseUrl, changes, "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(); + var linked = new List(); + foreach (var wi in link.Where(w => !already.Contains(w.ToString(CultureInfo.InvariantCulture)))) + { + await session.Work.PatchAsync(wi, [new + { + op = "add", path = "/relations/-", + value = new { rel = "ArtifactLink", url = $"vstfs:///Git/PullRequestId/{projectId}%2F{repoId}%2F{id}", attributes = new { name = "Pull Request" } }, + }], ct); + linked.Add(wi); + } + var parts = new List(); + if (changes.ContainsKey("title")) parts.Add("title"); + if (changes.ContainsKey("description")) parts.Add("description"); + 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); + return $"PR {id} updated: {string.Join("; ", parts)}" + (words > 150 ? $"\nnote: description has {words} words; usually ≤ 150 - what and why, changes, how to check" : ""); + } case "comment": { if (string.IsNullOrWhiteSpace(a.Text)) throw new ArgumentException("comment needs text"); diff --git a/src/AdoLens/Queries.cs b/src/AdoLens/Queries.cs index b214b4c..9a053ff 100644 --- a/src/AdoLens/Queries.cs +++ b/src/AdoLens/Queries.cs @@ -119,6 +119,14 @@ static Dictionary ParseEnvironments(string environments) return map; } + /// "required by rules:" lines of a type (inherited processes), or nothing. + static async Task RulesAsync(Session session, Snapshot snap, TypeInfo t, CancellationToken ct) + { + 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"; + } + public static async Task FieldsAsync(Lens lens, string? connection, string? project, string? type, string? field, string? changes, bool all, bool refresh, CancellationToken ct = default) { @@ -147,7 +155,7 @@ public static async Task FieldsAsync(Lens lens, string? connection, stri answer = Formatting.Field(snap, field, refreshing); else if (type is not null) answer = snap.FindType(type) is { } t - ? (Formatting.Type(snap, t, all, refreshing) + "\n" + await Templates.SectionAsync(session, snap, t, ct)).TrimEnd() + ? (Formatting.Type(snap, t, all, refreshing) + "\n" + await RulesAsync(session, snap, t, ct) + await Templates.SectionAsync(session, snap, t, ct)).TrimEnd() : $"no work item type '{type}' in {snap.Project.Name}; types: " + string.Join(", ", snap.Types.Where(x => !x.Disabled && x.Level != Levels.Hidden).Select(x => x.Name).Order(StringComparer.OrdinalIgnoreCase)); else diff --git a/src/AdoLens/Trace.cs b/src/AdoLens/Trace.cs index cb23fa1..a7c614e 100644 --- a/src/AdoLens/Trace.cs +++ b/src/AdoLens/Trace.cs @@ -165,7 +165,15 @@ bool IsPromotion(CodeChange x) => if (firstCode is { } fc && criteriaChanges.Where(x => x.When > fc).ToList() is { Count: > 0 } late) 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) - signals.Add(Category(wi) == "Completed" ? "closed without any linked code" : "no code linked yet"); + { + 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)))) + + "\n" + string.Join("\n", (await TryCommentsAsync(work, project, id, ct)).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 + ? $"no code linked, and the item speaks of configuration ({string.Join(", ", hints)}): probably a change in the system's setup - it can be checked only there, not in code" + : Category(wi) == "Completed" ? "closed without any linked code" : "no code linked yet"); + } foreach (var pr in changes.Select(x => x.Change).Where(x => x.Kind == "pr" && x.State is "active" or "abandoned")) signals.Add($"{pr.Label} is {pr.State}"); var openTasks = children.Where(c => Category(c) is not ("Completed" or "Removed")).ToList(); @@ -344,6 +352,16 @@ static string Names(IEnumerable list) => .Select(x => (x.Key, x.C.Guess)).ToList(); } + /// 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)] + private static partial Regex ConfigWords(); + + static async Task> TryCommentsAsync(WorkItems work, string project, int id, CancellationToken ct) + { + try { return await work.CommentsAsync(project, id, 30, ct); } + catch (AdoApiException) { return []; } + } + [GeneratedRegex(@"^Merged PR (?\d+)")] private static partial Regex MergedPr(); diff --git a/src/AdoLens/WorkItemFormat.cs b/src/AdoLens/WorkItemFormat.cs index 7b61c45..f8f77f3 100644 --- a/src/AdoLens/WorkItemFormat.cs +++ b/src/AdoLens/WorkItemFormat.cs @@ -175,6 +175,44 @@ static void Add(Dictionary> groups, string key, string valu } /// vstfs links in words: "changeset 523 (Fix x)", "PR 482", "commit 1a2b3c4", "build 213864". + /// "releaseId:stageId" → "Release-12 / UAT: succeeded 2026-10-10", filled by . + public static readonly System.Collections.Concurrent.ConcurrentDictionary ReleaseStages = new(); + + static (string Project, string Release, string Stage)? ReleaseStage(string url) + { + var plain = Uri.UnescapeDataString(url); + var i = plain.IndexOf("/ReleaseManagement/ReleaseEnvironment/", StringComparison.OrdinalIgnoreCase); + if (i < 0) return null; + var parts = plain[(i + "/ReleaseManagement/ReleaseEnvironment/".Length)..].Split(':'); + 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 . + 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(); + await Task.WhenAll(wanted.Select(async g => + { + try + { + var release = await client.GetAsync(client.ServiceUrl("vsrm") + $"{g.First().Project}/_apis/release/releases/{g.Key}", ct); + foreach (var env in Json.Arr(release, "environments")) + { + var steps = Json.Arr(env, "deploySteps").ToList(); + var when = (steps.Count > 0 ? Json.Str(steps[^1], "lastModifiedOn") : null) ?? Json.Str(env, "modifiedOn"); + var date = DateTime.TryParse(when, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind, out var d) + ? " " + d.ToLocalTime().ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture) : ""; + ReleaseStages[$"{g.Key}:{(env.TryGetProperty("id", out var id) ? id.ToString() : "")}"] = + $"{Json.Str(release, "name")} / {Json.Str(env, "name")}: {Json.Str(env, "status")}{date}"; + } + } + catch (AdoApiException) { } + })); + } + public static string Artifact(string url, string? name, string? comment) { // Git artifacts escape their parts: vstfs:///Git/Commit/{project}%2F{repo}%2F{sha}. @@ -184,7 +222,9 @@ public static string Artifact(string url, string? name, string? comment) { _ when url.Contains("/VersionControl/Changeset/", StringComparison.OrdinalIgnoreCase) => $"changeset {last}", _ when url.Contains("/Git/PullRequestId/", StringComparison.OrdinalIgnoreCase) => $"PR {last}", - _ when url.Contains("/Git/Commit/", StringComparison.OrdinalIgnoreCase) => $"commit {(last.Length > 7 ? last[..7] : 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}", _ 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 e23bd65..8cd9205 100644 --- a/src/AdoLens/WorkItems.cs +++ b/src/AdoLens/WorkItems.cs @@ -219,6 +219,20 @@ public async Task> BuildOpsAsync(Snapshot s, TypeInfo type, WriteRe } if (r.Reason is not null) set["System.Reason"] = r.Reason.Trim(); + // Rules of an inherited process ("make Field required when ..."): not part of the field list of the type. + var newState = set.TryGetValue("System.State", out var st) ? st as string + : current is null ? type.States.FirstOrDefault(x => x.Category == "Proposed")?.Name ?? type.States.FirstOrDefault()?.Name : null; + var byRule = new List(); + foreach (var rule in await RequiredRulesAsync(s, type, ct)) + { + if (set.ContainsKey(rule.Field) || current is { } cur && Field(cur, rule.Field) is not null) continue; + if (current is not null && newState is null) continue; // only a state change makes a rule apply on update + if (!rule.Applies(set, newState, created: current is null)) continue; + byRule.Add($"{(s.Fields.TryGetValue(rule.Field, out var fd) ? fd.Name : rule.Field)} [{rule.Field}] ({rule.When})"); + } + if (byRule.Count > 0) + throw new InvalidOperationException($"{type.Name} needs by a rule of the process: {string.Join("; ", byRule)} - pass them in fields"); + if (current is null) { // Fields Azure DevOps fills itself (ids from the paths, the creator, dates) are "required" in some processes @@ -248,6 +262,55 @@ public async Task> BuildOpsAsync(Snapshot s, TypeInfo type, WriteRe return ops; } + public sealed record RequiredRule(string Field, string When, List<(string Type, string? Field, string? Value)> Conditions) + { + /// Whether the rule applies to this save; conditions that cannot be known before saving do not count. + public bool Applies(IReadOnlyDictionary set, string? state, bool created) => Conditions.All(c => c.Type switch + { + "whenWorkItemIsCreated" => created, + "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), + _ => false, + }); + } + + readonly System.Collections.Concurrent.ConcurrentDictionary> _rules = new(); + + /// + /// "Make required" rules of the type in an inherited process; empty for other processes or without permission. + /// + public async Task> RequiredRulesAsync(Snapshot s, TypeInfo type, CancellationToken ct = default) + { + var key = s.Project.Id + "|" + type.Ref; + if (_rules.TryGetValue(key, out var known)) return known; + var list = new List(); + try + { + 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)) + { + 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)); + } + } + catch (AdoApiException) { } + _rules[key] = list; + return list; + } + /// Fields set by Azure DevOps on every save, never by the caller. internal static bool FilledByAzureDevOps(string reference) => reference is "System.State" or "System.Reason" or "System.AreaPath" or "System.IterationPath" or "System.AreaId" or "System.IterationId" diff --git a/src/AdoLens/WorkQueries.cs b/src/AdoLens/WorkQueries.cs index 019ccfa..981d07b 100644 --- a/src/AdoLens/WorkQueries.cs +++ b/src/AdoLens/WorkQueries.cs @@ -53,6 +53,7 @@ public static async Task ItemAsync(Lens lens, string? connection, string if (items.Count == 0) return $"no work item {string.Join(", ", wanted.Select(i => "#" + i))} (missing, deleted or not visible with this PAT)"; var headers = d == "summary" ? [] : await HeadersAsync(work, items.SelectMany(WorkItemFormat.LinkedIds), ct); + if (d != "summary") await WorkItemFormat.ReleaseStagesAsync(session.Client, items, ct); var sb = new StringBuilder(); foreach (var wi in items) { diff --git a/tests/AdoLens.Tests/ImprovementTests.cs b/tests/AdoLens.Tests/ImprovementTests.cs new file mode 100644 index 0000000..104297b --- /dev/null +++ b/tests/AdoLens.Tests/ImprovementTests.cs @@ -0,0 +1,69 @@ +using System.Xml.Linq; + +namespace AdoLens.Tests; + +public class ImprovementTests +{ + [Theory] + [InlineData("Anna Nowak (Contoso)", "Anna Nowak")] + [InlineData("Anna Nowak", "Anna Nowak")] + [InlineData(" Ben Kowalski (Demo Ltd) ", "Ben Kowalski")] + public void Display_names_are_compared_without_a_company_suffix(string name, string plain) => + Assert.Equal(plain, Identities.Plain(name)); + + [Fact] + public void A_table_is_shown_as_a_skeleton_of_fields_indexes_and_relations() + { + var doc = XDocument.Parse(""" + + DemoTableMain + + + CodeDemoCodeYes + CustAccountCustAccount + + CodeIdxCode + + CustCustTable + CustAccountAccountNum + + + + """); + var text = AotDiff.Skeleton(doc).Replace("\r", ""); + Assert.StartsWith("Label @Demo:Table; TableGroup Main\n", text); + Assert.Contains("Fields (2):\n- Code: DemoCode, Mandatory Yes\n- CustAccount: CustAccount", text); + Assert.Contains("Indexes (1):\n- CodeIdx: (Code)", text); + Assert.Contains("Relations (1):\n- Cust: CustTable, (CustAccount)", text); + } + + [Fact] + public void Release_stage_links_name_the_release_and_stage_when_known() + { + const string url = "vstfs:///ReleaseManagement/ReleaseEnvironment/p1:9012:9034"; + Assert.Equal("release 9012, stage 9034", WorkItemFormat.Artifact(url, "Integrated in release environment", null)); + WorkItemFormat.ReleaseStages["9012:9034"] = "Release-12 / UAT: succeeded 2026-10-10"; + Assert.Equal("release Release-12 / UAT: succeeded 2026-10-10", WorkItemFormat.Artifact(url, "Integrated in release environment", null)); + Assert.Equal("commit 0123456789abcdef0123456789abcdef01234567", + WorkItemFormat.Artifact("vstfs:///Git/Commit/p1%2Fr1%2F0123456789abcdef0123456789abcdef01234567", null, null)); + } + + [Fact] + public void Process_rules_apply_only_when_their_conditions_hold() + { + var onResolve = new WorkItems.RequiredRule("Custom.Area", "on moving to Resolved", [("whenStateChangedTo", null, "Resolved")]); + Assert.True(onResolve.Applies(new Dictionary(), "Resolved", created: false)); + Assert.False(onResolve.Applies(new Dictionary(), "Active", created: false)); + + var always = new WorkItems.RequiredRule("Custom.Area", "always", []); + Assert.True(always.Applies(new Dictionary(), "New", created: true)); + + var whenBug = new WorkItems.RequiredRule("Custom.Area", "when Custom.Kind = Bug", [("when", "Custom.Kind", "Bug")]); + Assert.True(whenBug.Applies(new Dictionary { ["Custom.Kind"] = "bug" }, "New", created: true)); + Assert.False(whenBug.Applies(new Dictionary(), "New", created: true)); + + // 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 ba16429..426971c 100644 --- a/tests/AdoLens.Tests/PrPipelineTests.cs +++ b/tests/AdoLens.Tests/PrPipelineTests.cs @@ -127,6 +127,13 @@ public async Task Pull_request_actions_send_what_azure_devops_expects() Assert.Contains("\"workItemRefs\":[{\"id\":\"1\"}]", body); Assert.StartsWith("created PR 482", created); + // After creating: a better title and description, and the work items the branch policy wants. + var updated = await PullRequestQueries.ActAsync(lens, null, new() { Action = "update", Id = 482, Title = "Login form v2", Text = "Adds the form.", WorkItems = "1;2" }); + Assert.Equal("PR 482 updated: title; description; linked #2; already linked #1", updated); + Assert.Contains("\"title\":\"Login form v2\"", writes.Last(w => w.Method == "PATCH").Body); + 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")); + await Assert.ThrowsAsync(() => PullRequestQueries.ActAsync(lens, null, new() { Action = "merge", Id = 482 })); }