diff --git a/CHANGELOG.md b/CHANGELOG.md index d23f614..2857ebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.9.5 — 2026-10-26 + +- `ado_file` by name works without Code Search (not installed or failing): the file lists of the TFVC Metadata + folders of the branches (also in another project of the organization) and of the Git repositories are searched, and + the matches come as full paths ($/…/Main/…, $/…/Release/…) to call again with. Nothing found: an example full path. + `ADOLENS_NO_CODE_SEARCH=1` tries this way on purpose. +- `ado_attachments file=all`: every image of the item (pasted into its text or comments, or attached) in one answer; + `file="1;3"` several files by number (e.g. three screenshots all called image.png). + ## 0.9.4 — 2026-10-26 - `ado_trace`: a branch earlier in the environment flow than the one a change was merged into is not reported as diff --git a/README.md b/README.md index 39641c3..930911c 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ What each MCP tool answers, when Claude should reach for it, and the CLI command | `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_tests` | the tests of work items (ids) or of a sprint's requirements and bugs: their Test Cases with the latest outcome per plan, suite and configuration (who, when), items without tests; `detail=full` adds the steps | "is it tested", sprint review | `adolens tests 123`, `adolens tests` (current sprint) | | `ado_test_create` | Test Cases for a work item from markdown (`## title`, steps `1. action => expected`), linked as tested-by, in the item's area and iteration; `plan` + `suite` (id or name) add them to a suite | turning acceptance criteria into tests | `adolens test-create 123 --file cases.md --plan R1 --suite Login` | -| `ado_attachments` | the files of a work item and the screenshots pasted into its text or comments; `file` gives the text of a PDF (`pages=2-5`; scanned pages shown as images), Word, Excel, PowerPoint, e-mail (.msg, .eml), ZIP (list; `part=` one file inside) or text file, or shows an image - the type is read from the bytes, binaries are never sent as text, long documents come as an outline and the start (`detail=full` for all); `save=true` streams the file to disk at any size and returns only its path, size and SHA-256; files over 20 MB or not readable get a link and the hint to save them; `add` attaches a local file | specifications, e-mails and screenshots of items; traces and logs for other tools | `adolens attachments 123 --file 2 --pages 1-3`, `adolens attachments 123 --file 2 --save` | +| `ado_attachments` | the files of a work item and the screenshots pasted into its text or comments; `file=all` shows every image at once, `file="1;3"` several; `file` gives the text of a PDF (`pages=2-5`; scanned pages shown as images), Word, Excel, PowerPoint, e-mail (.msg, .eml), ZIP (list; `part=` one file inside) or text file, or shows an image - the type is read from the bytes, binaries are never sent as text, long documents come as an outline and the start (`detail=full` for all); `save=true` streams the file to disk at any size and returns only its path, size and SHA-256; files over 20 MB or not readable get a link and the hint to save them; `add` attaches a local file | specifications, e-mails and screenshots of items; traces and logs for other tools | `adolens attachments 123 --file 2 --pages 1-3`, `adolens attachments 123 --file 2 --save` | | `ado_create_tree` | a tree of work items from an indented list (`- Feature: …`, children indented, `\| description`, `@person`), under an existing item if given; previewed first, one undo removes it all | breaking a feature into requirements and tasks | — | | `ado_metrics` | done items and estimate per finished sprint (velocity), cycle and lead time (median, 85%), what is in progress longest | "are we getting faster", planning | `adolens metrics --sprints 6` | | `ado_label` | a D365 message pasted as seen (values in place of %1 %2 … are fine) or `@File:Id`: the label, its text in every language (missing ones flagged) and the objects, X++ methods, fields and controls using it | "where does this error come from" | `adolens label "Item 1234: received 12, on hand 5"` | diff --git a/README.pl.md b/README.pl.md index 9938254..1352bc7 100644 --- a/README.pl.md +++ b/README.pl.md @@ -226,7 +226,7 @@ Na co odpowiada każde narzędzie MCP, kiedy Claude powinien po nie sięgnąć i | `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_tests` | testy work itemów (id) albo wymagań i błędów sprintu: ich Test Case'y z ostatnim wynikiem w każdym planie, zestawie i konfiguracji (kto, kiedy), elementy bez testów; `detail=full` dodaje kroki | „czy to przetestowane”, przegląd sprintu | `adolens tests 123`, `adolens tests` (bieżący sprint) | | `ado_test_create` | Test Case'y dla work itemu z markdownu (`## tytuł`, kroki `1. akcja => oczekiwany wynik`), podpięte jako tested-by, w obszarze i iteracji elementu; `plan` + `suite` (id albo nazwa) dodają je do zestawu | kryteria akceptacji zamienione w testy | `adolens test-create 123 --file przypadki.md --plan R1 --suite Logowanie` | -| `ado_attachments` | pliki work itemu i zrzuty ekranu wklejone w jego treść lub komentarze; `file` daje tekst PDF-a (`pages=2-5`; zeskanowane strony pokazane jako obrazy), Worda, Excela, PowerPointa, maila (.msg, .eml), ZIP-a (lista; `part=` jeden plik ze środka) lub pliku tekstowego, albo pokazuje obraz - typ jest rozpoznawany po bajtach, pliki binarne nigdy nie idą jako tekst, długie dokumenty przychodzą jako spis treści i początek (`detail=full` całość); `save=true` zapisuje plik na dysk strumieniowo w dowolnym rozmiarze i zwraca tylko ścieżkę, rozmiar i SHA-256; pliki ponad 20 MB albo nieczytelne dostają link i podpowiedź zapisu; `add` dołącza plik z dysku | specyfikacje, maile i zrzuty przy elementach; trace'y i logi dla innych narzędzi | `adolens attachments 123 --file 2 --pages 1-3`, `adolens attachments 123 --file 2 --save` | +| `ado_attachments` | pliki work itemu i zrzuty ekranu wklejone w jego treść lub komentarze; `file=all` pokazuje od razu wszystkie obrazy, `file="1;3"` kilka; `file` daje tekst PDF-a (`pages=2-5`; zeskanowane strony pokazane jako obrazy), Worda, Excela, PowerPointa, maila (.msg, .eml), ZIP-a (lista; `part=` jeden plik ze środka) lub pliku tekstowego, albo pokazuje obraz - typ jest rozpoznawany po bajtach, pliki binarne nigdy nie idą jako tekst, długie dokumenty przychodzą jako spis treści i początek (`detail=full` całość); `save=true` zapisuje plik na dysk strumieniowo w dowolnym rozmiarze i zwraca tylko ścieżkę, rozmiar i SHA-256; pliki ponad 20 MB albo nieczytelne dostają link i podpowiedź zapisu; `add` dołącza plik z dysku | specyfikacje, maile i zrzuty przy elementach; trace'y i logi dla innych narzędzi | `adolens attachments 123 --file 2 --pages 1-3`, `adolens attachments 123 --file 2 --save` | | `ado_create_tree` | drzewo work itemów z listy z wcięciami (`- Feature: …`, dzieci wcięte, `\| opis`, `@osoba`), opcjonalnie pod istniejącym elementem; najpierw podgląd, jedno cofnięcie usuwa całość | rozbicie feature'a na wymagania i taski | — | | `ado_metrics` | zrobione elementy i szacunek w zakończonych sprintach (prędkość), czas realizacji (mediana, 85%), co najdłużej w toku | „czy przyspieszamy”, planowanie | `adolens metrics --sprints 6` | | `ado_label` | komunikat D365 wklejony tak, jak widać na ekranie (z wartościami zamiast %1 %2 …) albo `@Plik:Id`: etykieta, jej tekst w każdym języku (brakujące oznaczone) i obiekty, metody X++, pola i kontrolki, które jej używają | „skąd ten błąd” | `adolens label "Towar 1234: przyjęto 12, stan 5"` | diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index fecfc88..3eb1e49 100644 --- a/src/AdoLens/AdoLens.csproj +++ b/src/AdoLens/AdoLens.csproj @@ -23,7 +23,7 @@ ADO Lens for Azure DevOps Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP Copyright © 2026 WillowDev - 0.9.4 + 0.9.5 diff --git a/src/AdoLens/Attachments.cs b/src/AdoLens/Attachments.cs index 04ed2cb..4a3cb45 100644 --- a/src/AdoLens/Attachments.cs +++ b/src/AdoLens/Attachments.cs @@ -52,6 +52,13 @@ void From(string? html, string where) return found; } + /// At most this many files in one answer (file=all or a list of numbers). + const int MaxMany = 8; + + static bool IsImage(Attached a) => + a.Comment?.StartsWith("pasted", StringComparison.Ordinal) == true + || Path.GetExtension(a.Name).ToLowerInvariant() is ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".webp"; + static string Size(long? bytes) => bytes is not { } b ? "" : b < 1024 ? $" ({b} B)" : b < 1024 * 1024 ? $" ({b / 1024} KB)" : $" ({b / 1024.0 / 1024:0.#} MB)"; /// @@ -71,7 +78,35 @@ void From(string? html, string where) if (list.Count == 0) return ($"{head}: no attachments and no pasted images", []); if (string.IsNullOrWhiteSpace(file)) return ($"{head}: {list.Count} file(s)\n" + string.Join('\n', list.Select((a, i) => $"{i + 1}. {a.Name}{Size(a.Size)}{(a.Comment is { Length: > 0 } c ? $" - {c}" : "")}")) + - "\nfile= reads one (documents as text, images and scans shown); save=true saves it to disk (any size).", []); + "\nfile= reads one (documents as text, images and scans shown), file=all shows every image, file=\"1;3\" several; save=true saves it to disk (any size).", []); + + // Several at once: file=all (every image, pasted or attached) or numbers "1;3;4". + var many = file.Trim().ToLowerInvariant() is "all" or "images" ? list.Where(IsImage).ToList() + : file.Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) is { Length: > 1 } numbers + ? numbers.Select(x => int.TryParse(x, out var k) && k >= 1 && k <= list.Count ? list[k - 1] : null).OfType().ToList() + : null; + if (many is not null) + { + if (many.Count == 0) return ($"{head}: no images among its {list.Count} file(s)", []); + var shown = new StringBuilder($"{head}: {Math.Min(many.Count, MaxMany)} of {many.Count} file(s)"); + var images = new List<(byte[] Bytes, string Mime)>(); + foreach (var a in many.Take(MaxMany)) + { + var url = a.Url + (a.Url.Contains('?') ? "&" : "?") + "download=true"; + try + { + var data = await session.Client.GetBytesAsync(url, MaxDownload, ct); + var read = Documents.Read(a.Name, data, pages, part); + images.AddRange(read.Images); + shown.Append($"\n- {a.Name} ({Documents.Size(data.Length)}{(a.Comment is { Length: > 0 } c ? $", {c}" : "")})") + .Append(read.Images.Count > 0 ? $": {read.Images.Count} image(s)" : "") + .Append(read.Text is { Length: > 0 } t ? "\n" + Documents.Fit(t, MaxText / MaxMany, false) : ""); + } + catch (AdoApiException ex) { shown.Append($"\n- {a.Name}: not read ({Formatting.Collapse(ex.Message, 80)})"); } + } + if (many.Count > MaxMany) shown.Append($"\n… {many.Count - MaxMany} more: file= for the rest"); + return (shown.ToString(), images); + } var same = list.Select((a, i) => (a, i)).Where(x => x.a.Name.Equals(file.Trim(), StringComparison.OrdinalIgnoreCase)).ToList(); if (same.Count > 1) diff --git a/src/AdoLens/Knowledge.cs b/src/AdoLens/Knowledge.cs index baa2338..4710478 100644 --- a/src/AdoLens/Knowledge.cs +++ b/src/AdoLens/Knowledge.cs @@ -143,6 +143,78 @@ static async Task CodeAsync(AdoClient client, string project, string tex sealed record FileRef(bool Tfvc, string Path, string? RepoId, string? RepoName, string Project); + /// File lists already read in this process: TFVC folder or Git repository → its file paths. + static readonly System.Collections.Concurrent.ConcurrentDictionary> Listings = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Files whose path ends with (a name with or without ".xml"), from the file lists of the + /// TFVC Metadata folders (found under $/project, a few levels deep) and of up to five Git repositories; and an + /// example full path to suggest when nothing matches. + /// + static async Task<(List Hits, string? Hint)> ListingAsync(Session session, string project, string path, string? repo, CancellationToken ct) + { + var client = session.Client; + var tail = "/" + path.TrimStart('/'); + bool Match(string p) => p.EndsWith(tail, StringComparison.OrdinalIgnoreCase) || p.EndsWith(tail + ".xml", StringComparison.OrdinalIgnoreCase); + var hits = new List(); + string? hint = null; + + // TFVC: the Metadata folders of the branches ($/Project/Trunk/Main/Metadata, $/Project/Release/Metadata, ...). + var metadata = new List(); + // The project's own TFVC first; code kept in another project of the organization is found from $/. + foreach (var start in new[] { $"$/{project}", "$/" }) + { + if (metadata.Count > 0) break; + try + { + var level = new List { start }; + for (var depth = 0; depth < (start == "$/" ? 5 : 4) && level.Count > 0 && metadata.Count < 6; depth++) + { + var next = new List(); + foreach (var folder in level.Take(15)) + { + var children = (await client.GetListAsync($"_apis/tfvc/items?scopePath={Uri.EscapeDataString(folder)}&recursionLevel=OneLevel", ct)) + .Where(i => Json.Bool(i, "isFolder")).Select(i => Json.Str(i, "path") ?? "").Where(p => p.Length > 0 && !p.Equals(folder, StringComparison.OrdinalIgnoreCase)).ToList(); + foreach (var c in children) + if (c.EndsWith("/Metadata", StringComparison.OrdinalIgnoreCase)) metadata.Add(c); + else next.Add(c); + } + level = next; + } + } + catch (AdoApiException) { } + } + foreach (var root in metadata.Take(6)) + { + try + { + if (!Listings.TryGetValue(root, out var files)) + Listings[root] = files = (await client.GetListAsync($"_apis/tfvc/items?scopePath={Uri.EscapeDataString(root)}&recursionLevel=Full", ct)) + .Where(i => !Json.Bool(i, "isFolder")).Select(i => Json.Str(i, "path") ?? "").ToList(); + hits.AddRange(files.Where(Match).Select(p => new CodeHit("", p, null, 1, true))); + } + catch (AdoApiException) { } + } + if (metadata.Count > 0) hint = $"{metadata[0]}///AxClass/{path.Trim('/')}{(path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ? "" : ".xml")}"; + + // Git: the repositories of the project (or the one given). + try + { + var repos = repo is not null ? [Json.Str(await client.GetAsync($"{AdoClient.Scope(project)}_apis/git/repositories/{Uri.EscapeDataString(repo)}", ct), "name") ?? repo] + : (await client.GetListAsync($"{AdoClient.Scope(project)}_apis/git/repositories", ct)).Select(r => Json.Str(r, "name") ?? "").Where(n => n.Length > 0).Take(5).ToList(); + foreach (var r in repos) + { + if (!Listings.TryGetValue($"git:{project}/{r}", out var files)) + Listings[$"git:{project}/{r}"] = files = (await client.GetListAsync($"{AdoClient.Scope(project)}_apis/git/repositories/{Uri.EscapeDataString(r)}/items?scopePath=/&recursionLevel=Full", ct)) + .Where(i => !Json.Bool(i, "isFolder")).Select(i => Json.Str(i, "path") ?? "").ToList(); + hits.AddRange(files.Where(Match).Select(p => new CodeHit(r, p, null, 1, false))); + hint ??= $"repo={r} path=//{path.Trim('/')}"; + } + } + catch (AdoApiException) { } + return (hits.Take(20).ToList(), hint); + } + /// /// 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). @@ -176,12 +248,25 @@ sealed record FileRef(bool Tfvc, string Path, string? RepoId, string? RepoName, } var name = path[(path.LastIndexOf('/') + 1)..]; List hits; - try { (hits, _) = await CodeHitsAsync(session.Client, project, $"file:{name}", 20, repo, ct); } + try + { + // ADOLENS_NO_CODE_SEARCH=1 tries the way without Code Search (diagnostics). + if (Environment.GetEnvironmentVariable("ADOLENS_NO_CODE_SEARCH") == "1") throw new AdoApiException("Code Search turned off by ADOLENS_NO_CODE_SEARCH"); + (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})"); + // Without Code Search (not installed, or failing): the file lists of the TFVC Metadata folders and of the + // Git repositories, read once per session. + var (listed, hint) = await ListingAsync(session, project, path, repo, ct); + if (listed.Count == 0) + throw new InvalidOperationException($"Code Search failed ({Formatting.Collapse(ex.Message, 120)}) and no file '{path}' is in the file lists either; " + + $"give the full path{(hint is null ? " ($/… for TFVC, or the path with repo= for Git)" : $", e.g. {hint}")}"); + hits = listed; } - var matching = hits.Where(h => h.Path.EndsWith(path.StartsWith('/') ? path : "/" + path, StringComparison.OrdinalIgnoreCase)).ToList(); + var wanted = path.StartsWith('/') ? path : "/" + path; + // An X++ object may be named without ".xml". + var matching = hits.Where(h => h.Path.EndsWith(wanted, StringComparison.OrdinalIgnoreCase) || h.Path.EndsWith(wanted + ".xml", StringComparison.OrdinalIgnoreCase)).ToList(); if (matching.Count == 1) { var h = matching[0]; diff --git a/src/AdoLens/McpTools.cs b/src/AdoLens/McpTools.cs index 775303d..f94d7b4 100644 --- a/src/AdoLens/McpTools.cs +++ b/src/AdoLens/McpTools.cs @@ -583,7 +583,7 @@ public static Task CreateTree(Lens lens, string items, () => WorkTree.CreateAsync(lens, connection, project, parent, items, iteration, area, confirm, ct)); [McpServerTool(Name = "ado_attachments"), Toolset(Toolsets.Code)] - [Description("Files of a work item and screenshots pasted into it: the list; file= gives a document's text (PDF, Word, Excel, PowerPoint, e-mail, ZIP list, text) or shows images and scanned pages; save=true streams it to disk (any size) and returns the path; add= attaches a file.")] + [Description("Files of a work item and screenshots pasted into it: the list; file= gives a document's text (PDF, Word, Excel, PowerPoint, e-mail, ZIP list, text) or shows images and scanned pages, file=all every image at once, file=\"1;3\" several; save=true streams it to disk (any size) and returns the path; add= attaches a file.")] public static async Task> AttachmentsTool(Lens lens, int id, string? file = null, [Description("PDF pages: 3 or 2-5.")] string? pages = null, diff --git a/tests/AdoLens.Tests/AttachmentTests.cs b/tests/AdoLens.Tests/AttachmentTests.cs index 3b6091c..c66025c 100644 --- a/tests/AdoLens.Tests/AttachmentTests.cs +++ b/tests/AdoLens.Tests/AttachmentTests.cs @@ -17,6 +17,46 @@ static byte[] Zip(params (string Path, string Content)[] parts) return ms.ToArray(); } + [Fact] + public async Task All_images_of_an_item_come_in_one_answer() + { + var items = new DemoItems(); + var rel = (string id) => new System.Text.Json.Nodes.JsonObject + { + ["rel"] = "AttachedFile", + ["url"] = $"https://dev.azure.com/contoso/_apis/wit/attachments/{id}?fileName=image.png", + ["attributes"] = new System.Text.Json.Nodes.JsonObject { ["name"] = "image.png", ["resourceSize"] = 9 }, + }; + items.Items[1]["relations"] = new System.Text.Json.Nodes.JsonArray(rel("a1"), rel("a2"), new System.Text.Json.Nodes.JsonObject + { + ["rel"] = "AttachedFile", ["url"] = "https://dev.azure.com/contoso/_apis/wit/attachments/a3?fileName=spec.txt", + ["attributes"] = new System.Text.Json.Nodes.JsonObject { ["name"] = "spec.txt", ["resourceSize"] = 4 }, + }); + byte[] png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0]; + var ado = items.Routes(new DemoProject().Routes(new FakeAdo())) + .On("/contoso/_apis/wit/attachments/", _ => new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new ByteArrayContent(png) }); + var dir = Path.Combine(Path.GetTempPath(), $"adolens-att-{Guid.NewGuid():N}"); + try + { + var s = new Settings { DefaultConnection = "contoso" }; + s.Connections["contoso"] = new Connection { Organization = "contoso", Project = "Demo" }; + var lens = new Lens(s, "0.0.0", dir, _ => null, _ => "pat", _ => ado.Client()); + + var (text, images) = await Attachments.ReadAsync(lens, null, 1, "all", null); + Assert.Contains("2 of 2 file(s)", text); + Assert.Equal(2, images.Count); + + // Two files with one name: a list of numbers takes both. + var (_, picked) = await Attachments.ReadAsync(lens, null, 1, "1;2", null); + Assert.Equal(2, picked.Count); + } + finally + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + } + [Fact] public void Word_documents_become_markdown_like_text() {