diff --git a/CHANGELOG.md b/CHANGELOG.md index 850f40b..9adbf30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.4.0 — 2026-10-15 + +How it works and why, and repeatable changes. + +- `ado_search`: one search over work items (full text, with the field that matched), wiki pages and code (Git and + TFVC), grouped; without the search service work items fall back to titles, a missing part does not stop the rest. +- `ado_file`: a file by TFVC path, Git path or only its name; X++ objects as their methods, `part` shows one method; + `history=true`: who changed it, when, through which pull request (also for squash and rebase flows) and for which work + items. +- `ado_undo` and a local log of every change ADO Lens makes (`%LOCALAPPDATA%\ado-lens\audit`): field values back, + fields changed by someone else since are kept and named, comments deleted, links removed or added back, created items + set to their removed state; always previewed first. +- `ado_health`: backlog hygiene of a team (no criteria, description, estimate or parent; stale; left in a finished + sprint; look-alike titles; done without code). +- `ado_release_notes`: requirements and bugs finished in a sprint or between dates as markdown, grouped by feature. +- Recipes (tool set `recipes`, `ado_recipe`, `adolens recipe`): saved steps with a WIQL query and changes, comments or + child items from a template, with parameters; every run previewed; `--yes` for Windows Task Scheduler. +- `ado_update` and recipes take iteration `next`. +- Skill `po-explain`: "how does X work" in business language with sources, code told apart from documentation. +- Wiki search shows page paths with real hyphens. + ## 0.3.0 — 2026-10-15 Easy to install and to keep current, for people who are not developers. diff --git a/README.md b/README.md index 981b739..2b43e19 100644 --- a/README.md +++ b/README.md @@ -155,12 +155,13 @@ never a permission. | Tool set | Tools | po | dev | full | |---|---|:-:|:-:|:-:| | `core` | `ado_status`, `ado_settings`, `ado_upgrade`, `ado_fields` | ✓ | ✓ | ✓ | -| `work` | `ado_work_item`, `ado_query`, `ado_create`, `ado_update`, `ado_comment`, `ado_link` | ✓ | ✓ | ✓ | +| `work` | `ado_work_item`, `ado_query`, `ado_create`, `ado_update`, `ado_comment`, `ado_link`, `ado_search`, `ado_undo` | ✓ | ✓ | ✓ | | `pr` | `ado_pr`, `ado_pr_update` | – | ✓ | ✓ | | `pipelines` | `ado_pipeline`, `ado_pipeline_run` | – | ✓ | ✓ | -| `boards` | `ado_sprint`, `ado_board`, `ado_backlog`, `ado_my_day` | ✓ | – | ✓ | +| `boards` | `ado_sprint`, `ado_board`, `ado_backlog`, `ado_my_day`, `ado_health`, `ado_release_notes` | ✓ | – | ✓ | | `wiki` | `ado_wiki`, `ado_wiki_write` | ✓ | ✓ | ✓ | -| `trace` | `ado_trace`, `ado_changes` | ✓ | ✓ | ✓ | +| `trace` | `ado_trace`, `ado_changes`, `ado_file` | ✓ | ✓ | ✓ | +| `recipes` | `ado_recipe` | ✓ | ✓ | ✓ | | `test`, `recipes` | planned | ✓ | (recipes) | ✓ | | `code` | planned | – | ✓ | ✓ | @@ -192,6 +193,12 @@ What each MCP tool answers, when Claude should reach for it, and the CLI command | `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"` | | `ado_wiki_write` | `create` a page, `append` / `prepend` to it, or `replace` it with the version from the last full read — a page changed by someone else in the meantime is never overwritten; optional change comment | release notes, keeping docs current | `adolens wiki-write "Team/Release notes" --file notes.md --mode append` | | `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_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` | +| `ado_recipe` | saved repeatable changes: steps with a WIQL query (or ids) and what to do with each item - fields, state, iteration `next`, tags, a comment, a child item from a template (`{title}`, `{id}`); `{parameters}`; every run previewed, then confirmed | "close the sprint", "set up a new customer" | `adolens recipe run close-sprint --param sprint="Demo\Sprint 6"`, `--yes` for Task Scheduler | ### Writing rules built in @@ -240,7 +247,7 @@ build.ps1 compile; -Deploy replaces the installed binaries, -T pack.ps1 release package (ZIP + SHA-256) release.ps1 release-notes.ps1 releases (see below); .github\ CI and Release workflows, issue forms install.ps1 uninstall.ps1 installation; get.ps1 is the one-line download and install -skills\ skills for Claude (po-verify, po-story, po-sprint) +skills\ skills for Claude (po-verify, po-story, po-sprint, po-explain) docs\PLAN.md road map build\ dist\ output (keep nothing of your own there) ``` @@ -287,6 +294,7 @@ regular expression per line, `!word` for allowed words. Without a list the relea | `SetupFlowTests.cs` | release versions and packages for updates, the team's wiki table (round trip, hand-edited pages), detecting the flow from releases and branches, the team map on the wiki with its version, local map over the team's | | `PrPipelineTests.cs` | PR lists and details (votes, checks, human threads only), PR actions (vote, comment on a line with a mention, create), failed build errors and log lines, running pipelines by name, saving environments, guessing the flow | | `TraceTests.cs` | line diff (Myers, unified hunks), X++ per method with form data sources, object names from AOT paths, `ado_trace` across TFVC and Git (deployed stages YES / NOT YET, environment labels, merged branches, deleted builds, criteria changed after coding), `ado_changes` lists and file diffs | +| `KnowledgeTests.cs` | search across work items, wiki and code (a missing part still answers), files found by name with X++ methods, file history with pull requests and work items, undo (values back, fields changed by others kept, comments deleted), backlog health, release notes, recipes (save, preview, run) | | `WikiTests.cs` | page tree, page with version, replace only with the current version (412 never overwrites), append, create over an existing page, search snippets | | `WorkItemTests.cs` | work item answers, required fields and allowed values before writing, transitions, preview and confirm of bulk updates, mentions, links, queries, HTML ⇄ markdown, PAT never sent to other hosts | diff --git a/README.pl.md b/README.pl.md index 84822e2..82a6001 100644 --- a/README.pl.md +++ b/README.pl.md @@ -157,12 +157,13 @@ Nigdy nie jest uprawnieniem. | Zestaw | Narzędzia | po | dev | full | |---|---|:-:|:-:|:-:| | `core` | `ado_status`, `ado_settings`, `ado_upgrade`, `ado_fields` | ✓ | ✓ | ✓ | -| `work` | `ado_work_item`, `ado_query`, `ado_create`, `ado_update`, `ado_comment`, `ado_link` | ✓ | ✓ | ✓ | +| `work` | `ado_work_item`, `ado_query`, `ado_create`, `ado_update`, `ado_comment`, `ado_link`, `ado_search`, `ado_undo` | ✓ | ✓ | ✓ | | `pr` | `ado_pr`, `ado_pr_update` | – | ✓ | ✓ | | `pipelines` | `ado_pipeline`, `ado_pipeline_run` | – | ✓ | ✓ | -| `boards` | `ado_sprint`, `ado_board`, `ado_backlog`, `ado_my_day` | ✓ | – | ✓ | +| `boards` | `ado_sprint`, `ado_board`, `ado_backlog`, `ado_my_day`, `ado_health`, `ado_release_notes` | ✓ | – | ✓ | | `wiki` | `ado_wiki`, `ado_wiki_write` | ✓ | ✓ | ✓ | -| `trace` | `ado_trace`, `ado_changes` | ✓ | ✓ | ✓ | +| `trace` | `ado_trace`, `ado_changes`, `ado_file` | ✓ | ✓ | ✓ | +| `recipes` | `ado_recipe` | ✓ | ✓ | ✓ | | `test`, `recipes` | w planie | ✓ | (recipes) | ✓ | | `code` | w planie | – | ✓ | ✓ | @@ -194,6 +195,12 @@ Na co odpowiada każde narzędzie MCP, kiedy Claude powinien po nie sięgnąć i | `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"` | | `ado_wiki_write` | `create` — nowa strona, `append` / `prepend` — dopisanie, `replace` — zastąpienie z wersją z ostatniego pełnego odczytu (strony zmienionej w międzyczasie przez kogoś innego nigdy nie nadpisze); opcjonalny komentarz zmiany | notatki wydania, aktualna dokumentacja | `adolens wiki-write "Team/Release notes" --file notes.md --mode append` | | `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_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` | +| `ado_recipe` | zapisane powtarzalne zmiany: kroki z zapytaniem WIQL (albo id) i tym, co zrobić z każdym elementem - pola, stan, iteracja `next`, tagi, komentarz, element podrzędny z szablonu (`{title}`, `{id}`); `{parametry}`; każde uruchomienie najpierw podgląd, potem potwierdzenie | „zamknij sprint”, „nowy klient” | `adolens recipe run close-sprint --param sprint="Demo\Sprint 6"`, `--yes` dla Harmonogramu zadań | ### Wbudowane zasady pisania @@ -242,7 +249,7 @@ build.ps1 kompilacja; -Deploy podmienia binaria w instalacji, pack.ps1 paczka wydania (ZIP + SHA-256) release.ps1 release-notes.ps1 wydania (zob. niżej); .github\ workflowy CI i Release, formularze zgłoszeń install.ps1 uninstall.ps1 instalacja; get.ps1 to pobranie i instalacja jedną linijką -skills\ skille dla Claude'a (po-verify, po-story, po-sprint) +skills\ skille dla Claude'a (po-verify, po-story, po-sprint, po-explain) docs\PLAN.md plan rozwoju build\ dist\ wyniki (nie trzymaj tu niczego własnego) ``` @@ -289,6 +296,7 @@ i publikuje go razem z `.sha256` i opisem z `CHANGELOG.md` (podgląd: `.\release | `SetupFlowTests.cs` | wersje i paczki wydań do aktualizacji, tabela zespołu na wiki (w obie strony, strony poprawiane ręcznie), wykrywanie przepływu z release'ów i gałęzi, mapa zespołu na wiki z wersją, mapa lokalna ponad zespołową | | `PrPipelineTests.cs` | listy i szczegóły PR-ów (głosy, kontrole, tylko wątki ludzi), akcje na PR (głos, komentarz na linii ze wzmianką, tworzenie), błędy nieudanego buildu i linie logu, uruchamianie pipeline'ów po nazwie, zapis środowisk, odgadywanie przepływu | | `TraceTests.cs` | diff linii (Myers, bloki unified), X++ per metoda ze źródłami danych formularza, nazwy obiektów ze ścieżek AOT, `ado_trace` w TFVC i Gicie (wdrożone etapy TAK / JESZCZE NIE, etykiety środowisk, scalone gałęzie, usunięte buildy, kryteria zmienione po rozpoczęciu prac), listy i diffy plików w `ado_changes` | +| `KnowledgeTests.cs` | wyszukiwanie po work itemach, wiki i kodzie (brak jednej części nie psuje reszty), pliki po nazwie z metodami X++, historia pliku z pull requestami i work itemami, cofanie (wartości wracają, pola zmienione przez innych zostają, komentarze usuwane), higiena backlogu, notatki wydania, przepisy (zapis, podgląd, uruchomienie) | | `WikiTests.cs` | drzewo stron, strona z wersją, zastąpienie tylko z aktualną wersją (412 nigdy nie nadpisuje), dopisywanie, tworzenie na istniejącej stronie, fragmenty wyszukiwania | | `WorkItemTests.cs` | odpowiedzi o work itemach, pola wymagane i dozwolone wartości przed zapisem, przejścia stanów, podgląd i potwierdzanie zmian wsadowych, wzmianki, linki, zapytania, HTML ⇄ markdown, PAT nigdy do innych hostów | diff --git a/skills/po-explain/SKILL.md b/skills/po-explain/SKILL.md new file mode 100644 index 0000000..d22143a --- /dev/null +++ b/skills/po-explain/SKILL.md @@ -0,0 +1,39 @@ +--- +name: po-explain +description: Explains how a feature works and why it works that way, in business language, from work items, the wiki and the code in Azure DevOps. Use when someone asks "how does X work", "what happens when…", "why does the system do…", "where is this documented", or needs a feature described for users, testers or a new colleague. Needs the ado-lens MCP server. +--- + +# Explaining a feature + +The reader is a Product Owner or a business user: answer in plain language, in the language the user writes in. +Technical names (objects, methods, files) only in brackets, or when asked for "details". + +## Steps + +1. `ado_search text=` — work items, wiki pages and code at once. Try a second wording + (a synonym, the English or the local term) when the first finds little. +2. Read the strongest sources, stop when the question is answered: + - wiki pages: `ado_wiki path=`, + - work items: `ado_work_item ids=` — the requirement and its acceptance criteria say what was intended, + - code: `ado_file path=` (X++ objects show their methods; `part=` one method) — the code says + what really happens. +3. "Why is it like this": `ado_file path= history=true` — who changed it, when and for which work items; read the + work item behind the change that matters. +4. With xpp-lens available (developer machine), its tools give callers and references; without it, stay with the above. + +## Answer + +Start with one or two sentences that answer the question. Then, as fits: +- **How it works** — numbered steps "what happens when…", with an example ("an order for 120 pcs → …"). +- A small Mermaid diagram when there is a flow with decisions. +- **Why** — the decision and its work item ("added in #4821 to stop orders over the credit limit"). +- **Sources** — links to the work items, wiki pages and files used. + +Always separate what the **code** does from what the **documentation** says; when they disagree, say so plainly — +that is worth a work item or a wiki fix. Say "not found" rather than guess. + +## Afterwards + +Offer, do not do unasked: +- a wiki page with the explanation (`ado_wiki_write`, ask where), +- a work item when code and documentation disagree. diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index 318960c..d7c0b5f 100644 --- a/src/AdoLens/AdoLens.csproj +++ b/src/AdoLens/AdoLens.csproj @@ -21,7 +21,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.3.0 + 0.4.0 diff --git a/src/AdoLens/Audit.cs b/src/AdoLens/Audit.cs new file mode 100644 index 0000000..fbec3f8 --- /dev/null +++ b/src/AdoLens/Audit.cs @@ -0,0 +1,225 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AdoLens; + +/// +/// One change ADO Lens made: a field update (values before and after), a created item, a comment or a link. Entries of +/// one tool call share a batch; ado_undo reverts the last batch. +/// +public sealed record AuditEntry +{ + public string Batch { get; init; } = ""; + public DateTime Utc { get; init; } + /// update | create | comment | link-add | link-remove | undone (Batch: the batch reverted). + public string Kind { get; init; } = ""; + public int Id { get; init; } + public string? Project { get; init; } + public string? Title { get; init; } + public int? Rev { get; init; } + public Dictionary? Before { get; init; } + public Dictionary? After { get; init; } + public int? CommentId { get; init; } + public string? Rel { get; init; } + public int? Target { get; init; } + public string? Tool { get; init; } +} + +/// The local log of changes (%LOCALAPPDATA%\ado-lens\audit\<connection>.jsonl) and undo. +public static class Audit +{ + static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + static readonly object Gate = new(); + + public static string PathFor(Lens lens, string connection) => Path.Combine(lens.DataDir, "audit", connection + ".jsonl"); + + public static string NewBatch() => Guid.NewGuid().ToString("N")[..12]; + + public static void Write(Lens lens, string connection, IEnumerable entries) + { + var lines = entries.Select(e => JsonSerializer.Serialize(e with { Utc = e.Utc == default ? DateTime.UtcNow : e.Utc }, Options)).ToList(); + if (lines.Count == 0) return; + var path = PathFor(lens, connection); + try + { + lock (Gate) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.AppendAllLines(path, lines); + } + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + public static List Read(Lens lens, string connection) + { + var path = PathFor(lens, connection); + if (!File.Exists(path)) return []; + var list = new List(); + lock (Gate) + foreach (var line in File.ReadLines(path)) + { + if (string.IsNullOrWhiteSpace(line)) continue; + try { if (JsonSerializer.Deserialize(line, Options) is { } e) list.Add(e); } + catch (JsonException) { } + } + return list; + } + + /// The value of a field as it can be written back: people by account, everything else as text. + public static string? Raw(JsonElement wi, string reference) + { + var fields = Json.Obj(wi, "fields"); + if (fields.ValueKind != JsonValueKind.Object || !fields.TryGetProperty(reference, out var v)) return null; + return v.ValueKind == JsonValueKind.Object ? Json.Str(v, "uniqueName") ?? Json.Str(v, "displayName") : Json.Scalar(v); + } + + /// The audit entries of a JSON Patch written to an item (fields with before/after; parent links). + public static List FromOps(string batch, string tool, JsonElement before, JsonElement after, IEnumerable ops) + { + var id = WorkItems.Id(after.ValueKind == JsonValueKind.Object ? after : before); + var project = WorkItems.Field(before, "System.TeamProject") ?? WorkItems.Field(after, "System.TeamProject"); + var title = WorkItems.Field(after, "System.Title") ?? WorkItems.Field(before, "System.Title"); + var b = new Dictionary(StringComparer.OrdinalIgnoreCase); + var a = new Dictionary(StringComparer.OrdinalIgnoreCase); + var list = new List(); + foreach (var op in ops.Select(o => JsonSerializer.SerializeToElement(o))) + { + var path = Json.Str(op, "path") ?? ""; + if (path.StartsWith("/fields/", StringComparison.Ordinal)) + { + var reference = path[8..]; + b[reference] = Raw(before, reference); + a[reference] = Raw(after, reference); + } + else if (path == "/relations/-" && op.TryGetProperty("value", out var rel)) + list.Add(new AuditEntry { Batch = batch, Tool = tool, Kind = "link-add", Id = id, Project = project, Title = title, + Rel = Json.Str(rel, "rel"), Target = TargetOf(Json.Str(rel, "url")) }); + } + if (b.Count > 0) + list.Insert(0, new AuditEntry { Batch = batch, Tool = tool, Kind = "update", Id = id, Project = project, Title = title, + Rev = after.TryGetProperty("rev", out var r) && r.TryGetInt32(out var n) ? n : null, Before = b, After = a }); + return list; + } + + public static int? TargetOf(string? url) => + url is not null && int.TryParse(url[(url.LastIndexOf('/') + 1)..], out var t) ? t : null; + + static string Token(string batch) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("undo:" + batch)))[..8].ToLowerInvariant(); + + static string Describe(AuditEntry e) => e.Kind switch + { + "update" => $"#{e.Id} {e.Title}: " + string.Join("; ", e.Before!.Select(kv => + $"{kv.Key.Split('.')[^1]} {Formatting.Collapse(e.After!.GetValueOrDefault(kv.Key) ?? "-", 30)} -> {Formatting.Collapse(kv.Value ?? "-", 30)}")), + "create" => $"#{e.Id} {e.Title}: created -> set to a removed state", + "comment" => $"#{e.Id} {e.Title}: comment {e.CommentId} deleted", + "link-add" => $"#{e.Id}: link {Short(e.Rel)} #{e.Target} removed", + "link-remove" => $"#{e.Id}: link {Short(e.Rel)} #{e.Target} added back", + _ => e.Kind, + }; + + static string Short(string? rel) => rel?.Split('.')[^1] ?? "?"; + + /// The last changes ADO Lens made (latest first), which ado_undo would revert. + public static string List(Lens lens, string? connection, int top) + { + var cfg = lens.For(connection).Config; + var batches = Open(Read(lens, cfg.Connection)); + if (batches.Count == 0) return "no changes by ADO Lens to undo on this connection"; + var sb = new StringBuilder($"changes by ADO Lens on {cfg.Connection}, latest first (undo reverts the first):\n"); + foreach (var batch in batches.Take(Math.Clamp(top, 1, 20))) + sb.AppendLine($"- {batch[0].Utc.ToLocalTime():yyyy-MM-dd HH:mm} {batch[0].Tool}: {string.Join(" | ", batch.Take(5).Select(Describe))}{(batch.Count > 5 ? $" | … {batch.Count - 5} more" : "")}"); + return sb.ToString().TrimEnd(); + } + + /// Batches not reverted yet, latest first. + static List> Open(List all) + { + var undone = all.Where(e => e.Kind == "undone").Select(e => e.Batch).ToHashSet(); + return all.Where(e => e.Kind != "undone" && !undone.Contains(e.Batch)) + .GroupBy(e => e.Batch).Select(g => g.ToList()).OrderByDescending(g => g[0].Utc).ToList(); + } + + /// + /// Reverts the last batch: always a preview first, written with its token. Fields someone changed after ADO Lens + /// are left as they are and reported. + /// + public static async Task UndoAsync(Lens lens, string? connection, string? confirm, CancellationToken ct = default) + { + var session = lens.For(connection); + var batches = Open(Read(lens, session.Config.Connection)); + if (batches.Count == 0) return "nothing to undo: no changes by ADO Lens on this connection"; + var batch = batches[0]; + var token = Token(batch[0].Batch); + if (!string.Equals(confirm?.Trim(), token, StringComparison.OrdinalIgnoreCase)) + return $"UNDO PREVIEW - nothing written yet. The last change ({batch[0].Tool}, {batch[0].Utc.ToLocalTime():yyyy-MM-dd HH:mm}) would be reverted:\n" + + string.Join('\n', batch.Select(e => "- " + Describe(e))) + + $"\nShow this to the user. To revert it, call again with confirm={token}."; + + var work = session.Work; + var ids = batch.Select(e => e.Id).Distinct().ToList(); + var current = (await work.GetAsync(ids, relations: true, ct: ct)).ToDictionary(WorkItems.Id); + var results = new List(); + foreach (var e in batch.AsEnumerable().Reverse()) + { + try + { + if (!current.TryGetValue(e.Id, out var wi)) { results.Add($"skipped #{e.Id}: not visible any more"); continue; } + switch (e.Kind) + { + case "update": + var rev = wi.TryGetProperty("rev", out var r) && r.TryGetInt32(out var n) ? n : 0; + var ops = new List(); + var kept = new List(); + foreach (var (reference, before) in e.Before!) + { + var now = Raw(wi, reference); + // Changed by someone else after ADO Lens: leave it. + if (rev != e.Rev && !Same(now, e.After!.GetValueOrDefault(reference))) { kept.Add(reference.Split('.')[^1]); continue; } + if (before is null) { if (now is not null) ops.Add(new { op = "remove", path = "/fields/" + reference }); } + else ops.Add(new { op = "add", path = "/fields/" + reference, value = before }); + } + if (ops.Count > 0) await work.PatchAsync(e.Id, ops, ct); + results.Add($"reverted #{e.Id} {e.Title}" + (kept.Count > 0 ? $" (left as changed since by others: {string.Join(", ", kept)})" : "")); + break; + case "comment" when e.CommentId is null: + results.Add($"#{e.Id}: the comment id is unknown - delete it in Azure DevOps"); + break; + case "comment": + await session.Client.SendAsync(HttpMethod.Delete, + $"{AdoClient.Scope(e.Project!)}_apis/wit/workItems/{e.Id}/comments/{e.CommentId}?api-version=7.1-preview.4", null, null, ct); + results.Add($"deleted the comment on #{e.Id}"); + break; + case "create": + var s = (await session.Meta.GetAsync(e.Project!, ct)).Snapshot; + var type = s.FindType(WorkItems.Field(wi, "System.WorkItemType") ?? ""); + var removed = type?.States.FirstOrDefault(x => x.Category == "Removed")?.Name; + if (removed is null) { results.Add($"#{e.Id} {e.Title}: its type has no removed state - delete it in Azure DevOps if needed"); break; } + await work.PatchAsync(e.Id, [new { op = "add", path = "/fields/System.State", value = removed }], ct); + results.Add($"#{e.Id} {e.Title}: set to {removed}"); + break; + case "link-add": + var rels = Json.Arr(wi, "relations").ToList(); + var i = rels.FindIndex(x => Json.Str(x, "rel") == e.Rel && TargetOf(Json.Str(x, "url")) == e.Target); + if (i >= 0) await work.PatchAsync(e.Id, [new { op = "remove", path = $"/relations/{i}" }], ct); + results.Add($"#{e.Id}: removed link {Short(e.Rel)} #{e.Target}"); + break; + case "link-remove": + await work.PatchAsync(e.Id, [new { op = "add", path = "/relations/-", value = new { rel = e.Rel, url = $"{work.WebBase}_apis/wit/workItems/{e.Target}" } }], ct); + results.Add($"#{e.Id}: added back link {Short(e.Rel)} #{e.Target}"); + break; + } + } + catch (AdoApiException ex) { results.Add($"FAILED #{e.Id}: {ex.Message}"); } + } + Write(lens, session.Config.Connection, [new AuditEntry { Batch = batch[0].Batch, Kind = "undone", Tool = "ado_undo" }]); + return "undone:\n" + string.Join('\n', results.Select(x => "- " + x)); + } + + static bool Same(string? a, string? b) => + string.Equals(Formatting.Collapse(a ?? "", 2000), Formatting.Collapse(b ?? "", 2000), StringComparison.OrdinalIgnoreCase); +} diff --git a/src/AdoLens/Boards.cs b/src/AdoLens/Boards.cs index 48788f4..c4df9f7 100644 --- a/src/AdoLens/Boards.cs +++ b/src/AdoLens/Boards.cs @@ -175,7 +175,7 @@ public async Task> PullRequestsToReviewAsync(string project, s } /// The answers behind the boards tools. -public static class BoardQueries +public static partial class BoardQueries { /// Changed less recently than this (in working days) while in progress: worth a look. const int StaleDays = 3; @@ -385,6 +385,7 @@ public static async Task BoardAsync(Lens lens, string? connection, strin if (!state.Equals(WorkItems.Field(wi, "System.State"), StringComparison.OrdinalIgnoreCase)) ops.Add(new { op = "add", path = "/fields/System.State", value = state }); var updated = await session.Work.PatchAsync(id, ops, ct); + Audit.Write(lens, session.Config.Connection, Audit.FromOps(Audit.NewBatch(), "ado_board", wi, updated, ops)); var note = ""; if (col.Limit > 0) { diff --git a/src/AdoLens/Cli.cs b/src/AdoLens/Cli.cs index 6f835b8..6f7e6ee 100644 --- a/src/AdoLens/Cli.cs +++ b/src/AdoLens/Cli.cs @@ -51,6 +51,14 @@ board [] --team --move --column --detail summary|norma wiki [] --search --depth 2 --detail full --wiki wiki-write --file (or standard input) --mode create|append|prepend|replace --version (for replace, from 'wiki ') --comment + search work items, wiki and code in one search --scope work;wiki;code --top 10 + file a file ($/… TFVC, Git path with --repo, or a file name) --version --part + --history (who changed it, when, for which work items) --detail full + health backlog hygiene of the team --team + release-notes [current|previous|] or --since 14d [--until yyyy-MM-dd] markdown for the wiki + undo revert the last change made by ADO Lens (preview, then --confirm ) --list + recipe list | show | save --file | delete + run [--param k=v]… [--confirm | --yes (no preview: Task Scheduler)] api raw JSON of a GET request (development) stats MCP calls of the last days: answer sizes, times, empty answers --days 7 --top 10 --live: show every call as Claude makes it (tool, tokens, ms), until Ctrl+C @@ -612,6 +620,67 @@ public async Task WikiWriteAsync() return 0; } + public async Task SearchAsync() + { + if (cl.Positional.Count == 0) { Console.Error.WriteLine("usage: adolens search [--scope work;wiki;code] [--top 10]"); return 2; } + Print(await Knowledge.SearchAsync(NewLens(), cl.Opt("connection"), cl.Opt("project"), string.Join(' ', cl.Positional), cl.Opt("scope"), Int(cl.Opt("top")) ?? 10)); + return 0; + } + + public async Task FileAsync() + { + if (cl.Positional.Count == 0) { Console.Error.WriteLine("usage: adolens file [--repo ] [--version ] [--part ] [--history]"); return 2; } + Print(await Knowledge.FileAsync(NewLens(), cl.Opt("connection"), cl.Opt("project"), string.Join(' ', cl.Positional), cl.Opt("repo"), cl.Opt("version"), + cl.Opt("part"), cl.Flag("history"), Int(cl.Opt("top")) ?? 15, cl.Opt("detail"))); + return 0; + } + + public async Task HealthAsync() + { + Print(await BoardQueries.HealthAsync(NewLens(), cl.Opt("connection"), cl.Opt("project"), cl.Opt("team"))); + return 0; + } + + public async Task ReleaseNotesAsync() + { + Print(await BoardQueries.ReleaseNotesAsync(NewLens(), cl.Opt("connection"), cl.Opt("project"), cl.Opt("team"), + cl.Positional.FirstOrDefault(), cl.Opt("since"), cl.Opt("until"))); + return 0; + } + + public async Task UndoAsync() + { + var lens = NewLens(); + Print(cl.Flag("list") ? Audit.List(lens, cl.Opt("connection"), 20) : await Audit.UndoAsync(lens, cl.Opt("connection"), cl.Opt("confirm"))); + return 0; + } + + /// recipe list | show <name> | save <name> --file r.json | run <name> --param k=v [--confirm t | --yes] | delete <name>. + public async Task RecipeAsync() + { + var lens = NewLens(); + var action = cl.Positional.FirstOrDefault()?.ToLowerInvariant() ?? "list"; + var name = cl.Positional.ElementAtOrDefault(1); + if (action != "list" && name is null) { Console.Error.WriteLine("usage: adolens recipe list | show | save --file | run [--param k=v]… [--confirm | --yes] | delete "); return 2; } + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + while (cl.Opt("param") is { } p) + { + var eq = p.IndexOf('='); + if (eq <= 0) throw new ArgumentException($"--param {p}: use name=value"); + values[p[..eq].Trim()] = p[(eq + 1)..].Trim(); + } + Print(action switch + { + "list" => Recipes.List(lens), + "show" => Recipes.Show(lens, name!), + "save" => Recipes.Save(lens, name!, cl.Opt("file") is { } f ? await File.ReadAllTextAsync(f) : await Console.In.ReadToEndAsync()), + "delete" => Recipes.Delete(lens, name!), + "run" => await Recipes.RunAsync(lens, cl.Opt("connection"), name!, values, cl.Opt("confirm"), cl.Flag("yes")), + _ => throw new ArgumentException("recipe action is list, show, save, run or delete"), + }); + return 0; + } + /// Raw GET for development: prints the JSON answer of an API path (read-only). public async Task ApiAsync() { diff --git a/src/AdoLens/CommandLine.cs b/src/AdoLens/CommandLine.cs index 5600c1f..fe5af9e 100644 --- a/src/AdoLens/CommandLine.cs +++ b/src/AdoLens/CommandLine.cs @@ -41,6 +41,12 @@ public sealed partial class CommandLine ["fields"] = ["connection=", "project=", "type=", "field=", "changes=", "all", "refresh"], ["stats"] = ["days=", "top=", "live"], ["api"] = ["connection="], + ["search"] = ["connection=", "project=", "scope=", "top="], + ["file"] = ["connection=", "project=", "repo=", "version=", "part=", "history", "top=", "detail="], + ["health"] = ["connection=", "project=", "team="], + ["release-notes"] = ["connection=", "project=", "team=", "since=", "until="], + ["undo"] = ["connection=", "confirm=", "list"], + ["recipe"] = ["connection=", "file=", "param=", "confirm=", "yes"], }; [GeneratedRegex(@"^--?(?[A-Za-z][A-Za-z0-9-]*)(=(?.*))?$", RegexOptions.Singleline)] diff --git a/src/AdoLens/Hygiene.cs b/src/AdoLens/Hygiene.cs new file mode 100644 index 0000000..f8abf0e --- /dev/null +++ b/src/AdoLens/Hygiene.cs @@ -0,0 +1,214 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace AdoLens; + +/// Backlog hygiene and release notes. +public static partial class BoardQueries +{ + const int StaleBacklogDays = 14; + const int MaxHealthItems = 1000; + const int ShownPerRule = 5; + const int DoneWithoutCodeDays = 30; + + static string Quote(string s) => "'" + s.Replace("'", "''") + "'"; + + static string InList(IEnumerable values) => "(" + string.Join(", ", values.Distinct(StringComparer.OrdinalIgnoreCase).Select(Quote)) + ")"; + + /// Requirement and bug types of the team's backlog (the ones the hygiene rules are about). + static List BacklogTypes(Snapshot s) => + s.Types.Where(t => !t.Disabled && t.Level is Levels.Requirement or Levels.Bug).ToList(); + + static async Task AreaAsync(Boards boards, string project, string team, CancellationToken ct) + { + var area = await boards.TeamAreaConditionAsync(project, team, ct); + return area.Length == 0 ? "" : " AND " + area; + } + + [GeneratedRegex(@"[^\p{L}\p{N}]+")] + private static partial Regex NonWord(); + + static HashSet Words(string? title) => + NonWord().Split((title ?? "").ToLowerInvariant()).Where(w => w.Length > 2).ToHashSet(); + + /// + /// What needs tidying on the team's open backlog: no acceptance criteria, no estimate, no parent, no change for a long + /// time, left in a past sprint, look-alike titles; and recently finished items without any linked code. + /// + public static async Task HealthAsync(Lens lens, string? connection, string? project, string? team, DateTime? today = null, CancellationToken ct = default) + { + var session = lens.For(connection); + var name = session.Project(project); + var boards = new Boards(session); + var t = await boards.TeamAsync(name, team, ct); + var day = (today ?? DateTime.Now).Date; + var s = (await session.Meta.GetAsync(name, ct)).Snapshot; + var types = BacklogTypes(s); + if (types.Count == 0) return $"no requirement or bug types found in {name}"; + // Resolved (e.g. "Done" waiting for closing) is finished work for this purpose. + var open = types.SelectMany(x => x.States).Where(x => x.Category is "Proposed" or "InProgress").Select(x => x.Name).ToList(); + var done = types.SelectMany(x => x.States).Where(x => x.Category == "Completed").Select(x => x.Name).ToList(); + var area = await AreaAsync(boards, name, t, ct); + var typeList = InList(types.Select(x => x.Name)); + + var (openIds, _) = await session.Work.WiqlAsync(name, + $"SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] IN {typeList}" + + (open.Count > 0 ? $" AND [System.State] IN {InList(open)}" : "") + area + " ORDER BY [System.ChangedDate] DESC", MaxHealthItems, ct); + var items = openIds.Count == 0 ? [] : await session.Work.GetAsync(openIds.Take(MaxHealthItems).ToList(), relations: true, ct: ct); + var iterations = await boards.IterationsAsync(name, t, ct); + var past = iterations.Where(i => i.Finish is { } f && f < day).Select(i => i.Path).ToHashSet(StringComparer.OrdinalIgnoreCase); + var estimate = Estimates.FirstOrDefault(e => s.Fields.ContainsKey(e)); + var hasPortfolio = s.Types.Any(x => !x.Disabled && x.Level == Levels.Portfolio); + + var findings = new Dictionary>(); + void Add(string rule, JsonElement w, string? extra = null) + { + if (!findings.TryGetValue(rule, out var l)) findings[rule] = l = []; + l.Add($"{WorkItemFormat.Header(w)}{Who(w)}{(extra is null ? "" : " - " + extra)}"); + } + foreach (var w in items) + { + var type = s.FindType(WorkItems.Field(w, "System.WorkItemType") ?? ""); + if (type is null) continue; + var requirement = type.Level == Levels.Requirement; + var fields = s.FieldsOf(type.Name); + if (requirement && fields.Any(f => f.Ref == WorkItems.AcceptanceRef) && Html.ToMarkdown(WorkItems.Field(w, WorkItems.AcceptanceRef)).Length == 0) + Add("no acceptance criteria", w); + if (Html.ToMarkdown(WorkItems.Field(w, WorkItems.DescriptionRef) ?? WorkItems.Field(w, WorkItems.ReproStepsRef)).Length == 0) + Add("no description", w); + if (requirement && estimate is not null && fields.Any(f => f.Ref == estimate) && Number(w, estimate) is null) + Add("no estimate", w); + if (requirement && hasPortfolio && !Json.Arr(w, "relations").Any(r => Json.Str(r, "rel") == "System.LinkTypes.Hierarchy-Reverse")) + Add("no parent (feature/epic)", w); + if (Changed(w) is { } ch && (day - ch.ToLocalTime().Date).Days >= StaleBacklogDays && Category(s, w) != "Proposed") + Add($"in progress, no change for {StaleBacklogDays}+ days", w, $"{(day - ch.ToLocalTime().Date).Days} days"); + if (WorkItems.Field(w, "System.IterationPath") is { } it && past.Contains(it)) + Add("left in a finished sprint", w, it[(it.LastIndexOf('\\') + 1)..]); + } + + // Look-alike titles: most words shared. + var titled = items.Select(w => (W: w, Words: Words(WorkItems.Field(w, "System.Title")))).Where(x => x.Words.Count >= 2).ToList(); + for (var i = 0; i < titled.Count; i++) + for (var j = i + 1; j < titled.Count; j++) + { + var a = titled[i].Words; + var b = titled[j].Words; + var common = a.Intersect(b).Count(); + // "Sprint 202" vs "Sprint 203": a series, not a duplicate. + var series = a.Count == b.Count && a.Except(b).Concat(b.Except(a)).All(w => w.All(char.IsDigit)); + if (!series && common >= 2 && common >= 0.8 * Math.Max(a.Count, b.Count)) + Add("possible duplicates", titled[i].W, "#" + WorkItems.Id(titled[j].W)); + } + + // Finished recently without code: neither the item nor its tasks have a commit, changeset or pull request. + var doneNoCode = new List(); + if (done.Count > 0) + { + var since = day.AddDays(-DoneWithoutCodeDays).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var (doneIds, _) = await session.Work.WiqlAsync(name, + $"SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] IN {typeList}" + + $" AND [System.State] IN {InList(done)} AND [Microsoft.VSTS.Common.StateChangeDate] >= '{since}'" + area + + " ORDER BY [Microsoft.VSTS.Common.StateChangeDate] DESC", 200, ct); + var doneItems = doneIds.Count == 0 ? [] : await session.Work.GetAsync(doneIds, relations: true, ct: ct); + var childIds = doneItems.SelectMany(w => Json.Arr(w, "relations")).Where(r => Json.Str(r, "rel") == "System.LinkTypes.Hierarchy-Forward") + .Select(r => Audit.TargetOf(Json.Str(r, "url")) ?? 0).Where(i => i > 0).Distinct().ToList(); + var children = childIds.Count == 0 ? [] : await session.Work.GetAsync(childIds, relations: true, ct: ct); + var withCode = children.Where(HasCode).Select(WorkItems.Id).ToHashSet(); + foreach (var w in doneItems) + { + var kids = Json.Arr(w, "relations").Where(r => Json.Str(r, "rel") == "System.LinkTypes.Hierarchy-Forward").Select(r => Audit.TargetOf(Json.Str(r, "url")) ?? 0); + if (!HasCode(w) && !kids.Any(withCode.Contains)) doneNoCode.Add($"{WorkItemFormat.Header(w)}{Who(w)}"); + } + } + + var sb = new StringBuilder($"backlog health of {t} ({name}): {items.Count}{(openIds.Count > items.Count ? $" most recently changed of {openIds.Count}" : "")} open " + + $"{string.Join("/", types.Select(x => x.Name))} items"); + if (findings.Count == 0 && doneNoCode.Count == 0) return sb.Append(" - nothing to tidy").ToString(); + sb.AppendLine(); + void Rule(string title, List list) + { + sb.AppendLine($"{title} ({list.Count}){(list.Count > ShownPerRule ? ", latest" : "")}:"); + foreach (var l in list.Take(ShownPerRule)) sb.AppendLine("- " + l); + } + // Items come newest first, so each rule shows the ones most likely still relevant. + foreach (var (rule, list) in findings.OrderByDescending(f => f.Value.Count)) Rule(rule, list); + if (doneNoCode.Count > 0) Rule($"done in the last {DoneWithoutCodeDays} days without linked code - fine for non-code work, else check with ado_trace", doneNoCode); + return sb.Append("Ask before changing anything; ado_query lists all items of a rule.").ToString(); + } + + static bool HasCode(JsonElement wi) => + Json.Arr(wi, "relations").Any(r => Json.Str(r, "rel") == "ArtifactLink" && Json.Str(r, "url") is { } url && + (url.Contains("/VersionControl/", StringComparison.OrdinalIgnoreCase) || url.Contains("/Git/", StringComparison.OrdinalIgnoreCase))); + + /// + /// Release notes in markdown: requirements and bugs finished in a sprint or since a date, grouped under their + /// feature, ready for a wiki page. + /// + public static async Task ReleaseNotesAsync(Lens lens, string? connection, string? project, string? team, string? sprint, string? since, string? until, + DateTime? today = null, CancellationToken ct = default) + { + var session = lens.For(connection); + var name = session.Project(project); + var boards = new Boards(session); + var t = await boards.TeamAsync(name, team, ct); + var day = (today ?? DateTime.Now).Date; + var s = (await session.Meta.GetAsync(name, ct)).Snapshot; + var types = BacklogTypes(s); + var done = types.SelectMany(x => x.States).Where(x => x.Category == "Completed").Select(x => x.Name).ToList(); + if (types.Count == 0 || done.Count == 0) return $"no requirement or bug types with a completed state in {name}"; + + string title, condition; + if (since is not null) + { + var from = Formatting.ParseSince(since, day); + var to = until is null ? day : Formatting.ParseSince(until, day); + title = $"{from:yyyy-MM-dd} - {to:yyyy-MM-dd}"; + condition = $" AND [Microsoft.VSTS.Common.StateChangeDate] >= '{from:yyyy-MM-dd}' AND [Microsoft.VSTS.Common.StateChangeDate] < '{to.AddDays(1):yyyy-MM-dd}'" + + await AreaAsync(boards, name, t, ct); + } + else + { + var it = Boards.Pick(await boards.IterationsAsync(name, t, ct), sprint, day); + title = it.Name + (it.Start is { } a && it.Finish is { } b ? $" ({a:yyyy-MM-dd} - {b:yyyy-MM-dd})" : ""); + condition = $" AND [System.IterationPath] = {Quote(it.Path)}"; + } + var (ids, _) = await session.Work.WiqlAsync(name, + $"SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] IN {InList(types.Select(x => x.Name))}" + + $" AND [System.State] IN {InList(done)}{condition} ORDER BY [System.Id]", 500, ct); + if (ids.Count == 0) return $"nothing finished in {title} ({t})"; + var items = await session.Work.GetAsync(ids, relations: true, ct: ct); + var parents = items.Select(w => Json.Arr(w, "relations").Where(r => Json.Str(r, "rel") == "System.LinkTypes.Hierarchy-Reverse") + .Select(r => Audit.TargetOf(Json.Str(r, "url")) ?? 0).FirstOrDefault()).Where(p => p > 0).Distinct().ToList(); + var parentTitles = parents.Count == 0 ? new Dictionary() + : (await session.Work.GetAsync(parents, relations: false, ["System.Id", "System.Title"], ct)).ToDictionary(WorkItems.Id, w => WorkItems.Field(w, "System.Title") ?? ""); + + string Line(JsonElement w) => $"- {WorkItems.Field(w, "System.Title")?.Trim()} (#{WorkItems.Id(w)})"; + int ParentOf(JsonElement w) => Json.Arr(w, "relations").Where(r => Json.Str(r, "rel") == "System.LinkTypes.Hierarchy-Reverse") + .Select(r => Audit.TargetOf(Json.Str(r, "url")) ?? 0).FirstOrDefault(); + + var sb = new StringBuilder($"# Release notes - {title}\n\n"); + var requirements = items.Where(w => Level(s, w) == Levels.Requirement).ToList(); + var bugs = items.Where(w => Level(s, w) == Levels.Bug).ToList(); + if (requirements.Count > 0) + { + sb.AppendLine("## New and changed\n"); + foreach (var g in requirements.GroupBy(ParentOf).OrderBy(g => g.Key == 0)) + { + if (g.Key != 0 && parentTitles.TryGetValue(g.Key, out var pt)) sb.AppendLine($"**{pt.Trim()}**\n"); + else if (g.Key == 0 && requirements.Any(w => ParentOf(w) != 0)) sb.AppendLine("**Other**\n"); + foreach (var w in g) sb.AppendLine(Line(w)); + sb.AppendLine(); + } + } + if (bugs.Count > 0) + { + sb.AppendLine("## Fixed\n"); + foreach (var w in bugs) sb.AppendLine(Line(w)); + sb.AppendLine(); + } + sb.Append($"({items.Count} items, {t}. Rewrite titles into user language before publishing; ado_wiki_write saves it as a page.)"); + return sb.ToString(); + } +} diff --git a/src/AdoLens/Knowledge.cs b/src/AdoLens/Knowledge.cs new file mode 100644 index 0000000..9976602 --- /dev/null +++ b/src/AdoLens/Knowledge.cs @@ -0,0 +1,349 @@ +using System.Globalization; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace AdoLens; + +/// "How does it work and why": one search over work items, wiki and code; a file; the history of a file. +public static partial class Knowledge +{ + const int MaxFileLines = 300; + static readonly string[] Scopes = ["work", "wiki", "code"]; + + [GeneratedRegex(@"")] + private static partial Regex Highlight(); + + /// One search over work items, wiki pages and code of a project, grouped; each part fails on its own. + public static async Task SearchAsync(Lens lens, string? connection, string? project, string text, string? scope, int top, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("text is required"); + var session = lens.For(connection); + var name = session.Project(project); + var wanted = (scope ?? "work;wiki;code").Split([';', ',', ' '], StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim().ToLowerInvariant()).ToHashSet(); + if (wanted.Except(Scopes).FirstOrDefault() is { } bad) throw new ArgumentException($"scope '{bad}': use work, wiki, code (several with ';')"); + top = Math.Clamp(top, 1, 50); + + var parts = new List<(string Scope, Task Task)>(); + if (wanted.Contains("work")) parts.Add(("work", Part(() => WorkItemsAsync(session, name, text, top, ct)))); + if (wanted.Contains("wiki")) parts.Add(("wiki", Part(() => WikiAsync(session.Client, name, text, top, ct)))); + if (wanted.Contains("code")) parts.Add(("code", Part(() => CodeAsync(session.Client, name, text, top, null, ct)))); + var sb = new StringBuilder($"search '{text}' in {name}\n"); + foreach (var (_, task) in parts) sb.AppendLine(await task); + return sb.ToString().TrimEnd(); + } + + static async Task Part(Func> f) + { + try { return await f(); } + catch (AdoApiException ex) { return $"({ex.Message})"; } + } + + static Dictionary SearchBody(string text, int top, string project, Dictionary? more = null, string projectFilter = "Project") + { + var filters = new Dictionary { [projectFilter] = [project] }; + if (more is not null) foreach (var (k, v) in more) filters[k] = v; + return new Dictionary { ["searchText"] = text, ["$top"] = top, ["$skip"] = 0, ["includeFacets"] = false, ["filters"] = filters }; + } + + static string Count(int shown, int total) => total > shown ? $"{shown} of {total}" : shown.ToString(CultureInfo.InvariantCulture); + + static int Total(JsonElement root, int fallback) => root.TryGetProperty("count", out var c) && c.TryGetInt32(out var n) ? n : fallback; + + static async Task WorkItemsAsync(Session session, string project, string text, int top, CancellationToken ct) + { + JsonElement root; + try + { + root = await session.Client.PostAsync(session.Client.ServiceUrl("almsearch") + $"{Uri.EscapeDataString(project)}/_apis/search/workitemsearchresults", + SearchBody(text, top, project, projectFilter: "System.TeamProject"), ct); + } + catch (AdoApiException ex) + { + if (Environment.GetEnvironmentVariable("ADOLENS_VERBOSE") == "1") Console.Error.WriteLine($"work item search: {ex.Message}"); + // Without the search service: titles only, through WIQL. + var safe = text.Replace("'", "''"); + var (ids, _) = await session.Work.WiqlAsync(project, + $"SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.Title] CONTAINS '{safe}' ORDER BY [System.ChangedDate] DESC", top, ct); + var items = await session.Work.GetAsync(ids, relations: false, ["System.Id", "System.WorkItemType", "System.State", "System.Title"], ct); + return items.Count == 0 ? "work items: none (titles searched)" + : $"work items (titles): {items.Count}\n" + string.Join('\n', items.Select(i => "- " + WorkItemFormat.Header(i))); + } + var results = Json.Arr(root, "results").ToList(); + if (results.Count == 0) return "work items: none"; + Snapshot? s = null; + try { s = (await session.Meta.GetAsync(project, ct)).Snapshot; } + catch (AdoApiException) { } + string FieldName(string reference) => + s?.Fields.Values.FirstOrDefault(f => f.Ref.Equals(reference, StringComparison.OrdinalIgnoreCase))?.Name ?? reference.Split('.')[^1]; + var sb = new StringBuilder($"work items: {Count(results.Count, Total(root, results.Count))}\n"); + foreach (var r in results) + { + var f = Json.Obj(r, "fields"); + sb.Append($"- #{Json.Str(f, "system.id")} {Json.Str(f, "system.workitemtype")} [{Json.Str(f, "system.state")}] {Json.Str(f, "system.title")}"); + // A hit outside the title says why it matched. + var hit = Json.Arr(r, "hits").FirstOrDefault(h => Json.Str(h, "fieldReferenceName") is { } n && !n.Equals("system.title", StringComparison.OrdinalIgnoreCase)); + if (hit.ValueKind == JsonValueKind.Object && Json.Arr(hit, "highlights").Select(h => h.GetString()).FirstOrDefault() is { } snippet) + sb.Append($" | {Json.Str(hit, "fieldName") ?? FieldName(Json.Str(hit, "fieldReferenceName")!)}: {Formatting.Collapse(Highlight().Replace(snippet, ""), 120)}"); + sb.AppendLine(); + } + return sb.ToString().TrimEnd(); + } + + static async Task WikiAsync(AdoClient client, string project, string text, int top, CancellationToken ct) + { + var root = await client.PostAsync(client.ServiceUrl("almsearch") + $"{Uri.EscapeDataString(project)}/_apis/search/wikisearchresults", SearchBody(text, top, project), ct); + var results = Json.Arr(root, "results").ToList(); + if (results.Count == 0) return "wiki: none"; + var sb = new StringBuilder($"wiki: {Count(results.Count, Total(root, results.Count))}\n"); + foreach (var r in results) + { + var path = WikiQueries.PagePath(Json.Str(r, "path") ?? Json.Str(r, "fileName") ?? ""); + var snippet = Json.Arr(r, "hits").SelectMany(h => Json.Arr(h, "highlights")).Select(h => h.GetString() ?? "").FirstOrDefault(); + sb.Append($"- {path}"); + if (snippet is not null) sb.Append(": ").Append(Formatting.Collapse(Highlight().Replace(snippet, ""), 140)); + sb.AppendLine(); + } + return sb.ToString().TrimEnd(); + } + + sealed record CodeHit(string Repo, string Path, string? Branch, int Matches, bool Tfvc); + + static async Task<(List Hits, int Total)> CodeHitsAsync(AdoClient client, string project, string text, int top, string? repo, CancellationToken ct) + { + var more = repo is null ? null : new Dictionary { ["Repository"] = [repo] }; + var root = await client.PostAsync(client.ServiceUrl("almsearch") + $"{Uri.EscapeDataString(project)}/_apis/search/codesearchresults", SearchBody(text, top, project, more), ct); + var hits = Json.Arr(root, "results").Select(r => + { + var path = Json.Str(r, "path") ?? Json.Str(r, "fileName") ?? ""; + var matches = Json.Obj(r, "matches"); + var count = matches.ValueKind == JsonValueKind.Object ? matches.EnumerateObject().Sum(p => p.Value.ValueKind == JsonValueKind.Array ? p.Value.GetArrayLength() : 0) : 0; + var branch = Json.Arr(r, "versions").Select(v => Json.Str(v, "branchName")).FirstOrDefault(b => b is not null); + return new CodeHit(Json.Str(Json.Obj(r, "repository"), "name") ?? "", path, branch, count, path.StartsWith("$/", StringComparison.Ordinal)); + }).ToList(); + return (hits, Total(root, hits.Count)); + } + + static async Task CodeAsync(AdoClient client, string project, string text, int top, string? repo, CancellationToken ct) + { + var (hits, total) = await CodeHitsAsync(client, project, text, top, repo, ct); + if (hits.Count == 0) return "code: none"; + var sb = new StringBuilder($"code: {Count(hits.Count, total)}\n"); + foreach (var h in hits) + { + var label = AotDiff.IsAot(h.Path) ? $"{AotDiff.ObjectName(h.Path)} - " : ""; + sb.AppendLine($"- {label}{(h.Tfvc ? h.Path : $"{h.Repo}: {h.Path}")}{(h.Matches > 1 ? $" ({h.Matches} matches)" : "")}"); + } + return sb.Append("ado_file reads a file or its history.").ToString(); + } + + // ---- files --------------------------------------------------------------------------------------------------------- + + sealed record FileRef(bool Tfvc, string Path, string? RepoId, string? RepoName, string Project); + + /// + /// A TFVC path ($/…), a Git path with its repository, or only a file name: then Code Search finds it (several + /// matches are listed for the caller to choose). + /// + static async Task<(FileRef? File, string? Choices)> ResolveAsync(Session session, string project, string path, string? repo, CancellationToken ct) + { + path = path.Trim().Replace('\\', '/'); + if (path.StartsWith("$/", StringComparison.Ordinal)) return (new FileRef(true, path, null, null, project), null); + if (repo is not null && path.Contains('/')) + { + var r = await session.Code.RepoAsync(repo, project, ct); + return (new FileRef(false, "/" + path.TrimStart('/'), r.Id, r.Name, r.Project ?? project), null); + } + if (repo is null && path.Contains('/')) + { + var repos = await session.Client.GetListAsync($"{AdoClient.Scope(project)}_apis/git/repositories", ct); + if (repos.Count == 1) + return (new FileRef(false, "/" + path.TrimStart('/'), Json.Str(repos[0], "id"), Json.Str(repos[0], "name"), project), null); + } + var name = path[(path.LastIndexOf('/') + 1)..]; + List hits; + try { (hits, _) = await CodeHitsAsync(session.Client, project, $"file:{name}", 20, repo, ct); } + catch (AdoApiException ex) + { + throw new InvalidOperationException($"give the full path ($/… for TFVC, or the path with repo= for Git): finding files by name needs Code Search ({ex.Message})"); + } + var matching = hits.Where(h => h.Path.EndsWith(path.StartsWith('/') ? path : "/" + path, StringComparison.OrdinalIgnoreCase)).ToList(); + if (matching.Count == 1) + { + var h = matching[0]; + if (h.Tfvc) return (new FileRef(true, h.Path, null, null, project), null); + var r = await session.Code.RepoAsync(h.Repo, project, ct); + return (new FileRef(false, h.Path, r.Id, r.Name, r.Project ?? project), null); + } + if (matching.Count == 0) return (null, $"no file '{path}' found in {project}"); + return (null, $"{matching.Count} files match '{path}' - pass the full path (and repo for Git):\n" + + string.Join('\n', matching.Select(h => h.Tfvc ? $"- {h.Path}" : $"- {h.Path} (repo {h.Repo})"))); + } + + /// A file at a version (default: latest): text, or for X++ objects their methods; part= one method. + public static async Task FileAsync(Lens lens, string? connection, string? project, string path, string? repo, string? version, + string? part, bool history, int top, string? detail, CancellationToken ct = default) + { + var session = lens.For(connection); + var name = session.Project(project); + var (file, choices) = await ResolveAsync(session, name, path, repo, ct); + if (file is null) return choices!; + if (history) return await HistoryAsync(session, file, version, top, ct); + + string? text; + string where; + if (file.Tfvc) + { + var v = version is null ? "" : $"&versionDescriptor.version={Uri.EscapeDataString(version.TrimStart('C', 'c'))}&versionDescriptor.versionType=changeset"; + text = await Content(session.Client, $"_apis/tfvc/items?path={Uri.EscapeDataString(file.Path)}&includeContent=true{v}", ct); + where = file.Path + (version is null ? "" : $" @ changeset {version}"); + } + else + { + var v = version is null ? "" : version.Length >= 7 && version.All(Uri.IsHexDigit) + ? $"&versionDescriptor.version={version}&versionDescriptor.versionType=commit" + : $"&versionDescriptor.version={Uri.EscapeDataString(version)}&versionDescriptor.versionType=branch"; + text = await Content(session.Client, $"_apis/git/repositories/{file.RepoId}/items?path={Uri.EscapeDataString(file.Path)}&includeContent=true{v}", ct); + where = $"{file.RepoName}: {file.Path}" + (version is null ? "" : $" @ {version}"); + } + if (text is null) return $"no file {where}"; + return Show(where, file.Path, text, part, detail == "full"); + } + + static async Task Content(AdoClient client, string url, CancellationToken ct) + { + try { return Json.Str(await client.GetAsync(url, ct), "content"); } + catch (AdoApiException ex) when (ex.Status is HttpStatusCode.NotFound or HttpStatusCode.BadRequest) { return null; } + } + + internal static string Show(string where, string path, string text, string? part, bool full) + { + var sb = new StringBuilder(where).AppendLine(); + if (AotDiff.IsAot(path) && TryParse(text) is { } doc) + { + var methods = AotDiff.Methods(doc); + if (part is not null) + { + var hit = methods.Where(m => m.Key.Equals(part, StringComparison.OrdinalIgnoreCase) || m.Key.EndsWith("." + part, StringComparison.OrdinalIgnoreCase)).ToList(); + if (hit.Count == 0) return sb.Append($"no method '{part}' in {AotDiff.ObjectName(path)}; methods: {string.Join(", ", methods.Keys)}").ToString(); + foreach (var (n, src) in hit) sb.AppendLine($"--- {n}").AppendLine(src.Trim()); + return sb.ToString().TrimEnd(); + } + 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)); + return sb.ToString().TrimEnd(); + } + var lines = Diff.Split(text); + if (full || lines.Length <= MaxFileLines) return sb.Append(text.TrimEnd()).ToString(); + return sb.AppendJoin('\n', lines.Take(MaxFileLines)).Append($"\n… {lines.Length - MaxFileLines} more lines (detail=full: whole file)").ToString(); + } + + static XDocument? TryParse(string xml) + { + try { return XDocument.Parse(xml); } + catch (System.Xml.XmlException) { return null; } + } + + [GeneratedRegex(@"Merged PR (\d+)", RegexOptions.IgnoreCase)] + private static partial Regex MergedPr(); + + [GeneratedRegex(@"(?:AB)?#(\d{2,7})\b")] + private static partial Regex ItemRef(); + + sealed record Change(DateTime? Date, string? Author, string Label, string? Message, List Items); + + /// Who changed a file, when, and for which work items: changesets (TFVC) or commits with their pull requests (Git). + static async Task HistoryAsync(Session session, FileRef file, string? branch, int top, CancellationToken ct) + { + top = Math.Clamp(top, 1, 50); + var client = session.Client; + var changes = new List(); + if (file.Tfvc) + { + var list = await client.GetListAsync($"_apis/tfvc/changesets?searchCriteria.itemPath={Uri.EscapeDataString(file.Path)}&$top={top}&maxCommentLength=200", ct); + var items = await Task.WhenAll(list.Select(async c => + { + var id = c.TryGetProperty("changesetId", out var v) ? v.GetInt32() : 0; + try { return (await client.GetListAsync($"_apis/tfvc/changesets/{id}/workItems", ct)).Select(w => w.TryGetProperty("id", out var i) ? i.GetInt32() : 0).Where(i => i > 0).ToList(); } + catch (AdoApiException) { return new List(); } + })); + changes.AddRange(list.Select((c, i) => new Change(Date(Json.Str(c, "createdDate")), Json.Str(Json.Obj(c, "author"), "displayName"), + $"changeset {(c.TryGetProperty("changesetId", out var v) ? v.GetInt32() : 0)}", Json.Str(c, "comment"), items[i]))); + } + else + { + var b = branch is null ? "" : $"&searchCriteria.itemVersion.version={Uri.EscapeDataString(branch)}&searchCriteria.itemVersion.versionType=branch"; + var list = await client.GetListAsync($"_apis/git/repositories/{file.RepoId}/commits?searchCriteria.itemPath={Uri.EscapeDataString(file.Path)}&searchCriteria.$top={top}{b}", ct); + // The pull request of each commit: "Merged PR n" in a merge commit, else the PR that brought the commit in. + var prOf = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var c in list) + if (MergedPr().Match(Json.Str(c, "comment") ?? "") is { Success: true } m) prOf[Json.Str(c, "commitId") ?? ""] = m.Groups[1].Value; + var other = list.Select(c => Json.Str(c, "commitId")).Where(x => x is not null && !prOf.ContainsKey(x)).ToList(); + if (other.Count > 0) + { + try + { + var found = await client.PostAsync($"_apis/git/repositories/{file.RepoId}/pullrequestquery", + new { queries = new[] { new { type = "commit", items = other } } }, ct); + foreach (var r in Json.Arr(found, "results")) + foreach (var p in r.EnumerateObject()) + if (p.Value.ValueKind == JsonValueKind.Array && p.Value.EnumerateArray().FirstOrDefault() is { ValueKind: JsonValueKind.Object } pr + && pr.TryGetProperty("pullRequestId", out var id)) + prOf[p.Name] = id.GetInt32().ToString(CultureInfo.InvariantCulture); + } + catch (AdoApiException) { } + } + var prItems = new Dictionary>(); + foreach (var pr in prOf.Values.Distinct()) + { + try + { + var linked = await client.GetListAsync($"_apis/git/repositories/{file.RepoId}/pullRequests/{pr}/workitems", ct); + prItems[pr] = linked.Select(w => int.TryParse(Json.Str(w, "id"), out var i) ? i : 0).Where(i => i > 0).ToList(); + } + catch (AdoApiException) { prItems[pr] = []; } + } + var items = list.Select(c => + { + var ids = ItemRef().Matches(Json.Str(c, "comment") ?? "").Select(m => int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture)).ToList(); + if (prOf.TryGetValue(Json.Str(c, "commitId") ?? "", out var pr)) ids.AddRange(prItems.GetValueOrDefault(pr, [])); + return ids.Distinct().ToList(); + }).ToArray(); + changes.AddRange(list.Select((c, i) => + { + var message = Json.Str(c, "comment"); + var shaKey = Json.Str(c, "commitId") ?? ""; + var pr = MergedPr().IsMatch(message ?? "") && prOf.TryGetValue(shaKey, out var n) ? $"PR {n}" + : prOf.TryGetValue(shaKey, out var via) ? $"commit {(shaKey.Length > 7 ? shaKey[..7] : shaKey)} (PR {via})" : null; + var sha = Json.Str(c, "commitId") ?? ""; + return new Change(Date(Json.Str(Json.Obj(c, "author"), "date")), Json.Str(Json.Obj(c, "author"), "name"), + pr ?? $"commit {(sha.Length > 7 ? sha[..7] : sha)}", message, items[i]); + })); + } + var where = file.Tfvc ? file.Path : $"{file.RepoName}: {file.Path}{(branch is null ? "" : $" ({branch})")}"; + if (changes.Count == 0) return $"no history for {where}"; + + var all = changes.SelectMany(c => c.Items).Distinct().ToList(); + var headers = all.Count == 0 ? [] : (await session.Work.GetAsync(all, relations: false, ["System.Id", "System.WorkItemType", "System.State", "System.Title"], ct)) + .ToDictionary(WorkItems.Id, w => $"#{WorkItems.Id(w)} {Formatting.Collapse(WorkItems.Field(w, "System.Title") ?? "", 60)} ({WorkItems.Field(w, "System.State")})"); + var sb = new StringBuilder($"history of {where}: {changes.Count} changes, newest first\n"); + foreach (var c in changes) + { + sb.Append($"- {c.Date:yyyy-MM-dd} {c.Author} - {c.Label}"); + var msg = c.Message is null ? "" : MergedPr().Replace(c.Message, "").TrimStart(':', ' '); + if (msg.Length > 0) sb.Append($": {Formatting.Collapse(msg, 80)}"); + var known = c.Items.Where(headers.ContainsKey).Select(i => headers[i]).ToList(); + if (known.Count > 0) sb.Append(" -> ").AppendJoin("; ", known); + sb.AppendLine(); + } + var none = changes.Count(c => !c.Items.Any(headers.ContainsKey)); + if (none > 0) sb.Append($"{none} of {changes.Count} changes have no work item"); + return sb.ToString().TrimEnd(); + } + + static DateTime? Date(string? s) => + DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var d) ? d : null; +} diff --git a/src/AdoLens/Lens.cs b/src/AdoLens/Lens.cs index 3fc6508..943994e 100644 --- a/src/AdoLens/Lens.cs +++ b/src/AdoLens/Lens.cs @@ -32,6 +32,7 @@ public sealed class Lens(Settings settings, string version, string dataDir, public Settings Settings => settings; public string Version => version; + public string DataDir => dataDir; /// Where settings changed through tools (ado_settings) are saved. public string SettingsPath => settingsPath ?? Path.Combine(dataDir, "adolens.json"); diff --git a/src/AdoLens/McpTools.cs b/src/AdoLens/McpTools.cs index 627dec8..36c4cb6 100644 --- a/src/AdoLens/McpTools.cs +++ b/src/AdoLens/McpTools.cs @@ -23,6 +23,10 @@ public static class McpTools - Writing: short and complete - what to do or what was done, no filler. Requirement: goal in 1-2 sentences, scope as bullets, testable acceptance criteria in their own field. Bug: what happens, what should, numbered steps, where. Task: one sentence. Comment: decision or question first. + - "How does X work": ado_search, then read the sources; say what comes from code and what from documentation. + """, + [Toolsets.Recipes] = """ + - A change the user repeats (close a sprint, new customer setup): offer to save it with ado_recipe. """, [Toolsets.Boards] = """ - Say plainly when a sprint has ended or a column is over its WIP limit. @@ -178,6 +182,24 @@ public static Task Backlog(Lens lens, => McpTools.Run("ado_backlog", new { level, top, move, above, below, team, project, connection }, () => BoardQueries.BacklogAsync(lens, connection, project, team, level, top, move, above, below, ct)); + [McpServerTool(Name = "ado_health", ReadOnly = true, Idempotent = true)] + [Description("Backlog hygiene of a team: open items without acceptance criteria, estimate, description or parent, stale, left in finished sprints, look-alike titles; recently done items without code.")] + public static Task Health(Lens lens, string? team = null, string? project = null, string? connection = null, CancellationToken ct = default) + => McpTools.Run("ado_health", new { team, project, connection }, () => BoardQueries.HealthAsync(lens, connection, project, team, null, ct)); + + [McpServerTool(Name = "ado_release_notes", ReadOnly = true, Idempotent = true)] + [Description("Release notes in markdown: requirements and bugs finished in a sprint (default current) or between dates, grouped by feature.")] + public static Task ReleaseNotes(Lens lens, + [Description("current, previous, or a sprint name.")] string? sprint = null, + [Description("Instead of a sprint: 14d, 2w or yyyy-MM-dd.")] string? since = null, + string? until = null, + string? team = null, + string? project = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_release_notes", new { sprint, since, until, team, project, connection }, + () => BoardQueries.ReleaseNotesAsync(lens, connection, project, team, sprint, since, until, null, ct)); + [McpServerTool(Name = "ado_my_day", ReadOnly = true, Idempotent = true)] [Description("What is new for me: mentions, my items and followed items changed by others, pull requests waiting for my review.")] public static Task MyDay(Lens lens, @@ -211,6 +233,21 @@ public static Task Changes(Lens lens, CancellationToken ct = default) => McpTools.Run("ado_changes", new { change, repo, file, detail, connection }, () => TraceQueries.ChangesAsync(lens, connection, change, repo, file, detail, ct)); + + [McpServerTool(Name = "ado_file", ReadOnly = true, Idempotent = true)] + [Description("A file from the repository (TFVC $/… path, Git path with repo, or just a file name), X++ objects as their methods (part=method shows one); history=true: who changed it, when, for which work items.")] + public static Task ReadFile(Lens lens, string path, + string? repo = null, + [Description("Changeset, commit or branch; default latest.")] string? version = null, + [Description("X++ method name.")] string? part = null, + bool history = false, + int top = 15, + [Description("full: whole file.")] string? detail = null, + string? project = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_file", new { path, repo, version, part, history, top, detail, project, connection }, + () => Knowledge.FileAsync(lens, connection, project, path, repo, version, part, history, top, detail, ct)); } [McpServerToolType, Toolset(Toolsets.Pr)] @@ -384,7 +421,7 @@ public static Task Update(Lens lens, string? description = null, string? acceptanceCriteria = null, [Description("Name or e-mail; empty clears it.")] string? assignedTo = null, - [Description("Iteration path or 'current'.")] string? iteration = null, + [Description("Iteration path, 'current' or 'next'.")] string? iteration = null, string? area = null, [Description("'a; b' replaces, '+a; -b' adds and removes.")] string? tags = null, [Description("Other fields by name or reference name; empty value clears.")] Dictionary? fields = null, @@ -414,4 +451,47 @@ public static Task Link(Lens lens, int id, CancellationToken ct = default) => McpTools.Run("ado_link", new { id, targets, type, remove, connection }, () => WorkQueries.LinkAsync(lens, connection, id, targets, type, remove, ct)); + + [McpServerTool(Name = "ado_search", ReadOnly = true, Idempotent = true)] + [Description("One search over work items, wiki pages and code of the project, grouped - the start for \"how does X work\".")] + public static Task Search(Lens lens, string text, + [Description("work;wiki;code (default all).")] string? scope = null, + int top = 10, + string? project = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_search", new { text, scope, top, project, connection }, () => Knowledge.SearchAsync(lens, connection, project, text, scope, top, ct)); + + [McpServerTool(Name = "ado_undo")] + [Description("Reverts the last change ADO Lens made (fields, comment, link, created item): preview, then confirm=; list=true shows recent changes.")] + public static Task Undo(Lens lens, string? confirm = null, bool list = false, string? connection = null, CancellationToken ct = default) + => McpTools.Run("ado_undo", new { confirm, list, connection }, + () => list ? Task.FromResult(Audit.List(lens, connection, 10)) : Audit.UndoAsync(lens, connection, confirm, ct)); +} + +[McpServerToolType, Toolset(Toolsets.Recipes)] +public static class RecipeTools +{ + [McpServerTool(Name = "ado_recipe")] + [Description("Saved repeatable changes. action: list | show | save (recipe = JSON: {description, parameters:[..], steps:[{query (WIQL with {param}) or ids, state, iteration ('next'), assignedTo, tags, fields, comment, child:{type, title ({title} {id}), fields}}]}) | run (params; preview, then confirm) | delete.")] + public static Task Recipe(Lens lens, string action, + string? name = null, + [Description("For save: the recipe JSON.")] string? recipe = null, + [Description("For run: parameter values.")] Dictionary? @params = null, + string? confirm = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_recipe", new { action, name, @params, confirm, connection }, async () => + { + string Need(string? x, string what) => string.IsNullOrWhiteSpace(x) ? throw new ArgumentException($"{what} is required for {action}") : x; + return action.Trim().ToLowerInvariant() switch + { + "list" => Recipes.List(lens), + "show" => Recipes.Show(lens, Need(name, "name")), + "save" => Recipes.Save(lens, Need(name, "name"), Need(recipe, "recipe")), + "delete" => Recipes.Delete(lens, Need(name, "name")), + "run" => await Recipes.RunAsync(lens, connection, Need(name, "name"), @params, confirm, yes: false, ct), + _ => throw new ArgumentException("action is list, show, save, run or delete"), + }; + }); } diff --git a/src/AdoLens/Program.cs b/src/AdoLens/Program.cs index 77f3ca0..7eba701 100644 --- a/src/AdoLens/Program.cs +++ b/src/AdoLens/Program.cs @@ -88,6 +88,18 @@ return await cli.WikiAsync(); case "wiki-write": return await cli.WikiWriteAsync(); + case "search": + return await cli.SearchAsync(); + case "file": + return await cli.FileAsync(); + case "health": + return await cli.HealthAsync(); + case "release-notes": + return await cli.ReleaseNotesAsync(); + case "undo": + return await cli.UndoAsync(); + case "recipe": + return await cli.RecipeAsync(); case "help" or "--help" or "-h" or "/?": Cli.PrintUsage(); return 0; diff --git a/src/AdoLens/Recipes.cs b/src/AdoLens/Recipes.cs new file mode 100644 index 0000000..7f1677d --- /dev/null +++ b/src/AdoLens/Recipes.cs @@ -0,0 +1,234 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace AdoLens; + +/// A child work item a recipe creates under each item found. +public sealed record RecipeChild +{ + public string Type { get; init; } = "Task"; + public string Title { get; init; } = ""; + public string? Description { get; init; } + public string? AssignedTo { get; init; } + public Dictionary? Fields { get; init; } +} + +/// One step: which items (WIQL or ids) and what to do with each: change fields, comment, create a child. +public sealed record RecipeStep +{ + public string? Query { get; init; } + public string? Ids { get; init; } + public string? State { get; init; } + public string? Reason { get; init; } + public string? AssignedTo { get; init; } + /// An iteration path, "current" or "next". + public string? Iteration { get; init; } + public string? Area { get; init; } + public string? Tags { get; init; } + public Dictionary? Fields { get; init; } + public string? Comment { get; init; } + public RecipeChild? Child { get; init; } + + internal WriteRequest Request(Func fill) => new() + { + State = fill(State), Reason = fill(Reason), AssignedTo = fill(AssignedTo), Iteration = fill(Iteration), Area = fill(Area), Tags = fill(Tags), + Fields = Fields?.ToDictionary(kv => kv.Key, kv => fill(kv.Value) ?? ""), + }; +} + +/// A named, repeatable change: steps with {parameters}, run from Claude or from the command line. +public sealed record Recipe +{ + public string Name { get; init; } = ""; + public string? Description { get; init; } + public string? Project { get; init; } + public List Parameters { get; init; } = []; + public List Steps { get; init; } = []; +} + +/// Recipes saved locally (%LOCALAPPDATA%\ado-lens\recipes\<name>.json); every run is previewed first. +public static partial class Recipes +{ + static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + [GeneratedRegex(@"\{(\w+)\}")] + private static partial Regex Placeholder(); + + [GeneratedRegex(@"^[a-z0-9][a-z0-9-]{0,48}$")] + private static partial Regex ValidName(); + + public static string Dir(Lens lens) => Path.Combine(lens.DataDir, "recipes"); + + static string PathOf(Lens lens, string name) => Path.Combine(Dir(lens), Normalize(name) + ".json"); + + static string Normalize(string name) + { + var n = Regex.Replace(name.Trim().ToLowerInvariant(), @"[\s_]+", "-"); + return ValidName().IsMatch(n) ? n : throw new ArgumentException($"recipe name '{name}': letters, digits and '-' only (e.g. close-sprint)"); + } + + public static Recipe Load(Lens lens, string name) + { + var path = PathOf(lens, name); + if (!File.Exists(path)) throw new InvalidOperationException($"no recipe '{name}'; saved: {string.Join(", ", Names(lens).DefaultIfEmpty("none"))}"); + return JsonSerializer.Deserialize(File.ReadAllText(path), Options) ?? throw new InvalidOperationException($"recipe '{name}' is empty"); + } + + static List Names(Lens lens) => + Directory.Exists(Dir(lens)) ? Directory.GetFiles(Dir(lens), "*.json").Select(f => Path.GetFileNameWithoutExtension(f)).Order().ToList() : []; + + public static string List(Lens lens) + { + var names = Names(lens); + if (names.Count == 0) return $"no recipes yet ({Dir(lens)}); save one with action=save"; + var sb = new StringBuilder($"recipes ({Dir(lens)}):\n"); + foreach (var n in names) + { + try + { + var r = Load(lens, n); + sb.AppendLine($"- {n}{(r.Parameters.Count > 0 ? $" ({string.Join(", ", r.Parameters)})" : "")}: {r.Description ?? $"{r.Steps.Count} steps"}"); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) { sb.AppendLine($"- {n}: unreadable ({ex.Message})"); } + } + return sb.ToString().TrimEnd(); + } + + public static string Show(Lens lens, string name) => $"{PathOf(lens, name)}\n{JsonSerializer.Serialize(Load(lens, name), Options)}"; + + public static string Save(Lens lens, string name, string json) + { + Recipe recipe; + try { recipe = JsonSerializer.Deserialize(json, Options) ?? throw new ArgumentException("recipe is empty"); } + catch (JsonException ex) { throw new ArgumentException($"recipe is not valid JSON: {ex.Message}"); } + recipe = recipe with { Name = Normalize(name) }; + if (recipe.Steps.Count == 0) throw new ArgumentException("a recipe needs at least one step"); + foreach (var (step, i) in recipe.Steps.Select((x, i) => (x, i + 1))) + { + if (step.Query is null == (step.Ids is null)) throw new ArgumentException($"step {i}: give either query (WIQL) or ids"); + if (step.Request(x => x).IsEmpty && step.Comment is null && step.Child is null) throw new ArgumentException($"step {i}: nothing to do"); + if (step.Child is { } c && string.IsNullOrWhiteSpace(c.Title)) throw new ArgumentException($"step {i}: child needs a title"); + } + var used = Placeholder().Matches(json).Select(m => m.Groups[1].Value).Where(p => p is not ("id" or "title")).Distinct().ToList(); + var undeclared = used.Except(recipe.Parameters, StringComparer.OrdinalIgnoreCase).ToList(); + if (undeclared.Count > 0) recipe = recipe with { Parameters = [.. recipe.Parameters, .. undeclared] }; + Directory.CreateDirectory(Dir(lens)); + var existed = File.Exists(PathOf(lens, recipe.Name)); + File.WriteAllText(PathOf(lens, recipe.Name), JsonSerializer.Serialize(recipe, Options)); + return $"{(existed ? "replaced" : "saved")} recipe {recipe.Name}: {recipe.Steps.Count} steps" + + (recipe.Parameters.Count > 0 ? $", parameters {string.Join(", ", recipe.Parameters)}" : "") + + $"\nrun it with action=run (preview first), or from a terminal: adolens recipe run {recipe.Name}"; + } + + public static string Delete(Lens lens, string name) + { + var path = PathOf(lens, name); + if (!File.Exists(path)) return $"no recipe '{name}'"; + File.Delete(path); + return $"deleted recipe {Normalize(name)}"; + } + + static string Fill(string? text, IReadOnlyDictionary values) => + text is null ? "" : Placeholder().Replace(text, m => values.TryGetValue(m.Groups[1].Value, out var v) ? v : m.Value); + + /// + /// Runs a recipe: the first call (or no confirm) previews every step - how many items, which ones, what changes; + /// confirm=<token> writes. skips the preview (scheduled runs from the command line). + /// + public static async Task RunAsync(Lens lens, string? connection, string name, IReadOnlyDictionary? parameters, + string? confirm, bool yes, CancellationToken ct = default) + { + var recipe = Load(lens, name); + var values = new Dictionary(parameters ?? new Dictionary(), StringComparer.OrdinalIgnoreCase); + var missing = recipe.Parameters.Where(p => !values.ContainsKey(p)).ToList(); + if (missing.Count > 0) throw new ArgumentException($"recipe {recipe.Name} needs: {string.Join(", ", missing)}"); + var session = lens.For(connection); + var project = session.Project(recipe.Project); + + var plans = new List<(RecipeStep Step, List Ids)>(); + foreach (var step in recipe.Steps) + { + var ids = step.Ids is not null ? WorkQueries.ParseIds(Fill(step.Ids, values)) + : (await session.Work.WiqlAsync(project, Fill(step.Query, values), 500, ct)).Ids; + plans.Add((step, ids)); + } + var token = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( + JsonSerializer.Serialize(recipe, Options) + string.Join(';', values.OrderBy(v => v.Key).Select(v => $"{v.Key}={v.Value}")) + + string.Join('|', plans.Select(p => string.Join(',', p.Ids))))))[..8].ToLowerInvariant(); + + if (!yes && !string.Equals(confirm?.Trim(), token, StringComparison.OrdinalIgnoreCase)) + { + var heads = plans.SelectMany(p => p.Ids).Distinct().Take(200).ToList(); + var known = heads.Count == 0 ? [] : (await session.Work.GetAsync(heads, relations: false, + ["System.Id", "System.WorkItemType", "System.State", "System.Title"], ct)).ToDictionary(WorkItems.Id, WorkItemFormat.Header); + var sb = new StringBuilder($"PREVIEW of recipe {recipe.Name} - nothing written yet.\n"); + foreach (var ((step, ids), i) in plans.Select((p, i) => (p, i + 1))) + { + sb.AppendLine($"step {i}: {Describe(step, values)} -> {ids.Count} items"); + foreach (var id in ids.Take(10)) sb.AppendLine($" - {known.GetValueOrDefault(id, $"#{id}")}"); + if (ids.Count > 10) sb.AppendLine($" - … {ids.Count - 10} more"); + } + return sb.Append($"Show this to the user. To run it, call again with confirm={token}.").ToString(); + } + + var result = new StringBuilder($"recipe {recipe.Name}:\n"); + foreach (var ((step, ids), i) in plans.Select((p, i) => (p, i + 1))) + { + if (ids.Count == 0) { result.AppendLine($"step {i}: no items"); continue; } + string Text(string? x) => Fill(x, values); + var request = step.Request(x => x is null ? null : Text(x)); + if (!request.IsEmpty || step.Comment is not null) + { + var text = await WorkQueries.UpdateAsync(lens, connection, string.Join(';', ids), null, request, + step.Comment is null ? null : Text(step.Comment), null, int.MaxValue, ct); + result.AppendLine($"step {i}: {text.Split('\n')[0]}"); + } + if (step.Child is { } child) + { + var items = await session.Work.GetAsync(ids, relations: false, ["System.Id", "System.Title", "System.TeamProject"], ct); + var created = 0; + foreach (var wi in items) + { + var each = new Dictionary(values, StringComparer.OrdinalIgnoreCase) + { + ["id"] = WorkItems.Id(wi).ToString(System.Globalization.CultureInfo.InvariantCulture), + ["title"] = WorkItems.Field(wi, "System.Title") ?? "", + }; + await WorkQueries.CreateAsync(lens, connection, WorkItems.Field(wi, "System.TeamProject") ?? project, child.Type, new WriteRequest + { + Title = Fill(child.Title, each), Description = child.Description is null ? null : Fill(child.Description, each), + AssignedTo = child.AssignedTo is null ? null : Fill(child.AssignedTo, each), Parent = WorkItems.Id(wi), + Fields = child.Fields?.ToDictionary(kv => kv.Key, kv => Fill(kv.Value, each)), + }, ct); + created++; + } + result.AppendLine($"step {i}: created {created} {child.Type} item(s)"); + } + } + return result.Append("ado_undo reverts the last step if needed.").ToString(); + } + + static string Describe(RecipeStep s, IReadOnlyDictionary v) + { + var parts = new List(); + if (s.State is not null) parts.Add($"state={Fill(s.State, v)}"); + if (s.Iteration is not null) parts.Add($"iteration={Fill(s.Iteration, v)}"); + if (s.AssignedTo is not null) parts.Add($"assigned to={Fill(s.AssignedTo, v)}"); + if (s.Area is not null) parts.Add($"area={Fill(s.Area, v)}"); + if (s.Tags is not null) parts.Add($"tags {Fill(s.Tags, v)}"); + if (s.Reason is not null) parts.Add($"reason={Fill(s.Reason, v)}"); + if (s.Fields is not null) parts.AddRange(s.Fields.Select(kv => $"{kv.Key}={Fill(kv.Value, v)}")); + if (s.Comment is not null) parts.Add("comment"); + if (s.Child is { } c) parts.Add($"new {c.Type} '{c.Title}' under each"); + return string.Join(", ", parts); + } +} diff --git a/src/AdoLens/Wiki.cs b/src/AdoLens/Wiki.cs index 4d71d79..bbe78ea 100644 --- a/src/AdoLens/Wiki.cs +++ b/src/AdoLens/Wiki.cs @@ -35,6 +35,13 @@ static string PagesUrl(string project, WikiRef w, string path, string extra = "" (w.Type == "codeWiki" && w.Branch is not null ? $"&versionDescriptor.version={Uri.EscapeDataString(w.Branch)}&versionDescriptor.versionType=branch" : ""); /// "Team/Setup" → "/Team/Setup"; spaces and case kept. + /// A wiki file path as a page path: in file names "-" is a space and "%2D" a real hyphen. + internal static string PagePath(string file) + { + var p = file.EndsWith(".md", StringComparison.OrdinalIgnoreCase) ? file[..^3] : file; + return Uri.UnescapeDataString(p.Replace('-', ' ')); + } + internal static string NormalizePath(string path) { var p = path.Trim().Replace('\\', '/'); @@ -127,7 +134,7 @@ static async Task SearchAsync(AdoClient client, string project, string t var sb = new StringBuilder($"wiki pages matching '{text}': {(total > results.Count ? $"{results.Count} of {total}" : results.Count.ToString())}\n"); foreach (var r in results) { - var path = (Json.Str(r, "path") ?? Json.Str(r, "fileName") ?? "").Replace(".md", "").Replace('-', ' '); + var path = PagePath(Json.Str(r, "path") ?? Json.Str(r, "fileName") ?? ""); var snippet = Json.Arr(r, "hits").SelectMany(h => Json.Arr(h, "highlights")).Select(h => h.GetString() ?? "").FirstOrDefault(); sb.Append($"- {path} (wiki {Json.Str(Json.Obj(r, "wiki"), "name")})"); if (snippet is not null) sb.Append(": ").Append(Formatting.Collapse(Highlight().Replace(snippet, ""), 160)); diff --git a/src/AdoLens/WorkItems.cs b/src/AdoLens/WorkItems.cs index 3717d59..0f70975 100644 --- a/src/AdoLens/WorkItems.cs +++ b/src/AdoLens/WorkItems.cs @@ -138,6 +138,13 @@ static List Columns(JsonElement root) => return list.Select(i => Json.Str(i, "path")).FirstOrDefault(); } + public async Task NextIterationAsync(string project, CancellationToken ct = default) + { + var team = await TeamAsync(project, ct); + var list = await client.GetListAsync($"{AdoClient.Scope(project)}{Uri.EscapeDataString(team)}/_apis/work/teamsettings/iterations?$timeframe=future", ct); + return list.OrderBy(i => Json.Str(Json.Obj(i, "attributes"), "startDate") ?? "~").Select(i => Json.Str(i, "path")).FirstOrDefault(); + } + public Task CreateAsync(string project, string type, List ops, CancellationToken ct = default) => client.SendAsync(HttpMethod.Post, $"{AdoClient.Scope(project)}_apis/wit/workitems/${Uri.EscapeDataString(type)}", ops, "application/json-patch+json", ct); @@ -182,9 +189,12 @@ public async Task> BuildOpsAsync(Snapshot s, TypeInfo type, WriteRe set["System.AssignedTo"] = r.AssignedTo.Trim().Length == 0 ? "" : (await people.ResolveAsync(r.AssignedTo, ct)).FieldValue; if (r.Area is not null) set["System.AreaPath"] = TreePath(project, r.Area); if (r.Iteration is not null) - set["System.IterationPath"] = r.Iteration.Trim().Equals("current", StringComparison.OrdinalIgnoreCase) - ? await CurrentIterationAsync(project, ct) ?? throw new InvalidOperationException("the team has no current iteration") - : TreePath(project, r.Iteration); + set["System.IterationPath"] = r.Iteration.Trim().ToLowerInvariant() switch + { + "current" => await CurrentIterationAsync(project, ct) ?? throw new InvalidOperationException("the team has no current iteration"), + "next" => await NextIterationAsync(project, ct) ?? throw new InvalidOperationException("the team has no future iteration"), + _ => TreePath(project, r.Iteration), + }; if (r.Tags is not null) set["System.Tags"] = MergeTags(current is { } cur ? Field(cur, "System.Tags") : null, r.Tags); if (r.Fields is not null) diff --git a/src/AdoLens/WorkQueries.cs b/src/AdoLens/WorkQueries.cs index 61ad43e..8ba72e4 100644 --- a/src/AdoLens/WorkQueries.cs +++ b/src/AdoLens/WorkQueries.cs @@ -87,6 +87,8 @@ public static async Task CreateAsync(Lens lens, string? connection, stri var notes = new List(); var ops = await session.Work.BuildOpsAsync(s, t, r, null, notes, ct); var created = await session.Work.CreateAsync(s.Project.Name, t.Name, ops, ct); + Audit.Write(lens, session.Config.Connection, [new AuditEntry { Batch = Audit.NewBatch(), Tool = "ado_create", Kind = "create", + Id = WorkItems.Id(created), Project = s.Project.Name, Title = WorkItems.Field(created, "System.Title") }]); return Written("created", created, s, session.Work, notes); } @@ -141,14 +143,21 @@ public static async Task UpdateAsync(Lens lens, string? connection, stri var results = new List(); var failed = 0; + var batch = Audit.NewBatch(); + var log = new List(); await Parallel.ForEachAsync(plans, new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = ct }, async (p, token2) => { var id = WorkItems.Id(p.Item); try { var updated = p.Ops.Count > 0 ? await work.PatchAsync(id, p.Ops, token2) : p.Item; + if (p.Ops.Count > 0) lock (log) log.AddRange(Audit.FromOps(batch, "ado_update", p.Item, updated, p.Ops)); if (!string.IsNullOrWhiteSpace(comment)) - await work.AddCommentAsync(WorkItems.Field(p.Item, "System.TeamProject")!, id, await CommentHtmlAsync(session.People, comment, token2), token2); + { + var project = WorkItems.Field(p.Item, "System.TeamProject")!; + var c = await work.AddCommentAsync(project, id, await CommentHtmlAsync(session.People, comment, token2), token2); + lock (log) log.Add(CommentEntry(batch, "ado_update", p.Item, project, c)); + } lock (results) results.Add(items.Count == 1 ? Written("updated", updated, p.S, work, p.Notes) : "ok " + WorkItemFormat.Header(updated)); } catch (Exception ex) when (ex is AdoApiException or InvalidOperationException) @@ -156,6 +165,7 @@ public static async Task UpdateAsync(Lens lens, string? connection, stri lock (results) { results.Add($"FAILED #{id}: {ex.Message}"); failed++; } } }); + Audit.Write(lens, session.Config.Connection, log); if (items.Count == 1) return results[0]; var order = ordered.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i); return $"updated {items.Count - failed} of {items.Count}{(failed > 0 ? $", {failed} failed" : "")}:\n" + @@ -205,6 +215,12 @@ public static async Task CommentHtmlAsync(Identities people, string mark return Html.FromMarkdown(text, raw); } + static AuditEntry CommentEntry(string batch, string tool, JsonElement wi, string project, JsonElement comment) => new() + { + Batch = batch, Tool = tool, Kind = "comment", Id = WorkItems.Id(wi), Project = project, Title = WorkItems.Field(wi, "System.Title"), + CommentId = comment.ValueKind == JsonValueKind.Object && comment.TryGetProperty("id", out var cid) && cid.TryGetInt32(out var n) ? n : null, + }; + public static async Task CommentAsync(Lens lens, string? connection, int id, string text, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("text is required"); @@ -213,7 +229,8 @@ public static async Task CommentAsync(Lens lens, string? connection, int if (items.Count == 0) return $"no work item #{id} visible with this PAT"; var project = WorkItems.Field(items[0], "System.TeamProject")!; var html = await CommentHtmlAsync(session.People, text, ct); - await session.Work.AddCommentAsync(project, id, html, ct); + var added = await session.Work.AddCommentAsync(project, id, html, ct); + Audit.Write(lens, session.Config.Connection, [CommentEntry(Audit.NewBatch(), "ado_comment", items[0], project, added)]); var mentions = Mention().Matches(text).Count; var note = Html.Words(text) > 60 ? $"\nnote: {Html.Words(text)} words; comments read best at ≤ 60 (decision or question first)" : ""; return $"commented on {WorkItemFormat.Header(items[0])}{(mentions > 0 ? $" ({mentions} mention(s) notified)" : "")}{note}"; @@ -268,7 +285,14 @@ public static async Task LinkAsync(Lens lens, string? connection, int id if (IndexOf(t) >= 0) skipped.Add($"#{t} (already linked)"); else ops.Add(new { op = "add", path = "/relations/-", value = new { rel, url = $"{work.WebBase}_apis/wit/workItems/{t}" } }); } - if (ops.Count > 0) await work.PatchAsync(id, ops, ct); + if (ops.Count > 0) + { + await work.PatchAsync(id, ops, ct); + var batch = Audit.NewBatch(); + Audit.Write(lens, session.Config.Connection, ids.Where(t => !skipped.Any(x => x.StartsWith($"#{t} ", StringComparison.Ordinal))) + .Select(t => new AuditEntry { Batch = batch, Tool = "ado_link", Kind = remove ? "link-remove" : "link-add", Id = id, + Title = WorkItems.Field(wi, "System.Title"), Rel = rel, Target = t })); + } var headers = await HeadersAsync(work, ids, ct); var done = ids.Where(t => !skipped.Any(x => x.StartsWith($"#{t} ", StringComparison.Ordinal))).Select(t => headers.GetValueOrDefault(t, $"#{t}")).ToList(); var sb = new StringBuilder($"{WorkItemFormat.Header(wi)}: "); diff --git a/tests/AdoLens.Tests/KnowledgeTests.cs b/tests/AdoLens.Tests/KnowledgeTests.cs new file mode 100644 index 0000000..768226a --- /dev/null +++ b/tests/AdoLens.Tests/KnowledgeTests.cs @@ -0,0 +1,233 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; + +namespace AdoLens.Tests; + +/// Phase 4: search, files and their history, undo, backlog health, release notes, recipes. +public class KnowledgeTests : IDisposable +{ + static readonly DateTime Today = new(2026, 10, 14, 9, 0, 0, DateTimeKind.Local); + + readonly string dir = Path.Combine(Path.GetTempPath(), $"adolens-knowledge-{Guid.NewGuid():N}"); + readonly DemoProject demo = new(); + readonly DemoItems items = new(); + readonly List deletes = []; + readonly List<(int Id, string Body)> patches = []; + int[] wiql = [4, 2]; + readonly Lens lens; + + static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK) => + new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + + static string Body(HttpRequestMessage r) => r.Content!.ReadAsStringAsync().Result; + + const string Aot = """ + + + CreditCheck + + + + validate= amount; + }]]> + run + + + + """; + + public KnowledgeTests() + { + foreach (var (id, item) in items.Items) item["rev"] = 3; + var ado = new FakeAdo() + // Patches change the stored item (and its revision), so undo sees what is there now. + .On("/contoso/_apis/wit/workitems/", r => + { + var id = int.Parse(r.RequestUri!.AbsolutePath.Split('/')[^1]); + var body = Body(r); + lock (patches) patches.Add((id, body)); + var item = items.Items[id]; + foreach (var op in JsonNode.Parse(body)!.AsArray()) + { + var path = (string)op!["path"]!; + if (!path.StartsWith("/fields/")) continue; + if ((string)op["op"]! == "add") item["fields"]![path[8..]] = op["value"]!.DeepClone(); + else ((JsonObject)item["fields"]!).Remove(path[8..]); + } + item["rev"] = (int)item["rev"]! + 1; + return Json(item.ToJsonString()); + }) + .On("/contoso/Demo/_apis/wit/workItems/1/comments", r => + { + if (r.Method == HttpMethod.Delete) { deletes.Add(r.RequestUri!.AbsolutePath); return Json(""); } + return Json("""{"id":77}"""); + }) + .On("/contoso/Demo/_apis/wit/workItems/", """{"id":78}""") + .On("/contoso/Demo/Demo Team/_apis/wit/wiql", _ => Json(new JsonObject { ["workItems"] = new JsonArray(wiql.Select(i => (JsonNode)new JsonObject { ["id"] = i }).ToArray()) }.ToJsonString())); + ado = items.Routes(demo.Routes(ado)) + .On("/contoso/Demo/_apis/search/workitemsearchresults", """ + {"count":7,"results":[{"fields":{"system.id":"1","system.workitemtype":"Product Backlog Item","system.state":"New","system.title":"Login page"}, + "hits":[{"fieldReferenceName":"system.title","highlights":["Login page"]}, + {"fieldReferenceName":"microsoft.vsts.common.acceptancecriteria","fieldName":"Acceptance Criteria","highlights":["user can sign in with login"]}]}]} + """) + .On("/contoso/Demo/_apis/search/wikisearchresults", """{"count":1,"results":[{"path":"/Portal/Sign-in.md","hits":[{"highlights":["the login uses SSO"]}]}]}""") + .On("/contoso/Demo/_apis/search/codesearchresults", r => Body(r).Contains("file:") + ? Json("""{"count":2,"results":[{"path":"/src/AxClass/CreditCheck.xml","repository":{"name":"erp"}},{"path":"/old/CreditCheckOld.xml","repository":{"name":"erp"}}]}""") + : Json("""{"message":"Code search is not available"}""", HttpStatusCode.NotFound)) + .On("/contoso/Demo/_apis/git/repositories/erp", """{"id":"r9","name":"erp","project":{"name":"Demo"}}""") + .On("/contoso/_apis/git/repositories/r9/items", Json_(new JsonObject { ["content"] = Aot })) + .On("/contoso/_apis/git/repositories/r9/commits", """ + {"value":[ + {"commitId":"aaaaaaaa11","author":{"name":"Ben Kowalski","date":"2026-10-10T10:00:00Z"},"comment":"Merged PR 482: Credit limit check"}, + {"commitId":"bbbbbbbb22","author":{"name":"Anna Nowak","date":"2026-09-01T10:00:00Z"},"comment":"Typo"}]} + """) + .On("/contoso/_apis/git/repositories/r9/pullRequests/482/workitems", """{"value":[{"id":"1"}]}""") + .On("/contoso/Demo/Demo Team/_apis/work/teamsettings/teamfieldvalues", """{"field":{"referenceName":"System.AreaPath"},"values":[{"value":"Demo","includeChildren":true}]}""") + .On("/contoso/Demo/Demo Team/_apis/work/teamsettings/iterations", """ + {"value":[ + {"id":"s1","name":"Sprint 1","path":"Demo\\Sprint 1","attributes":{"startDate":"2026-09-21T00:00:00Z","finishDate":"2026-10-02T00:00:00Z","timeFrame":"past"}}, + {"id":"s2","name":"Sprint 2","path":"Demo\\Sprint 2","attributes":{"startDate":"2026-10-05T00:00:00Z","finishDate":"2026-10-16T00:00:00Z","timeFrame":"current"}}]} + """); + var s = new Settings { DefaultConnection = "contoso" }; + s.Connections["contoso"] = new Connection { Organization = "contoso", Project = "Demo" }; + lens = new Lens(s, "0.0.0", dir, _ => null, _ => "pat", _ => ado.Client()); + } + + static string Json_(JsonObject o) => o.ToJsonString(); + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + + [Fact] + public async Task One_search_groups_work_items_wiki_and_code_and_survives_a_missing_part() + { + var text = await Knowledge.SearchAsync(lens, null, null, "login", null, 10); + Assert.Contains("work items: 1 of 7\n- #1 Product Backlog Item [New] Login page | Acceptance Criteria: user can sign in with login", text); + Assert.Contains("wiki: 1\n- /Portal/Sign in: the login uses SSO", text); + Assert.Contains("Code search is not available", text); + await Assert.ThrowsAsync(() => Knowledge.SearchAsync(lens, null, null, "x", "docs", 10)); + } + + [Fact] + public async Task A_file_found_by_name_shows_X_methods_and_one_method_on_request() + { + var text = await Knowledge.FileAsync(lens, null, null, "CreditCheck.xml", null, null, null, false, 15, null); + Assert.Contains("erp: /src/AxClass/CreditCheck.xml", text); + Assert.Contains("CreditCheck (class): 3 methods", text); + Assert.Contains("- validate (4 lines)", text); + + var method = await Knowledge.FileAsync(lens, null, null, "/src/AxClass/CreditCheck.xml", "erp", null, "validate", false, 15, null); + Assert.Contains("--- validate\npublic boolean validate()", method.Replace("\r", "")); + Assert.DoesNotContain("run()", method); + } + + [Fact] + public async Task File_history_names_the_work_items_behind_each_change() + { + var text = await Knowledge.FileAsync(lens, null, null, "/src/AxClass/CreditCheck.xml", "erp", null, null, true, 15, null); + Assert.Contains("history of erp: /src/AxClass/CreditCheck.xml: 2 changes", text); + Assert.Contains("- 2026-10-10 Ben Kowalski - PR 482: Credit limit check -> #1 Login page (New)", text); + Assert.Contains("- 2026-09-01 Anna Nowak - commit bbbbbbb: Typo", text); + Assert.Contains("1 of 2 changes have no work item", text); + } + + [Fact] + public async Task Undo_previews_then_restores_the_values_before_the_change() + { + await WorkQueries.UpdateAsync(lens, null, "1", null, new WriteRequest { State = "Committed", Title = "Sign-in page" }, null, null, 3); + Assert.Equal("Committed", (string)items.Items[1]["fields"]!["System.State"]!); + + var preview = await Audit.UndoAsync(lens, null, null); + Assert.StartsWith("UNDO PREVIEW", preview); + Assert.Contains("State Committed -> New", preview); + var token = preview[(preview.LastIndexOf("confirm=", StringComparison.Ordinal) + 8)..].TrimEnd('.'); + + var done = await Audit.UndoAsync(lens, null, token); + Assert.Contains("reverted #1", done); + Assert.Equal("New", (string)items.Items[1]["fields"]!["System.State"]!); + Assert.Equal("Login page", (string)items.Items[1]["fields"]!["System.Title"]!); + Assert.Equal("nothing to undo: no changes by ADO Lens on this connection", await Audit.UndoAsync(lens, null, null)); + } + + [Fact] + public async Task Undo_leaves_what_someone_else_changed_since() + { + await WorkQueries.UpdateAsync(lens, null, "1", null, new WriteRequest { State = "Committed", Title = "Sign-in page" }, null, null, 3); + // Someone renames it afterwards. + items.Items[1]["fields"]!["System.Title"] = "Sign-in page v2"; + items.Items[1]["rev"] = (int)items.Items[1]["rev"]! + 1; + + var preview = await Audit.UndoAsync(lens, null, null); + var token = preview[(preview.LastIndexOf("confirm=", StringComparison.Ordinal) + 8)..].TrimEnd('.'); + var done = await Audit.UndoAsync(lens, null, token); + Assert.Contains("left as changed since by others: Title", done); + Assert.Equal("New", (string)items.Items[1]["fields"]!["System.State"]!); + Assert.Equal("Sign-in page v2", (string)items.Items[1]["fields"]!["System.Title"]!); + } + + [Fact] + public async Task Undo_of_a_comment_deletes_it() + { + await WorkQueries.CommentAsync(lens, null, 1, "Looks good"); + Assert.Contains("#1 Login page: comment 77 deleted", Audit.List(lens, null, 5)); + var preview = await Audit.UndoAsync(lens, null, null); + var token = preview[(preview.LastIndexOf("confirm=", StringComparison.Ordinal) + 8)..].TrimEnd('.'); + await Audit.UndoAsync(lens, null, token); + Assert.EndsWith("/workItems/1/comments/77", deletes.Single()); + } + + [Fact] + public async Task Health_lists_what_needs_tidying() + { + items.Items[4]["fields"]!["System.IterationPath"] = "Demo\\Sprint 1"; + wiql = [1, 4]; + var text = await BoardQueries.HealthAsync(lens, null, null, null, Today); + Assert.Contains("backlog health of Demo Team (Demo): 2 open", text); + Assert.Contains("left in a finished sprint (1):\n- #4 Bug [New] Error on save - Sprint 1", text.Replace("\r", "")); + Assert.Contains("no description", text); + Assert.DoesNotContain("no acceptance criteria", text); // #1 has criteria + } + + [Fact] + public async Task Release_notes_group_requirements_by_feature_and_list_fixed_bugs() + { + wiql = [1, 4]; + var text = (await BoardQueries.ReleaseNotesAsync(lens, null, null, null, null, null, null, Today)).Replace("\r", ""); + Assert.StartsWith("# Release notes - Sprint 2 (2026-10-05 - 2026-10-16)", text); + Assert.Contains("## New and changed\n\n**Portal**\n\n- Login page (#1)", text); + Assert.Contains("## Fixed\n\n- Error on save (#4)", text); + } + + [Fact] + public async Task A_recipe_is_saved_previewed_and_run() + { + var saved = Recipes.Save(lens, "Close Sprint", """ + {"description":"move what is left to the next sprint","steps":[ + {"query":"SELECT [System.Id] FROM WorkItems WHERE [System.IterationPath] = '{sprint}'","tags":"+carried over","comment":"Moved from {sprint}"}, + {"ids":"1","child":{"type":"Task","title":"Test {title}"}}]} + """); + Assert.Contains("saved recipe close-sprint: 2 steps, parameters sprint", saved); + Assert.Contains("- close-sprint (sprint): move what is left", Recipes.List(lens)); + await Assert.ThrowsAsync(() => Recipes.RunAsync(lens, null, "close-sprint", null, null, false)); + + var values = new Dictionary { ["sprint"] = "Demo\\Sprint 1" }; + var preview = await Recipes.RunAsync(lens, null, "close-sprint", values, null, false); + Assert.Contains("step 1: tags +carried over, comment -> 2 items", preview); + Assert.Contains("step 2: new Task 'Test {title}' under each -> 1 items", preview); + Assert.Empty(patches); + + var token = preview[(preview.LastIndexOf("confirm=", StringComparison.Ordinal) + 8)..].TrimEnd('.'); + var run = await Recipes.RunAsync(lens, null, "close-sprint", values, token, false); + Assert.Contains("step 1: updated 2 of 2", run); + Assert.Contains("step 2: created 1 Task item(s)", run); + Assert.Contains("Test Login page", items.Created.Single()); + Assert.Contains("carried over", (string)items.Items[4]["fields"]!["System.Tags"]!); + Assert.Equal("deleted recipe close-sprint", Recipes.Delete(lens, "close-sprint")); + } +} diff --git a/tests/AdoLens.Tests/SetupTests.cs b/tests/AdoLens.Tests/SetupTests.cs index c6145cf..a55bacc 100644 --- a/tests/AdoLens.Tests/SetupTests.cs +++ b/tests/AdoLens.Tests/SetupTests.cs @@ -287,8 +287,9 @@ public void Tool_definitions_do_not_grow_by_accident(string mode, int maxTokens) public void The_server_really_registers_the_tools_of_the_mode() { var names = RegisteredTools(new Settings { Mode = "po" }).Select(t => t.ProtocolTool.Name).Order().ToList(); - Assert.Equal(["ado_backlog", "ado_board", "ado_changes", "ado_comment", "ado_create", "ado_fields", "ado_link", "ado_my_day", "ado_query", "ado_settings", - "ado_sprint", "ado_status", "ado_trace", "ado_update", "ado_upgrade", "ado_wiki", "ado_wiki_write", "ado_work_item"], names); + Assert.Equal(["ado_backlog", "ado_board", "ado_changes", "ado_comment", "ado_create", "ado_fields", "ado_file", "ado_health", "ado_link", "ado_my_day", + "ado_query", "ado_recipe", "ado_release_notes", "ado_search", "ado_settings", "ado_sprint", "ado_status", "ado_trace", "ado_undo", "ado_update", + "ado_upgrade", "ado_wiki", "ado_wiki_write", "ado_work_item"], names); } [Fact]