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

Filter by extension

Filter by extension


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

<ItemGroup>
Expand Down
37 changes: 36 additions & 1 deletion src/AdoLens/Attachments.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ void From(string? html, string where)
return found;
}

/// <summary>At most this many files in one answer (file=all or a list of numbers).</summary>
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)";

/// <summary>
Expand All @@ -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=<name or number> reads one (documents as text, images and scans shown); save=true saves it to disk (any size).", []);
"\nfile=<name or number> 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<Attached>().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=<numbers> 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)
Expand Down
91 changes: 88 additions & 3 deletions src/AdoLens/Knowledge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,78 @@ static async Task<string> CodeAsync(AdoClient client, string project, string tex

sealed record FileRef(bool Tfvc, string Path, string? RepoId, string? RepoName, string Project);

/// <summary>File lists already read in this process: TFVC folder or Git repository → its file paths.</summary>
static readonly System.Collections.Concurrent.ConcurrentDictionary<string, List<string>> Listings = new(StringComparer.OrdinalIgnoreCase);

/// <summary>
/// Files whose path ends with <paramref name="path"/> (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.
/// </summary>
static async Task<(List<CodeHit> 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<CodeHit>();
string? hint = null;

// TFVC: the Metadata folders of the branches ($/Project/Trunk/Main/Metadata, $/Project/Release/Metadata, ...).
var metadata = new List<string>();
// 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<string> { start };
for (var depth = 0; depth < (start == "$/" ? 5 : 4) && level.Count > 0 && metadata.Count < 6; depth++)
{
var next = new List<string>();
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]}/<Model>/<Model>/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>/{path.Trim('/')}";
}
}
catch (AdoApiException) { }
return (hits.Take(20).ToList(), hint);
}

/// <summary>
/// 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).
Expand Down Expand Up @@ -176,12 +248,25 @@ sealed record FileRef(bool Tfvc, string Path, string? RepoId, string? RepoName,
}
var name = path[(path.LastIndexOf('/') + 1)..];
List<CodeHit> 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];
Expand Down
2 changes: 1 addition & 1 deletion src/AdoLens/McpTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,7 @@ public static Task<string> 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=<name or number> 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=<local path> attaches a file.")]
[Description("Files of a work item and screenshots pasted into it: the list; file=<name or number> 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=<local path> attaches a file.")]
public static async Task<IEnumerable<ModelContextProtocol.Protocol.ContentBlock>> AttachmentsTool(Lens lens, int id,
string? file = null,
[Description("PDF pages: 3 or 2-5.")] string? pages = null,
Expand Down
Loading