From 74a3b0cea0886e2d806215f63d7550ad9afe0b03 Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:14:28 +0200 Subject: [PATCH 1/6] Trace work to code and deployments - ado_trace: acceptance criteria (from the parent for a task) and whether they changed after coding started; changesets, commits and PRs of the item and its tasks with their X++ objects; builds (also deleted ones); every release stage with YES / NOT YET across the projects the code lives in; Git: merged into each mapped branch; tests, tasks, signals - ado_changes: files of a changeset, PR or commit with X++ methods per object; one file's diff (AOT XML per method plus metadata) - environments per connection (config --env staging=UAT) - skill po-verify; skills are part of the package - shorter server instructions; the size check measures the real tool JSON --- CHANGELOG.md | 16 ++ README.md | 19 +- README.pl.md | 19 +- pack.ps1 | 2 + skills/po-verify/SKILL.md | 46 +++++ src/AdoLens/Cli.cs | 29 ++- src/AdoLens/Code.cs | 224 +++++++++++++++++++++ src/AdoLens/CommandLine.cs | 4 +- src/AdoLens/Diff.cs | 237 ++++++++++++++++++++++ src/AdoLens/Lens.cs | 2 + src/AdoLens/McpTools.cs | 46 +++-- src/AdoLens/Program.cs | 4 + src/AdoLens/Settings.cs | 16 +- src/AdoLens/Trace.cs | 324 ++++++++++++++++++++++++++++++ tests/AdoLens.Tests/SetupTests.cs | 40 ++-- tests/AdoLens.Tests/TraceTests.cs | 192 ++++++++++++++++++ 16 files changed, 1174 insertions(+), 46 deletions(-) create mode 100644 skills/po-verify/SKILL.md create mode 100644 src/AdoLens/Code.cs create mode 100644 src/AdoLens/Diff.cs create mode 100644 src/AdoLens/Trace.cs create mode 100644 tests/AdoLens.Tests/TraceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a88c93..eec452a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.2.0 — unreleased + +Checking work against what was asked, and where it is. + +- `ado_trace` (tool set `trace`): the acceptance criteria (from the parent for a task) and whether they changed after + coding started; changesets, commits and pull requests linked to the item and its tasks, with the X++ objects they + touch; builds, also those deleted by retention; every release stage of the code's branches with YES / NOT YET, in all + projects the code lives in; for Git, whether the change is merged into each mapped branch; tests, tasks, signals. +- `ado_changes`: files of a changeset, pull request or commit with added / changed / removed X++ methods per object; + the diff of one file — AOT XML per method plus the metadata without raw XML noise; other files as unified diffs. +- Environments per connection: `adolens config --connection --env staging=UAT` maps branches to environment names. +- Skill `po-verify` (`skills/po-verify`): guides Claude through checking a work item for a Product Owner. +- Server instructions keep only advice the tool descriptions do not give (fewer tokens per session); the tool size + check measures the JSON Claude really receives and only guards against accidental growth. +- CLI: `trace`, `changes`. + ## 0.1.0 — 2026-10-14 First release: work items, sprints, boards, backlogs and the wiki for Claude Desktop and Claude Code. diff --git a/README.md b/README.md index d6526b0..4239a62 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ not thousands. Changes between versions: [CHANGELOG.md](CHANGELOG.md). The road Azure DevOps is a trademark of Microsoft. > Status: **0.x — in development.** Connections, Claude registration, the local work item structure, work items, -> sprints, boards, backlogs and the wiki work; tracing work to code, pull requests and pipelines follow the plan. +> sprints, boards, backlogs, the wiki and tracing work to code and deployments work; pull requests, pipelines and +> the installer follow the plan. ## What it does differently @@ -92,8 +93,14 @@ adolens config --add-toolset pipelines # one more tool set on top of the m adolens config --confirm-above 3 # updates of more items are previewed first (0: always) adolens config --usage-log false adolens config --show-tokens true # end every answer with [~N tokens] +adolens config --connection contoso --env staging=UAT --env master=PROD # what branches mean (for ado_trace) ``` +**Environments.** Azure DevOps does not know that the branch `staging` means UAT. Map branches to environment names per +connection (`--env branch=label`, `--remove-env branch`; for TFVC use the last part of the branch path, e.g. `Main`). +`ado_trace` then says "UAT: YES" instead of only naming the release stage, and for Git also checks whether the change is +merged into each mapped branch even when no release pipeline deploys it. + Settings live in `%LOCALAPPDATA%\ado-lens\adolens.json` (no secrets) — they can be edited by hand too. ### Modes and tool sets @@ -107,7 +114,8 @@ never a permission. | `work` | `ado_work_item`, `ado_query`, `ado_create`, `ado_update`, `ado_comment`, `ado_link` | ✓ | ✓ | ✓ | | `boards` | `ado_sprint`, `ado_board`, `ado_backlog`, `ado_my_day` | ✓ | – | ✓ | | `wiki` | `ado_wiki`, `ado_wiki_write` | ✓ | ✓ | ✓ | -| `trace`, `test`, `recipes` | planned | ✓ | (trace, recipes) | ✓ | +| `trace` | `ado_trace`, `ado_changes` | ✓ | ✓ | ✓ | +| `test`, `recipes` | planned | ✓ | (recipes) | ✓ | | `code`, `pr`, `pipelines` | planned | – | ✓ | ✓ | ## Tool map @@ -127,6 +135,8 @@ What each MCP tool answers, when Claude should reach for it, and the CLI command | `ado_sprint` | a team's sprint (`current`, `next`, `previous` or by name): dates and working day, items by state, estimates, remaining work against the capacity left, items needing attention (no change for days, blocked, no estimate, no criteria, unassigned), task progress per item; says plainly when the sprint has ended | stand-up, sprint review, "will we make it" | `adolens sprint`, `adolens sprint next --team "Demo Team" --detail summary` | | `ado_board` | a team's board: columns with WIP limits (over-limit flagged) and their cards; `move` + `column` moves a card — the state follows the column's mapping for the item's type | "what is in progress", moving cards | `adolens board`, `adolens board --move 123 --column "In Progress"` | | `ado_backlog` | a team's backlog in priority order (requirement, portfolio, task, or by name); `move` + `above`/`below` reorders | refinement, priorities | `adolens backlog --top 20`, `adolens backlog --move 123 --above 456` | +| `ado_trace` | was it done as asked, and where is it: the acceptance criteria (from the parent for a task) and whether they changed after coding started; changesets, commits and pull requests linked to the item and its tasks with the X++ objects they touch; builds (also those deleted by retention); every release stage of the code's branches with **YES / NOT YET**, labelled by the environment map (`staging` → UAT); for Git, whether the change is merged into each mapped branch; tests, tasks and warning signals | "did they do what I asked", "is it on UAT yet" | `adolens trace 123` | +| `ado_changes` | the files of a changeset (`523`), pull request (`PR 482`) or commit (sha + `repo`) with X++ objects and their added / changed / removed methods; `file` gives the diff of one file — for AOT XML per method plus the metadata (fields, controls, properties) without raw XML noise | reading what exactly changed | `adolens changes 523`, `adolens changes 523 --file AxClass/SalesTable_Extension.xml` | | `ado_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` | @@ -158,7 +168,7 @@ Estimates are characters / 4; Claude's tokenizer differs a little (Polish text r ## CLI ``` -adolens item|query|create|update|comment|link|fields|sprint|board|backlog|myday|wiki|wiki-write … +adolens item|query|create|update|comment|link|fields|sprint|board|backlog|myday|wiki|wiki-write|trace|changes … adolens login|logout|connections|config|status|register|unregister|refresh|stats|api|mcp|version ``` @@ -221,6 +231,7 @@ regular expression per line, `!word` for allowed words. Without a list the relea | `SetupTests.cs` | settings, connections and PAT order (Credential Manager first), command line, Claude configuration edits, Credential Manager, HTTP retries and errors, tool definition budget per mode | | `MetadataTests.cs` | fetching the structure (levels, inherited process name, bugs on the requirement backlog), change history, compact `ado_fields` answers | | `BoardTests.cs` | sprint progress, capacity left with days off, attention list, picking sprints, ended sprints, board columns and WIP, moving cards with the state mapping, backlog order and reordering, my day | +| `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 | | `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 | @@ -235,6 +246,8 @@ issues or commits. | work item reads, writes, checks | `WorkItems.cs`, `WorkQueries.cs`; output in `WorkItemFormat.cs` | | sprints, capacity, boards, backlogs, my day | `Boards.cs` (API in `Boards`, answers in `BoardQueries`) | | wiki | `Wiki.cs` | +| changesets, commits, PRs, builds, deployments | `Code.cs`; tracing in `Trace.cs`; diffs and X++ per method in `Diff.cs` | +| skills for Claude | `skills\\SKILL.md` | | work item structure (types, fields, states) | `MetadataService.cs` (fetch), `MetaStore.cs` (SQLite; raise `SchemaVersion` to refetch), `Metadata.cs` (change detection) | | rich text | `Html.cs` | | people | `Identities.cs` | diff --git a/README.pl.md b/README.pl.md index 32b35ea..9d455a2 100644 --- a/README.pl.md +++ b/README.pl.md @@ -13,7 +13,8 @@ a nie tysiące. Zmiany między wersjami: [CHANGELOG.md](CHANGELOG.md). Plan rozw Azure DevOps jest znakiem towarowym Microsoftu. > Stan: **0.x — w budowie.** Działają połączenia, rejestracja w Claude, lokalna struktura work itemów, work itemy, -> sprinty, tablice, backlogi i wiki; śledzenie pracy do kodu, pull requesty i pipeline'y — według planu. +> sprinty, tablice, backlogi, wiki oraz śledzenie pracy do kodu i wdrożeń; pull requesty, pipeline'y i instalator — +> według planu. ## Czym się różni @@ -92,8 +93,14 @@ adolens config --add-toolset pipelines # dodatkowy zestaw narzędzi ponad adolens config --confirm-above 3 # zmiany większej liczby elementów najpierw jako podgląd (0: zawsze) adolens config --usage-log false adolens config --show-tokens true # każda odpowiedź kończy się [~N tokens] +adolens config --connection contoso --env staging=UAT --env master=PROD # co znaczą gałęzie (dla ado_trace) ``` +**Środowiska.** Azure DevOps nie wie, że gałąź `staging` to UAT. Zmapuj gałęzie na nazwy środowisk dla połączenia +(`--env gałąź=etykieta`, `--remove-env gałąź`; dla TFVC użyj ostatniej części ścieżki gałęzi, np. `Main`). Wtedy +`ado_trace` mówi „UAT: TAK” zamiast samej nazwy etapu release'u, a dla Gita sprawdza też, czy zmiana jest scalona do +każdej zmapowanej gałęzi, nawet gdy żaden pipeline jej nie wdraża. + Ustawienia siedzą w `%LOCALAPPDATA%\ado-lens\adolens.json` (bez sekretów) — można je też edytować ręcznie. ### Tryby i zestawy narzędzi @@ -107,7 +114,8 @@ Nigdy nie jest uprawnieniem. | `work` | `ado_work_item`, `ado_query`, `ado_create`, `ado_update`, `ado_comment`, `ado_link` | ✓ | ✓ | ✓ | | `boards` | `ado_sprint`, `ado_board`, `ado_backlog`, `ado_my_day` | ✓ | – | ✓ | | `wiki` | `ado_wiki`, `ado_wiki_write` | ✓ | ✓ | ✓ | -| `trace`, `test`, `recipes` | w planie | ✓ | (trace, recipes) | ✓ | +| `trace` | `ado_trace`, `ado_changes` | ✓ | ✓ | ✓ | +| `test`, `recipes` | w planie | ✓ | (recipes) | ✓ | | `code`, `pr`, `pipelines` | w planie | – | ✓ | ✓ | ## Mapa narzędzi @@ -127,6 +135,8 @@ Na co odpowiada każde narzędzie MCP, kiedy Claude powinien po nie sięgnąć i | `ado_sprint` | sprint zespołu (`current`, `next`, `previous` albo po nazwie): daty i dzień roboczy, elementy według stanu, szacunki, pozostała praca wobec pozostałej pojemności, elementy do uwagi (bez zmian od kilku dni, zablokowane, bez szacunku, bez kryteriów, bez osoby), postęp zadań per element; mówi wprost, gdy sprint się skończył | daily, przegląd sprintu, „czy zdążymy” | `adolens sprint`, `adolens sprint next --team "Demo Team" --detail summary` | | `ado_board` | tablica zespołu: kolumny z limitami WIP (przekroczenia oznaczone) i ich karty; `move` + `column` przesuwa kartę — stan wynika z mapowania kolumny dla typu elementu | „co jest w toku”, przesuwanie kart | `adolens board`, `adolens board --move 123 --column "In Progress"` | | `ado_backlog` | backlog zespołu w kolejności priorytetu (wymagania, portfolio, zadania albo po nazwie); `move` + `above`/`below` zmienia kolejność | refinement, priorytety | `adolens backlog --top 20`, `adolens backlog --move 123 --above 456` | +| `ado_trace` | czy zrobiono to, o co proszono, i gdzie to jest: kryteria akceptacji (dla zadania — z elementu nadrzędnego) i to, czy zmieniły się po rozpoczęciu prac; changesety, commity i pull requesty podpięte pod element i jego zadania, z obiektami X++, których dotyczą; buildy (także usunięte przez retencję); każdy etap release'u gałęzi tego kodu z **TAK / JESZCZE NIE** i etykietą z mapy środowisk (`staging` → UAT); dla Gita — czy zmiana jest scalona do każdej zmapowanej gałęzi; testy, zadania i sygnały ostrzegawcze | „czy zrobili to, o co prosiłem”, „czy to już jest na UAT” | `adolens trace 123` | +| `ado_changes` | pliki changesetu (`523`), pull requesta (`PR 482`) albo commita (sha + `repo`) z obiektami X++ i ich dodanymi / zmienionymi / usuniętymi metodami; `file` daje diff jednego pliku — dla AOT XML per metoda plus metadane (pola, kontrolki, właściwości) bez szumu surowego XML-a | czytanie, co dokładnie się zmieniło | `adolens changes 523`, `adolens changes 523 --file AxClass/SalesTable_Extension.xml` | | `ado_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` | @@ -158,7 +168,7 @@ Szacunek to znaki / 4; tokenizer Claude'a liczy trochę inaczej (polski tekst wy ## CLI ``` -adolens item|query|create|update|comment|link|fields|sprint|board|backlog|myday|wiki|wiki-write … +adolens item|query|create|update|comment|link|fields|sprint|board|backlog|myday|wiki|wiki-write|trace|changes … adolens login|logout|connections|config|status|register|unregister|refresh|stats|api|mcp|version ``` @@ -221,6 +231,7 @@ i publikuje go razem z `.sha256` i opisem z `CHANGELOG.md` (podgląd: `.\release | `SetupTests.cs` | ustawienia, połączenia i kolejność PAT (najpierw Menedżer poświadczeń), linia poleceń, edycja konfiguracji Claude'a, Menedżer poświadczeń, ponawianie i błędy HTTP, budżet definicji narzędzi na tryb, faktyczna rejestracja narzędzi | | `MetadataTests.cs` | pobieranie struktury (poziomy, nazwa procesu dziedziczonego, bugi na backlogu wymagań), historia zmian, zwięzłe odpowiedzi `ado_fields` | | `BoardTests.cs` | postęp sprintu, pozostała pojemność z dniami wolnymi, lista do uwagi, wybór sprintu, zakończone sprinty, kolumny tablicy i WIP, przesuwanie kart z mapowaniem stanów, kolejność backlogu i jej zmiana, mój dzień | +| `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` | | `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 | @@ -235,6 +246,8 @@ organizacji do testów, zgłoszeń ani commitów. | odczyt, zapis i kontrole work itemów | `WorkItems.cs`, `WorkQueries.cs`; wyjście w `WorkItemFormat.cs` | | sprinty, pojemność, tablice, backlogi, mój dzień | `Boards.cs` (API w `Boards`, odpowiedzi w `BoardQueries`) | | wiki | `Wiki.cs` | +| changesety, commity, PR-y, buildy, wdrożenia | `Code.cs`; śledzenie w `Trace.cs`; diffy i X++ per metoda w `Diff.cs` | +| skille dla Claude'a | `skills\\SKILL.md` | | struktura work itemów (typy, pola, stany) | `MetadataService.cs` (pobieranie), `MetaStore.cs` (SQLite; podnieś `SchemaVersion`, żeby pobrać od nowa), `Metadata.cs` (wykrywanie zmian) | | tekst sformatowany | `Html.cs` | | osoby | `Identities.cs` | diff --git a/pack.ps1 b/pack.ps1 index 0bc66ae..1fb46c7 100644 --- a/pack.ps1 +++ b/pack.ps1 @@ -40,6 +40,8 @@ Remove-Item (Join-Path $stage 'bin\*.pdb') -Force -ErrorAction SilentlyContinue foreach ($f in 'install.ps1', 'uninstall.ps1', 'README.md', 'README.pl.md', 'CHANGELOG.md', 'SECURITY.md', 'LICENSE') { Copy-Item (Join-Path $PSScriptRoot $f) $stage -Force } +# Skills for Claude (SKILL.md per folder), installed next to the server. +Copy-Item (Join-Path $PSScriptRoot 'skills') $stage -Recurse -Force $size = [math]::Round((Get-ChildItem $stage -Recurse -File | Measure-Object Length -Sum).Sum / 1MB, 1) Write-Host "package: $stage ($size MB)" -ForegroundColor Cyan diff --git a/skills/po-verify/SKILL.md b/skills/po-verify/SKILL.md new file mode 100644 index 0000000..b02f9d1 --- /dev/null +++ b/skills/po-verify/SKILL.md @@ -0,0 +1,46 @@ +--- +name: po-verify +description: Checks whether a work item was done as asked and where the change is (DEV / UAT / PROD), for a Product Owner. Use when someone asks "did they do what I asked", "is #123 done right", "is it on UAT yet", "can I close this", or wants an acceptance check of a story, bug or task in Azure DevOps. Needs the ado-lens MCP server. +--- + +# Checking work for a Product Owner + +The reader is a Product Owner, not a developer: answer in plain business language, in the language the user writes in. +Technical names (objects, methods, files, ids of commits) only in brackets or when asked for "details". + +## Steps + +1. `ado_trace id=` — what was asked (acceptance criteria), what code is linked, builds, where it is deployed, signals. + For a task, the criteria come from its parent; say so. +2. For every criterion, look for evidence in the code — never assume: + - `ado_changes change=` lists the changed objects with their methods, + - `ado_changes … file=` shows what exactly changed (X++ per method, metadata such as fields and controls). + Read only the files that can prove or disprove a criterion; stop when each one has an answer. +3. Judge each criterion: + - ✅ **done** — the code clearly does it (name the place in brackets), + - ⚠️ **to check by hand** — partly there, or only testable in the system (data, configuration, UI behaviour), + - ❌ **missing** — nothing in the changes does it. + +## Answer + +Start with one sentence: is it done, and where is it ("Done as asked, on UAT since Tuesday, not yet on production"). +Then: + +| Criterion | Status | Why | +|---|---|---| +| short criterion text | ✅ / ⚠️ / ❌ | one plain sentence (technical place in brackets) | + +Then, only when they apply: +- **Where it is** — each environment: on it since … / not yet. +- **Watch out** — the signals from `ado_trace` in plain words (criteria changed after coding started, closed with open + tasks, no criteria at all, build failed, PR not merged). +- **How to try it** — 2–4 steps the PO can do on UAT to see it working, with example data. + +Keep it short: no file lists, no code unless asked ("show details" → add the technical part). + +## Afterwards + +Offer, do not do unasked: +- a comment on the work item with the result (`ado_comment`, ≤ 60 words, decision first; `@[name]` the developer when + something is missing), +- closing the item when everything is ✅ and it is where the PO tests (`ado_update state=…`). diff --git a/src/AdoLens/Cli.cs b/src/AdoLens/Cli.cs index 74a434f..e0f1ce3 100644 --- a/src/AdoLens/Cli.cs +++ b/src/AdoLens/Cli.cs @@ -172,7 +172,8 @@ public int Connections() { var pat = CredentialStore.Read(name) is not null ? "PAT stored" : "no stored PAT"; Console.WriteLine($"{(name.Equals(settings.DefaultConnection, StringComparison.OrdinalIgnoreCase) ? "*" : " ")} {name,-16} " + - $"https://dev.azure.com/{c.Organization} project {c.Project ?? "-"} team {c.Team ?? "-"} ({pat})"); + $"https://dev.azure.com/{c.Organization} project {c.Project ?? "-"} team {c.Team ?? "-"} ({pat})" + + (c.Environments.Count > 0 ? $"\n {"",-16} environments: {string.Join(", ", c.Environments.Select(e => $"{e.Key} = {e.Value}"))}" : "")); } return 0; } @@ -189,9 +190,17 @@ public int Config() throw new ArgumentException($"no connection named '{name}' - add it with 'adolens login --connection {name}'"); if (cl.Opt("project") is { } p) { conn.Project = NullIfEmpty(p); changed = true; } if (cl.Opt("team") is { } t) { conn.Team = NullIfEmpty(t); changed = true; } + while (cl.Opt("env") is { } e) + { + var eq = e.IndexOf('='); + if (eq <= 0 || eq == e.Length - 1) throw new ArgumentException($"--env takes branch=label, e.g. staging=UAT, not '{e}'"); + conn.Environments[e[..eq].Trim()] = e[(eq + 1)..].Trim(); + changed = true; + } + while (cl.Opt("remove-env") is { } r) { conn.Environments.Remove(r.Trim()); changed = true; } } - else if (cl.Peek("project") is not null || cl.Peek("team") is not null) - throw new ArgumentException("--project / --team need --connection "); + else if (cl.Peek("project") is not null || cl.Peek("team") is not null || cl.Peek("env") is not null || cl.Peek("remove-env") is not null) + throw new ArgumentException("--project / --team / --env need --connection "); if (cl.Opt("default-connection") is { } d) { if (!settings.Connections.ContainsKey(d)) throw new ArgumentException($"no connection named '{d}'"); @@ -447,6 +456,20 @@ public async Task MyDayAsync() return 0; } + public async Task TraceAsync() + { + if (Int(cl.Positional.FirstOrDefault()) is not { } id) { Console.Error.WriteLine("usage: adolens trace [--detail summary|full]"); return 2; } + Print(await TraceQueries.TraceAsync(NewLens(), cl.Opt("connection"), id, cl.Opt("detail"))); + return 0; + } + + public async Task ChangesAsync() + { + if (cl.Positional.Count == 0) { Console.Error.WriteLine("usage: adolens changes <523 | C523 | \"PR 482\" | sha --repo > [--file ]"); return 2; } + Print(await TraceQueries.ChangesAsync(NewLens(), cl.Opt("connection"), string.Join(' ', cl.Positional), cl.Opt("repo"), cl.Opt("file"), cl.Opt("detail"))); + return 0; + } + public async Task WikiAsync() { Print(await WikiQueries.ReadAsync(NewLens(), cl.Opt("connection"), cl.Opt("project"), cl.Opt("wiki"), diff --git a/src/AdoLens/Code.cs b/src/AdoLens/Code.cs new file mode 100644 index 0000000..fb48a44 --- /dev/null +++ b/src/AdoLens/Code.cs @@ -0,0 +1,224 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Net; +using System.Text.Json; + +namespace AdoLens; + +/// A changed file: its path, how it changed, and where to read the two versions. +public sealed record FileChange(string Path, string ChangeType, string? OldPath); + +/// A change of code linked to work: a TFVC changeset, a Git commit or a pull request. +public sealed partial record CodeChange(string Kind, string Id, string? Repo, string? RepoId, string? Author, DateTime? Date, string? Message, + string? Branch, string? State, string? TargetBranch, string? MergeCommit, List Files) +{ + public string Label => Kind switch + { + "changeset" => $"changeset {Id}", + "pr" => $"PR {Id}", + _ => $"commit {(Id.Length > 7 ? Id[..7] : Id)}", + } + (Repo is null ? "" : $" ({Repo})"); +} + +/// Latest successful deployment of one stage of a release definition, with what it deployed. +public sealed record Deployment(string Definition, string Stage, DateTime? When, string? Branch, string? SourceVersion, string? BuildNumber, string? Repository); + +/// Version control (TFVC and Git), builds and releases. +public sealed class Code(AdoClient client) +{ + const int MaxChanges = 2000; + readonly ConcurrentDictionary> _tfvcBranches = new(StringComparer.OrdinalIgnoreCase); + readonly ConcurrentDictionary _repos = new(StringComparer.OrdinalIgnoreCase); + + static DateTime? Date(string? s) => + DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var d) ? d : null; + + public async Task ChangesetAsync(int id, CancellationToken ct) + { + var csTask = client.GetAsync($"_apis/tfvc/changesets/{id}?maxCommentLength=500", ct); + var files = new List(); + while (files.Count < MaxChanges) + { + var page = await client.GetListAsync($"_apis/tfvc/changesets/{id}/changes?$skip={files.Count}&$top=1000", ct); + if (page.Count == 0) break; + files.AddRange(page.Select(c => new FileChange(Json.Str(Json.Obj(c, "item"), "path") ?? "", Json.Str(c, "changeType") ?? "", + Json.Arr(c, "mergeSources").Select(m => Json.Str(m, "serverItem")).FirstOrDefault()))); + } + var cs = await csTask; + return new CodeChange("changeset", id.ToString(CultureInfo.InvariantCulture), null, null, Json.Str(Json.Obj(cs, "author"), "displayName"), + Date(Json.Str(cs, "createdDate")), Json.Str(cs, "comment"), null, null, null, null, files) + { + // $/Project/Branch/... : the project is the first part of every server path. + Project = files.Select(f => f.Path.Split('/', StringSplitOptions.RemoveEmptyEntries).ElementAtOrDefault(1)).FirstOrDefault(p => p is not null), + }; + } + + /// TFVC branch roots of the project; a path belongs to the longest root it starts with. + public async Task TfvcBranchAsync(string path, CancellationToken ct) + { + var project = path.Split('/', StringSplitOptions.RemoveEmptyEntries).ElementAtOrDefault(1) ?? ""; + if (!_tfvcBranches.TryGetValue(project, out var roots)) + { + try + { + var list = await client.GetListAsync($"{AdoClient.Scope(project)}_apis/tfvc/branches?includeChildren=true", ct); + roots = []; + void Walk(JsonElement b) + { + if (Json.Str(b, "path") is { } p) roots.Add(p); + foreach (var c in Json.Arr(b, "children")) Walk(c); + } + foreach (var b in list) Walk(b); + } + catch (AdoApiException) { roots = []; } + _tfvcBranches[project] = roots; + } + return roots.Where(r => path.StartsWith(r + "/", StringComparison.OrdinalIgnoreCase) || path.Equals(r, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(r => r.Length).FirstOrDefault(); + } + + public async Task<(string Id, string Name, string? Project)> RepoAsync(string idOrName, string? project, CancellationToken ct) + { + if (_repos.TryGetValue(idOrName, out var hit)) return hit; + var scope = project is null ? "" : AdoClient.Scope(project); + var repo = await client.GetAsync($"{scope}_apis/git/repositories/{Uri.EscapeDataString(idOrName)}", ct); + var result = (Json.Str(repo, "id") ?? idOrName, Json.Str(repo, "name") ?? idOrName, Json.Str(Json.Obj(repo, "project"), "name")); + _repos[idOrName] = result; + _repos[result.Item1] = result; + return result; + } + + public async Task CommitAsync(string repoId, string sha, CancellationToken ct) + { + var repo = await RepoAsync(repoId, null, ct); + var c = await client.GetAsync($"_apis/git/repositories/{repo.Id}/commits/{sha}?changeCount=0", ct); + var files = new List(); + while (files.Count < MaxChanges) + { + var page = await client.GetAsync($"_apis/git/repositories/{repo.Id}/commits/{sha}/changes?top=1000&skip={files.Count}", ct); + var changes = Json.Arr(page, "changes").Where(x => !Json.Bool(Json.Obj(x, "item"), "isFolder")).ToList(); + if (!Json.Arr(page, "changes").Any()) break; + files.AddRange(changes.Select(x => new FileChange(Json.Str(Json.Obj(x, "item"), "path") ?? "", Json.Str(x, "changeType") ?? "", Json.Str(x, "sourceServerItem")))); + if (Json.Arr(page, "changes").Count() < 1000) break; + } + var author = Json.Obj(c, "author"); + return new CodeChange("commit", Json.Str(c, "commitId") ?? sha, repo.Name, repo.Id, Json.Str(author, "name"), Date(Json.Str(author, "date")), + Json.Str(c, "comment"), null, null, null, null, files) + { Parent = Json.Arr(c, "parents").Select(p => p.GetString()).FirstOrDefault(), Project = repo.Project }; + } + + public async Task PullRequestAsync(string? repoId, int id, bool withFiles, CancellationToken ct) + { + var pr = await client.GetAsync(repoId is null ? $"_apis/git/pullrequests/{id}" : $"_apis/git/repositories/{repoId}/pullrequests/{id}", ct); + var repo = Json.Obj(pr, "repository"); + var rid = Json.Str(repo, "id") ?? repoId ?? ""; + var files = new List(); + string? baseCommit = null; + if (withFiles) + { + var iterations = await client.GetListAsync($"_apis/git/repositories/{rid}/pullRequests/{id}/iterations", ct); + if (iterations.LastOrDefault() is { ValueKind: JsonValueKind.Object } it && it.TryGetProperty("id", out var itId)) + { + baseCommit = Json.Str(Json.Obj(it, "commonRefCommit"), "commitId"); + var changes = await client.GetAsync($"_apis/git/repositories/{rid}/pullRequests/{id}/iterations/{itId.GetInt32()}/changes?$top=2000", ct); + files.AddRange(Json.Arr(changes, "changeEntries").Where(x => !Json.Bool(Json.Obj(x, "item"), "isFolder")) + .Select(x => new FileChange(Json.Str(Json.Obj(x, "item"), "path") ?? "", Json.Str(x, "changeType") ?? "", Json.Str(x, "originalPath")))); + } + } + var status = Json.Str(pr, "status"); + var merge = Json.Str(Json.Obj(pr, "lastMergeCommit"), "commitId"); + var head = Json.Str(Json.Obj(pr, "lastMergeSourceCommit"), "commitId"); + return new CodeChange("pr", id.ToString(CultureInfo.InvariantCulture), Json.Str(repo, "name"), rid, + Json.Str(Json.Obj(pr, "createdBy"), "displayName"), Date(Json.Str(pr, "closedDate") ?? Json.Str(pr, "creationDate")), + Json.Str(pr, "title"), Short(Json.Str(pr, "sourceRefName")), status == "completed" ? "completed" : status, + Short(Json.Str(pr, "targetRefName")), status == "completed" ? merge : head, files) + { BaseCommit = baseCommit, HeadCommit = head, Project = Json.Str(Json.Obj(repo, "project"), "name") }; + } + + public static string? Short(string? refName) => refName?.Replace("refs/heads/", ""); + + /// Text of a file at a TFVC changeset; null when it does not exist there. + public async Task TfvcFileAsync(string path, int changeset, CancellationToken ct) + { + if (changeset <= 0) return null; + try + { + var item = await client.GetAsync($"_apis/tfvc/items?path={Uri.EscapeDataString(path)}&versionDescriptor.version={changeset}&versionDescriptor.versionType=changeset&includeContent=true", ct); + return Json.Str(item, "content"); + } + catch (AdoApiException ex) when (ex.Status is HttpStatusCode.NotFound or HttpStatusCode.BadRequest) { return null; } + } + + /// Text of a file at a Git commit; null when it does not exist there. + public async Task GitFileAsync(string repoId, string path, string? commit, CancellationToken ct) + { + if (string.IsNullOrEmpty(commit)) return null; + try + { + var item = await client.GetAsync($"_apis/git/repositories/{repoId}/items?path={Uri.EscapeDataString(path)}&versionDescriptor.version={commit}&versionDescriptor.versionType=commit&includeContent=true", ct); + return Json.Str(item, "content"); + } + catch (AdoApiException ex) when (ex.Status is HttpStatusCode.NotFound or HttpStatusCode.BadRequest) { return null; } + } + + /// Whether is in the history of (a commit or branch). + public async Task ContainsAsync(string repoId, string commit, string target, bool targetIsBranch, CancellationToken ct) + { + try + { + var diff = await client.GetAsync($"_apis/git/repositories/{repoId}/diffs/commits?baseVersion={commit}&baseVersionType=commit" + + $"&targetVersion={Uri.EscapeDataString(target)}&targetVersionType={(targetIsBranch ? "branch" : "commit")}&$top=1", ct); + // The merge base equals the commit exactly when the commit is an ancestor of the target. + return string.Equals(Json.Str(diff, "commonCommit"), commit, StringComparison.OrdinalIgnoreCase); + } + catch (AdoApiException) { return null; } + } + + /// A build, or null when it is gone (builds are deleted by retention). + public async Task BuildAsync(string project, int id, CancellationToken ct) + { + try { return await client.GetAsync($"{AdoClient.Scope(project)}_apis/build/builds/{id}", ct); } + catch (AdoApiException ex) when (ex.Status == HttpStatusCode.NotFound) { return null; } + } + + /// The latest successful deployment of every stage of every classic release definition. + public async Task> DeploymentsAsync(string project, CancellationToken ct) + { + List list; + try + { + list = await client.GetListAsync(client.ServiceUrl("vsrm") + $"{Uri.EscapeDataString(project)}/_apis/release/deployments?deploymentStatus=succeeded&queryOrder=descending&$top=200", ct); + } + catch (AdoApiException) { return []; } + var latest = new Dictionary<(string, string), Deployment>(); + foreach (var d in list) + { + var def = Json.Str(Json.Obj(d, "releaseDefinition"), "name") ?? ""; + var stage = Json.Str(Json.Obj(d, "releaseEnvironment"), "name") ?? ""; + if (latest.ContainsKey((def, stage))) continue; + var artifact = Json.Arr(Json.Obj(d, "release"), "artifacts").FirstOrDefault(a => Json.Bool(a, "isPrimary")) is { ValueKind: JsonValueKind.Object } p + ? p : Json.Arr(Json.Obj(d, "release"), "artifacts").FirstOrDefault(); + var refs = Json.Obj(artifact, "definitionReference"); + latest[(def, stage)] = new Deployment(def, stage, Date(Json.Str(d, "completedOn")), + Json.Str(Json.Obj(refs, "branch"), "id"), Json.Str(Json.Obj(refs, "sourceVersion"), "id"), + Json.Str(Json.Obj(refs, "version"), "name"), Json.Str(Json.Obj(refs, "repository"), "id")); + } + return [.. latest.Values]; + } +} + +public sealed partial record CodeChange +{ + /// For a pull request: the merge base and the last source commit (the PR's diff is between them). + public string? BaseCommit { get; init; } + public string? HeadCommit { get; init; } + + /// For a commit: its first parent (the version before it). + public string? Parent { get; init; } + + /// The project the code lives in (may differ from the work item's project). + public string? Project { get; init; } + + /// The commit that has to be in a branch for this change to be there: a commit itself, a completed PR's merge. + public string? LandedCommit => Kind switch { "commit" => Id, "pr" when State == "completed" => MergeCommit, _ => null }; +} diff --git a/src/AdoLens/CommandLine.cs b/src/AdoLens/CommandLine.cs index fb6fba4..200eb4f 100644 --- a/src/AdoLens/CommandLine.cs +++ b/src/AdoLens/CommandLine.cs @@ -15,7 +15,9 @@ public sealed partial class CommandLine ["login"] = ["connection=", "org=", "project=", "team=", "pat-stdin", "default"], ["logout"] = ["connection="], ["connections"] = [], - ["config"] = ["connection=", "default-connection=", "project=", "team=", "mode=", "usage-log=", "add-toolset=", "remove-toolset=", "confirm-above=", "show-tokens="], + ["trace"] = ["connection=", "detail="], + ["changes"] = ["connection=", "repo=", "file=", "detail="], + ["config"] = ["env=", "remove-env=", "connection=", "default-connection=", "project=", "team=", "mode=", "usage-log=", "add-toolset=", "remove-toolset=", "confirm-above=", "show-tokens="], ["item"] = ["connection=", "include=", "detail="], ["query"] = ["connection=", "project=", "wiql=", "query=", "top="], ["create"] = ["connection=", "project=", "type=", "title=", "description=", "criteria=", "assign=", "parent=", "iteration=", "area=", "tags=", "state=", "field="], diff --git a/src/AdoLens/Diff.cs b/src/AdoLens/Diff.cs new file mode 100644 index 0000000..42b5d91 --- /dev/null +++ b/src/AdoLens/Diff.cs @@ -0,0 +1,237 @@ +using System.Text; +using System.Xml.Linq; + +namespace AdoLens; + +/// Line diff (Myers) printed as unified hunks with a few lines of context. +public static class Diff +{ + public enum Op { Same, Del, Add } + + public static List<(Op Op, string Line)> Lines(IReadOnlyList a, IReadOnlyList b) + { + // Common prefix and suffix first: most edits touch a few lines of a long file. + var start = 0; + while (start < a.Count && start < b.Count && a[start] == b[start]) start++; + var endA = a.Count; + var endB = b.Count; + while (endA > start && endB > start && a[endA - 1] == b[endB - 1]) { endA--; endB--; } + + var result = new List<(Op, string)>(); + for (var i = 0; i < start; i++) result.Add((Op.Same, a[i])); + result.AddRange(Myers(a.Skip(start).Take(endA - start).ToList(), b.Skip(start).Take(endB - start).ToList())); + for (var i = endA; i < a.Count; i++) result.Add((Op.Same, a[i])); + return result; + } + + static List<(Op, string)> Myers(List a, List b) + { + int n = a.Count, m = b.Count, max = n + m; + if (n == 0) return b.Select(l => (Op.Add, l)).ToList(); + if (m == 0) return a.Select(l => (Op.Del, l)).ToList(); + var v = new int[2 * max + 2]; + var trace = new List(); + for (var d = 0; d <= max; d++) + { + trace.Add((int[])v.Clone()); + for (var k = -d; k <= d; k += 2) + { + var x = k == -d || (k != d && v[k - 1 + max] < v[k + 1 + max]) ? v[k + 1 + max] : v[k - 1 + max] + 1; + var y = x - k; + while (x < n && y < m && a[x] == b[y]) { x++; y++; } + v[k + max] = x; + if (x >= n && y >= m) return Backtrack(trace, a, b, max); + } + } + return []; + } + + static List<(Op, string)> Backtrack(List trace, List a, List b, int max) + { + var result = new List<(Op, string)>(); + int x = a.Count, y = b.Count; + for (var d = trace.Count - 1; d > 0; d--) + { + var v = trace[d]; + var k = x - y; + var prevK = k == -d || (k != d && v[k - 1 + max] < v[k + 1 + max]) ? k + 1 : k - 1; + var prevX = v[prevK + max]; + var prevY = prevX - prevK; + while (x > prevX && y > prevY) { result.Add((Op.Same, a[x - 1])); x--; y--; } + if (x == prevX) result.Add((Op.Add, b[y - 1])); else result.Add((Op.Del, a[x - 1])); + x = prevX; + y = prevY; + } + while (x > 0 && y > 0) { result.Add((Op.Same, a[x - 1])); x--; y--; } + result.Reverse(); + return result; + } + + /// Unified hunks ("@@ -a,b +c,d @@") with lines around each change; null when equal. + public static string? Unified(string before, string after, int context = 3, int maxLines = 400) + { + var a = Split(before); + var b = Split(after); + var ops = Lines(a, b); + if (ops.All(o => o.Op == Op.Same)) return null; + + // Line numbers in both versions at every position of the edit script. + var lineA = new int[ops.Count]; + var lineB = new int[ops.Count]; + for (int i = 0, x = 0, y = 0; i < ops.Count; i++) + { + lineA[i] = x; + lineB[i] = y; + if (ops[i].Op != Op.Add) x++; + if (ops[i].Op != Op.Del) y++; + } + + // Changes closer than two contexts apart share a hunk. + var changes = Enumerable.Range(0, ops.Count).Where(i => ops[i].Op != Op.Same).ToList(); + var hunks = new List<(int From, int To)>(); + int first = changes[0], last = changes[0]; + foreach (var c in changes.Skip(1)) + { + if (c - last > 2 * context) { hunks.Add((first, last)); first = c; } + last = c; + } + hunks.Add((first, last)); + + var sb = new StringBuilder(); + var printed = 0; + foreach (var (f, l) in hunks) + { + var from = Math.Max(0, f - context); + var to = Math.Min(ops.Count - 1, l + context); + var hunk = ops.Skip(from).Take(to - from + 1).ToList(); + sb.AppendLine($"@@ -{lineA[from] + 1},{hunk.Count(o => o.Op != Op.Add)} +{lineB[from] + 1},{hunk.Count(o => o.Op != Op.Del)} @@"); + foreach (var (op, line) in hunk) + { + if (printed++ >= maxLines) { sb.AppendLine($"… diff cut after {maxLines} lines"); return sb.ToString().TrimEnd(); } + sb.Append(op switch { Op.Add => '+', Op.Del => '-', _ => ' ' }).AppendLine(line); + } + } + return sb.ToString().TrimEnd(); + } + + public static string[] Split(string text) => text.Replace("\r\n", "\n").Replace('\r', '\n').TrimEnd('\n').Split('\n'); +} + +/// +/// Dynamics 365 F&O metadata (AOT XML: AxClass, AxTable, AxForm…): changes per X++ method instead of raw XML, +/// plus a compact diff of the metadata around them (fields, controls, properties). +/// +public static class AotDiff +{ + public static bool IsAot(string path) => + path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) + && path.Replace('\\', '/').Split('/').Any(p => p.StartsWith("Ax", StringComparison.Ordinal) && p.Length > 2 && char.IsUpper(p[2])); + + /// "AxClass/SalesTable_Extension.xml" → "SalesTable_Extension (class)". + public static string ObjectName(string path) + { + var parts = path.Replace('\\', '/').Split('/'); + var name = Path.GetFileNameWithoutExtension(parts[^1]); + var kind = parts.Length > 1 && parts[^2].StartsWith("Ax", StringComparison.Ordinal) ? parts[^2][2..] : ""; + kind = kind switch + { + "MenuItemDisplay" => "display menu item", "MenuItemAction" => "action menu item", "MenuItemOutput" => "output menu item", + "DataEntityView" => "data entity", "SecurityPrivilege" => "privilege", "SecurityDuty" => "duty", "SecurityRole" => "role", + _ when kind.StartsWith("Edt", StringComparison.Ordinal) => kind.EndsWith("Extension", StringComparison.Ordinal) ? "EDT extension" : "EDT", + // TableExtension → "table extension", MenuExtension → "menu extension" + _ => string.Concat(kind.Select((ch, i) => i > 0 && char.IsUpper(ch) ? " " + char.ToLowerInvariant(ch) : char.ToLowerInvariant(ch).ToString())), + }; + return kind.Length == 0 ? name : $"{name} ({kind})"; + } + + /// Methods by qualified name ("DataSource.method" in forms) with their source. + public static Dictionary Methods(XDocument? doc) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (doc?.Root is null) return map; + if (doc.Root.Element("SourceCode")?.Element("Declaration")?.Value is { } decl) map["(declaration)"] = decl; + foreach (var m in doc.Descendants().Where(e => e.Name.LocalName == "Method")) + { + var name = m.Element(m.Name.Namespace + "Name")?.Value ?? m.Elements().FirstOrDefault(e => e.Name.LocalName == "Name")?.Value; + var source = m.Elements().FirstOrDefault(e => e.Name.LocalName == "Source")?.Value; + if (name is null || source is null) continue; + // The nearest named owner other than the object itself: a data source, control or field of a form or table. + var owner = m.Ancestors().Skip(1).FirstOrDefault(a => a.Parent is not null && a.Parent != doc.Root && a.Elements().Any(e => e.Name.LocalName == "Name") + && a.Name.LocalName is not ("Methods" or "SourceCode")); + var ownerName = owner?.Elements().First(e => e.Name.LocalName == "Name").Value; + var key = ownerName is null ? name : $"{ownerName}.{name}"; + map[key] = source; + } + return map; + } + + /// The XML without method bodies: what is left are fields, controls and properties. + public static string Metadata(XDocument? doc) + { + if (doc?.Root is null) return ""; + var copy = new XDocument(doc); + foreach (var e in copy.Descendants().Where(e => e.Name.LocalName is "Source" or "Declaration").ToList()) e.Value = "…"; + return copy.ToString(); + } + + static XDocument? Parse(string? xml) + { + if (string.IsNullOrWhiteSpace(xml)) return null; + try { return XDocument.Parse(xml); } + catch (System.Xml.XmlException) { return null; } + } + + /// One line: "+method a, ~method b, -method c; metadata changed". + public static string Summary(string? before, string? after) + { + var a = Parse(before); + var b = Parse(after); + if (a is null && b is not null) + { + var methods = Methods(b).Keys.Where(k => k != "(declaration)").Order(StringComparer.OrdinalIgnoreCase).ToList(); + return methods.Count == 0 ? "new" : $"new, {methods.Count} method(s): {string.Join(", ", methods.Take(12))}{(methods.Count > 12 ? ", …" : "")}"; + } + if (b is null) return a is null ? "not readable as AOT XML" : "deleted"; + var (added, removed, changed, metadata) = Compare(a, b); + var parts = new List(); + parts.AddRange(added.Select(m => "+" + m)); + parts.AddRange(changed.Select(m => "~" + m)); + parts.AddRange(removed.Select(m => "-" + m)); + if (metadata) parts.Add("metadata changed"); + return parts.Count == 0 ? "no change in methods or metadata" : string.Join(", ", parts); + } + + static (List Added, List Removed, List Changed, bool Metadata) Compare(XDocument? a, XDocument? b) + { + var ma = Methods(a); + var mb = Methods(b); + var added = mb.Keys.Where(k => !ma.ContainsKey(k)).Order(StringComparer.OrdinalIgnoreCase).ToList(); + var removed = ma.Keys.Where(k => !mb.ContainsKey(k)).Order(StringComparer.OrdinalIgnoreCase).ToList(); + var changed = mb.Keys.Where(k => ma.TryGetValue(k, out var s) && Normalize(s) != Normalize(mb[k])).Order(StringComparer.OrdinalIgnoreCase).ToList(); + return (added, removed, changed, Normalize(Metadata(a)) != Normalize(Metadata(b))); + } + + static string Normalize(string s) => string.Join('\n', Diff.Split(s).Select(l => l.TrimEnd())); + + /// Changed methods as unified diffs, new methods in full, removed ones named, then the metadata diff. + public static string Detail(string? before, string? after, int maxLines = 400) + { + var a = Parse(before); + var b = Parse(after); + if (a is null && b is null) return "(not readable as AOT XML)"; + var (added, removed, changed, metadata) = Compare(a, b); + var ma = Methods(a); + var mb = Methods(b); + var sb = new StringBuilder(); + foreach (var m in changed) + sb.AppendLine($"~ {m}").AppendLine(Diff.Unified(ma[m], mb[m], 3, maxLines)).AppendLine(); + foreach (var m in added) + sb.AppendLine($"+ {m} (new)").AppendLine(string.Join('\n', Diff.Split(mb[m]).Select(l => "+" + l))).AppendLine(); + foreach (var m in removed) sb.AppendLine($"- {m} (removed)").AppendLine(); + if (metadata) + sb.AppendLine("metadata (fields, controls, properties; method bodies left out):") + .AppendLine(Diff.Unified(Metadata(a), Metadata(b), 2, maxLines / 2) ?? ""); + var text = sb.ToString().TrimEnd(); + return text.Length == 0 ? "no change in methods or metadata (whitespace or encoding only)" : text; + } +} diff --git a/src/AdoLens/Lens.cs b/src/AdoLens/Lens.cs index dd4729b..93344d7 100644 --- a/src/AdoLens/Lens.cs +++ b/src/AdoLens/Lens.cs @@ -10,6 +10,8 @@ public sealed record Session(Config Config, AdoClient Client, MetadataService Me WorkItems? _work; public WorkItems Work => _work ??= new(Client, Meta, People, Config); + public Code Code { get; } = new(Client); + /// The project given, else the connection's default project. public string Project(string? project) => !string.IsNullOrWhiteSpace(project) ? project.Trim() diff --git a/src/AdoLens/McpTools.cs b/src/AdoLens/McpTools.cs index cc4504a..96eba60 100644 --- a/src/AdoLens/McpTools.cs +++ b/src/AdoLens/McpTools.cs @@ -16,28 +16,22 @@ public static class McpTools static readonly Dictionary Lines = new() { [Toolsets.Core] = """ - - ado_status: version, connection, user. ado_fields: work item types by backlog level (portfolio / requirement / - task / bug), states, transitions, fields, allowed values, structure changes. Field names and values differ per - process and company: check them with ado_fields before writing fields or WIQL. + - Field names, values and states differ per process and company: check them with ado_fields before writing. """, [Toolsets.Work] = """ - - ado_work_item reads items (include=comments,history); ado_query runs WIQL or a saved query; ado_create, - ado_update (one or many; many are previewed first: show the preview, then repeat the call with confirm), - ado_comment (@[name] mentions notify people), ado_link. + - ado_update on many items returns a preview: show it, then repeat the call with confirm. - 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. """, [Toolsets.Boards] = """ - - ado_sprint: sprint dates, progress, remaining work vs capacity, and what needs attention (stale, blocked, - no estimate, no criteria, unassigned) - say plainly when the sprint has ended. ado_board: columns with WIP - limits and cards; move= column= moves a card (state follows the column). ado_backlog: priority - order; move with above/below reorders. ado_my_day: mentions, own and followed items changed by others, PRs - waiting for review. + - Say plainly when a sprint has ended or a column is over its WIP limit. + """, + [Toolsets.Trace] = """ + - Checking work against what was asked: ado_trace, then read the diffs with ado_changes - never assume. """, [Toolsets.Wiki] = """ - - ado_wiki: page tree, a page (markdown, with its version), or search. ado_wiki_write: create, append, prepend, - or replace with the version from the last full read (a page changed meanwhile is never overwritten). + - Replace a wiki page only after reading it whole (detail=full), with its version. """, }; @@ -163,6 +157,30 @@ public static Task MyDay(Lens lens, () => BoardQueries.MyDayAsync(lens, connection, project, since, null, ct)); } +[McpServerToolType, Toolset(Toolsets.Trace)] +public static class TraceTools +{ + [McpServerTool(Name = "ado_trace", ReadOnly = true, Idempotent = true)] + [Description("Was a work item done as asked and where is it: acceptance criteria and their changes, linked code (item and tasks), builds, each release stage YES / NOT YET, tests, signals.")] + public static Task Trace(Lens lens, int id, + [Description("summary | normal | full.")] string? detail = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_trace", new { id, detail, connection }, () => TraceQueries.TraceAsync(lens, connection, id, detail, ct)); + + [McpServerTool(Name = "ado_changes", ReadOnly = true, Idempotent = true)] + [Description("Files of a changeset (523), pull request (PR 482) or commit (sha + repo), X++ objects with added/changed/removed methods; with file: the diff of one file, X++ per method.")] + public static Task Changes(Lens lens, + [Description("523, C523, PR 482, or a commit sha.")] string change, + [Description("Repository name, for commits.")] string? repo = null, + [Description("File name or end of its path.")] string? file = null, + [Description("summary | normal | full.")] string? detail = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_changes", new { change, repo, file, detail, connection }, + () => TraceQueries.ChangesAsync(lens, connection, change, repo, file, detail, ct)); +} + [McpServerToolType, Toolset(Toolsets.Wiki)] public static class WikiTools { @@ -261,7 +279,7 @@ public static Task Update(Lens lens, 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, - [Description("Markdown comment added to each item.")] string? comment = null, + [Description("Markdown comment for each item, alone or with changes; @[name or e-mail] notifies a person.")] string? comment = null, [Description("Token from the preview.")] string? confirm = null, string? connection = null, CancellationToken ct = default) diff --git a/src/AdoLens/Program.cs b/src/AdoLens/Program.cs index ce87017..a018d38 100644 --- a/src/AdoLens/Program.cs +++ b/src/AdoLens/Program.cs @@ -72,6 +72,10 @@ return await cli.BacklogAsync(); case "myday": return await cli.MyDayAsync(); + case "trace": + return await cli.TraceAsync(); + case "changes": + return await cli.ChangesAsync(); case "wiki": return await cli.WikiAsync(); case "wiki-write": diff --git a/src/AdoLens/Settings.cs b/src/AdoLens/Settings.cs index f8267f9..b88dff1 100644 --- a/src/AdoLens/Settings.cs +++ b/src/AdoLens/Settings.cs @@ -10,6 +10,12 @@ public sealed class Connection public string Organization { get; set; } = ""; public string? Project { get; set; } public string? Team { get; set; } + + /// + /// What a branch means for people: "staging" → "UAT", "master" → "PROD" (TFVC: the last part of the branch path, + /// e.g. "Main"). Azure DevOps does not know it; ado_trace uses it to say where a change has arrived. + /// + public Dictionary Environments { get; set; } = new(StringComparer.OrdinalIgnoreCase); } /// Non-secret settings (adolens.json in the data folder). PATs live in Windows Credential Manager. @@ -53,8 +59,10 @@ public static Settings Load(string path) { if (!File.Exists(path)) return new(); var s = JsonSerializer.Deserialize(File.ReadAllText(path), Json) ?? new(); - // The deserializer replaces the dictionary, so the case-insensitive comparer has to be restored. + // The deserializer replaces the dictionaries, so the case-insensitive comparers have to be restored. s.Connections = new Dictionary(s.Connections, StringComparer.OrdinalIgnoreCase); + foreach (var c in s.Connections.Values) + c.Environments = new Dictionary(c.Environments ?? [], StringComparer.OrdinalIgnoreCase); return s; } @@ -116,6 +124,9 @@ public static string NormalizeConnectionName(string input) /// Effective settings of one connection, with the PAT and where it came from. public sealed record Config(string Connection, string Organization, string Pat, string? Project, string? Team, string PatSource) { + /// Branch → environment label of the connection (see ). + public IReadOnlyDictionary Environments { get; init; } = new Dictionary(); + public const string ConnectionVar = "ADOLENS_CONNECTION"; public const string OrgVar = "AZURE_DEVOPS_ORG"; public const string PatVar = "AZURE_DEVOPS_PAT"; @@ -166,9 +177,10 @@ public static Config Resolve(Settings settings, string? connection, Func +/// "Did they do what was asked, and where is it?" — a work item's acceptance criteria next to the code linked to it +/// (and to its tasks), the builds, and every release stage with whether it already contains the change. +/// +public static partial class TraceQueries +{ + sealed record Link(string Kind, string Value, int From); + + [GeneratedRegex(@"^vstfs:///(?VersionControl/Changeset|Git/Commit|Git/PullRequestId|Build/Build|Git/Ref)/(?.+)$", RegexOptions.IgnoreCase)] + private static partial Regex ArtifactUrl(); + + static List Links(JsonElement wi) + { + var id = WorkItems.Id(wi); + var list = new List(); + foreach (var r in Json.Arr(wi, "relations").Where(r => Json.Str(r, "rel") == "ArtifactLink")) + if (ArtifactUrl().Match(Json.Str(r, "url") ?? "") is { Success: true } m) + list.Add(new Link(m.Groups["kind"].Value.ToLowerInvariant(), Uri.UnescapeDataString(m.Groups["value"].Value), id)); + return list; + } + + /// When an update was made: its System.ChangedDate (revisedDate of the latest update is 9999-01-01). + static DateTime? UpdateTime(JsonElement update) + { + var changed = Json.Obj(Json.Obj(Json.Obj(update, "fields"), "System.ChangedDate"), "newValue"); + var text = Json.Scalar(changed) ?? Json.Str(update, "revisedDate"); + return DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var d) && d.Year < 9999 ? d : null; + } + + static string Date(DateTime? d) => d?.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "?"; + + static IEnumerable Objects(CodeChange c) => + c.Files.Select(f => AotDiff.IsAot(f.Path) ? AotDiff.ObjectName(f.Path) : f.Path[(f.Path.LastIndexOf('/') + 1)..]) + .Where(n => n.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase); + + public static async Task TraceAsync(Lens lens, string? connection, int id, string? detail, CancellationToken ct = default) + { + var session = lens.For(connection); + var work = session.Work; + var code = session.Code; + var full = detail == "full"; + var summary = detail == "summary"; + + var items = await work.GetAsync([id], relations: true, ct: ct); + if (items.Count == 0) return $"no work item #{id} visible with this PAT"; + var wi = items[0]; + var project = WorkItems.Field(wi, "System.TeamProject") ?? session.Project(null); + var childIds = Json.Arr(wi, "relations").Where(r => Json.Str(r, "rel") == "System.LinkTypes.Hierarchy-Forward") + .Select(r => Json.Str(r, "url") ?? "").Select(u => int.TryParse(u[(u.LastIndexOf('/') + 1)..], out var c) ? c : 0).Where(c => c > 0).ToList(); + var children = childIds.Count == 0 ? [] : await work.GetAsync(childIds, relations: true, ct: ct); + var s = (await session.Meta.GetAsync(project, ct)).Snapshot; + string Category(JsonElement x) => + s.FindType(WorkItems.Field(x, "System.WorkItemType") ?? "")?.States.FirstOrDefault(st => st.Name == WorkItems.Field(x, "System.State"))?.Category ?? ""; + + var sb = new StringBuilder(); + sb.AppendLine(WorkItemFormat.Header(wi)); + sb.AppendLine(WorkItemFormat.Url(work.WebBase, wi)); + var signals = new List(); + + // 1. What was asked. + var criteria = Html.ToMarkdown(WorkItems.Field(wi, WorkItems.AcceptanceRef)); + var description = Html.ToMarkdown(WorkItems.Field(wi, WorkItems.DescriptionRef) ?? WorkItems.Field(wi, WorkItems.ReproStepsRef)); + sb.AppendLine("\nASKED"); + // A task rarely has criteria of its own: what was asked is in its parent (story, bug). + if (criteria.Length == 0 && Json.Arr(wi, "relations").FirstOrDefault(r => Json.Str(r, "rel") == "System.LinkTypes.Hierarchy-Reverse") is { ValueKind: JsonValueKind.Object } up + && int.TryParse((Json.Str(up, "url") ?? "")[((Json.Str(up, "url") ?? "").LastIndexOf('/') + 1)..], out var parentId) + && (await work.GetAsync([parentId], relations: false, ct: ct)).FirstOrDefault() is { ValueKind: JsonValueKind.Object } parent) + { + var parentCriteria = Html.ToMarkdown(WorkItems.Field(parent, WorkItems.AcceptanceRef)); + sb.AppendLine($"from the parent {WorkItemFormat.Header(parent)}:"); + if (parentCriteria.Length > 0) criteria = parentCriteria; + else if (description.Length == 0) description = Html.ToMarkdown(WorkItems.Field(parent, WorkItems.DescriptionRef) ?? WorkItems.Field(parent, WorkItems.ReproStepsRef)); + } + if (criteria.Length > 0) sb.AppendLine("acceptance criteria:").AppendLine(criteria.Length > 2500 && !full ? criteria[..2500] + "…" : criteria); + else + { + sb.AppendLine("acceptance criteria: none in their field" + (description.Length > 0 ? "; description:" : "")); + if (description.Length > 0) sb.AppendLine(description.Length > 1500 && !full ? description[..1500] + "…" : description); + signals.Add(description.Length > 0 ? "no acceptance criteria: the check can only go by the description" + : "no acceptance criteria and no description: nothing to check the code against (look at the parent item)"); + } + var criteriaChanges = (await work.UpdatesAsync(project, id, ct)) + .Where(u => Json.Obj(Json.Obj(u, "fields"), WorkItems.AcceptanceRef).ValueKind == JsonValueKind.Object) + .Select(u => (When: UpdateTime(u), Who: Json.Str(Json.Obj(u, "revisedBy"), "displayName"))) + .Where(x => x.When is not null).ToList(); + + // 2. What was done: code linked to the item and to its tasks. + var links = Links(wi).Concat(children.SelectMany(Links)).DistinctBy(l => (l.Kind, l.Value)).ToList(); + var changes = new List<(CodeChange Change, int From)>(); + var failed = new List(); + await Parallel.ForEachAsync(links.Where(l => l.Kind != "build/build" && l.Kind != "git/ref"), + new ParallelOptions { MaxDegreeOfParallelism = 6, CancellationToken = ct }, async (l, token) => + { + try + { + var parts = l.Value.Split('/'); + CodeChange c = l.Kind switch + { + "versioncontrol/changeset" => await code.ChangesetAsync(int.Parse(parts[^1], CultureInfo.InvariantCulture), token), + "git/commit" => await code.CommitAsync(parts[1], parts[2], token), + _ => await code.PullRequestAsync(parts[1], int.Parse(parts[2], CultureInfo.InvariantCulture), withFiles: false, token), + }; + lock (changes) changes.Add((c, l.From)); + } + catch (AdoApiException ex) { lock (failed) failed.Add($"{l.Kind.Split('/')[^1]} {l.Value.Split('/')[^1]}: {Formatting.Collapse(ex.Message, 100)}"); } + }); + changes = changes.OrderBy(c => c.Change.Date).ToList(); + var branches = new Dictionary(); + foreach (var cs in changes.Select(x => x.Change).Where(x => x.Kind == "changeset")) + branches[cs.Id] = cs.Files.Count > 0 ? await code.TfvcBranchAsync(cs.Files[0].Path, ct) : null; + + sb.AppendLine("\nDONE"); + if (changes.Count == 0) sb.AppendLine("no changesets, commits or pull requests linked to the item or its tasks"); + foreach (var (c, from) in changes) + { + var via = from == id ? "" : $" (via #{from})"; + var line = c.Kind switch + { + "pr" => $"{c.Label} {c.State}{(c.State == "completed" ? $" {Date(c.Date)}" : "")}: {c.TargetBranch} <- {c.Branch}, \"{Formatting.Collapse(c.Message ?? "", 80)}\", {c.Author}", + _ => $"{c.Label} {Date(c.Date)} {c.Author}: \"{Formatting.Collapse(c.Message ?? "", 80)}\"" + + (c.Kind == "changeset" && branches.GetValueOrDefault(c.Id) is { } b ? $" on {b}" : ""), + }; + sb.AppendLine("- " + line + via); + if (!summary && c.Files.Count > 0) + { + var objects = Objects(c).ToList(); + var shown = full ? objects : objects.Take(12).ToList(); + sb.AppendLine($" {c.Files.Count} file(s): {string.Join(", ", shown)}{(objects.Count > shown.Count ? $", … {objects.Count - shown.Count} more" : "")}"); + } + } + foreach (var f in failed) sb.AppendLine($"- not readable: {f}"); + if (!summary && changes.Count > 0) sb.AppendLine("ado_changes change= shows what changed (X++ per method)."); + + var firstCode = changes.Select(c => c.Change.Date).Where(d => d is not null).Min(); + if (firstCode is { } fc && criteriaChanges.Where(x => x.When > fc).ToList() is { Count: > 0 } late) + signals.Add($"acceptance criteria changed after the first code change ({string.Join(", ", late.Select(x => $"{Date(x.When)} {x.Who}"))}) - check the code against the current version"); + if (changes.Count == 0) + signals.Add(Category(wi) == "Completed" ? "closed without any linked code" : "no code linked yet"); + foreach (var pr in changes.Select(x => x.Change).Where(x => x.Kind == "pr" && x.State is "active" or "abandoned")) + signals.Add($"{pr.Label} is {pr.State}"); + var openTasks = children.Where(c => Category(c) is not ("Completed" or "Removed")).ToList(); + if (Category(wi) == "Completed" && openTasks.Count > 0) + signals.Add($"closed with {openTasks.Count} open task(s): {string.Join(", ", openTasks.Select(t => "#" + WorkItems.Id(t)))}"); + + // 3. Builds that integrated the change (old ones are deleted by retention). + var buildIds = links.Where(l => l.Kind == "build/build").Select(l => int.TryParse(l.Value, out var b) ? b : 0).Where(b => b > 0) + .Distinct().OrderByDescending(b => b).ToList(); + if (buildIds.Count > 0) + { + sb.AppendLine("\nBUILDS"); + var shownBuilds = buildIds.Take(full ? 50 : 5).ToList(); + var builds = await Task.WhenAll(shownBuilds.Select(b => code.BuildAsync(project, b, ct))); + for (var i = 0; i < shownBuilds.Count; i++) + { + if (builds[i] is not { } b) { sb.AppendLine($"- build {shownBuilds[i]}: no longer kept (deleted by retention)"); continue; } + sb.AppendLine($"- {Json.Str(Json.Obj(b, "definition"), "name")} {Json.Str(b, "buildNumber")}: {Json.Str(b, "result") ?? Json.Str(b, "status")}, " + + $"{Code.Short(Json.Str(b, "sourceBranch"))} @ {Json.Str(b, "sourceVersion")?[..Math.Min(8, Json.Str(b, "sourceVersion")!.Length)]}, {Date(DateTime.TryParse(Json.Str(b, "finishTime"), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var f) ? f : null)}"); + if (i == 0 && Json.Str(b, "result") is "failed" or "partiallySucceeded") signals.Add($"the latest linked build ({Json.Str(b, "buildNumber")}) {Json.Str(b, "result")}"); + } + if (buildIds.Count > shownBuilds.Count) sb.AppendLine($"- … {buildIds.Count - shownBuilds.Count} older build(s)"); + } + + // 4. Where it is: every release stage whose source is a branch the change is on. + // Releases live with the code, which can be in another project than the work item. + var projects = changes.Select(x => x.Change.Project).Append(project).Where(p => p is not null).Select(p => p!) + .Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var deployments = (await Task.WhenAll(projects.Select(p => code.DeploymentsAsync(p, ct)))).SelectMany(x => x).ToList(); + var env = session.Config.Environments; + string Label(string? branch, string def, string stage) + { + var key = branch is null ? "" : branch.StartsWith("$/", StringComparison.Ordinal) ? branch[(branch.LastIndexOf('/') + 1)..] : Code.Short(branch)!; + return env.TryGetValue(key, out var l) ? $"{l} ({def} / {stage})" : $"{def} / {stage}"; + } + var where = new List(); + var anyIn = false; + foreach (var d in deployments.OrderBy(d => d.Definition).ThenBy(d => d.Stage)) + { + bool? contains = null; + if (d.Branch?.StartsWith("$/", StringComparison.Ordinal) == true) + { + var onBranch = changes.Where(c => c.Change.Kind == "changeset" && string.Equals(branches.GetValueOrDefault(c.Change.Id), d.Branch, StringComparison.OrdinalIgnoreCase)) + .Select(c => int.Parse(c.Change.Id, CultureInfo.InvariantCulture)).ToList(); + if (onBranch.Count == 0) continue; + contains = int.TryParse(d.SourceVersion, out var v) ? v >= onBranch.Max() : null; + } + else if (d.SourceVersion is { } deployedCommit && d.Repository is { } repo) + { + var gitChanges = changes.Select(x => x.Change) + .Where(x => x.Kind != "changeset" && string.Equals(x.RepoId, repo, StringComparison.OrdinalIgnoreCase)).ToList(); + if (gitChanges.Count == 0) continue; + var results = new List(); + // A PR not completed has not landed anywhere; everything else is checked against the deployed commit. + foreach (var g in gitChanges) + results.Add(g.LandedCommit is { } commit ? await code.ContainsAsync(repo, commit, deployedCommit, false, ct) : false); + contains = results.All(r => r == true) ? true : results.Any(r => r == false) ? false : null; + } + else continue; + anyIn |= contains == true; + where.Add($"- {Label(d.Branch, d.Definition, d.Stage)}: " + contains switch + { + true => $"YES - deployed {Date(d.When)} (build {d.BuildNumber})", + false => $"NOT YET - last deployment {Date(d.When)} (build {d.BuildNumber}) does not contain it", + _ => $"unknown - last deployment {Date(d.When)} (build {d.BuildNumber})", + }); + } + // Git branches named in the environment map, even without release pipelines: is the change merged there? + foreach (var (branch, label) in env.Where(e => !e.Key.Contains('/'))) + foreach (var group in changes.Select(x => x.Change).Where(x => x.Kind != "changeset" && x.RepoId is not null).GroupBy(x => x.RepoId!)) + { + var commits = group.Select(x => x.LandedCommit).ToList(); + var merged = !commits.Any(x => x is null) + && (await Task.WhenAll(commits.Select(x => code.ContainsAsync(group.Key, x!, branch, true, ct)))).All(r => r == true); + where.Add($"- {label} branch {branch} ({group.First().Repo}): {(merged ? "merged" : "NOT merged yet")}"); + } + if (where.Count > 0) sb.AppendLine("\nWHERE").AppendLine(string.Join('\n', where)); + else if (changes.Count > 0) + sb.AppendLine("\nWHERE").AppendLine("no release stage deploys the branches of this change" + + (env.Count == 0 ? $"; map branches to environments with 'adolens config --connection {session.Config.Connection} --env staging=UAT'" : "")); + if (changes.Count > 0 && where.Count > 0 && !anyIn && !where.Any(w => w.Contains(": merged", StringComparison.Ordinal))) + signals.Add("not deployed to any stage yet"); + + // 5. Tests linked to the requirement. + var tests = Json.Arr(wi, "relations").Where(r => Json.Str(r, "rel") == "Microsoft.VSTS.Common.TestedBy-Forward") + .Select(r => Json.Str(r, "url") ?? "").Select(u => int.TryParse(u[(u.LastIndexOf('/') + 1)..], out var t) ? t : 0).Where(t => t > 0).ToList(); + if (tests.Count > 0) + { + var testItems = await work.GetAsync(tests, relations: false, ["System.Id", "System.WorkItemType", "System.State", "System.Title", "System.TeamProject"], ct); + sb.AppendLine("\nTESTS").AppendLine(string.Join('\n', testItems.Select(t => "- " + WorkItemFormat.Header(t)))); + } + if (children.Count > 0 && !summary) + sb.AppendLine("\nTASKS").AppendLine(string.Join('\n', children.Select(t => $"- {WorkItemFormat.Header(t)}{(WorkItems.Field(t, "System.AssignedTo") is { } who ? $" ({who})" : "")}"))); + + sb.AppendLine("\nSIGNALS").AppendLine(signals.Count == 0 ? "- none" : string.Join('\n', signals.Select(x => "- " + x))); + return sb.ToString().TrimEnd(); + } + + [GeneratedRegex(@"^(?:c|cs|changeset)?\s*#?(?\d+)$", RegexOptions.IgnoreCase)] + private static partial Regex ChangesetRef(); + + [GeneratedRegex(@"^(?:pr|pull\s*request|!)\s*#?(?\d+)$", RegexOptions.IgnoreCase)] + private static partial Regex PullRequestRef(); + + [GeneratedRegex(@"^(?:commit\s+)?(?[0-9a-f]{7,40})$", RegexOptions.IgnoreCase)] + private static partial Regex CommitRef(); + + /// A changeset ("523", "C523"), pull request ("PR 482", "!482") or commit (sha, with repo): files, and diffs. + public static async Task ChangesAsync(Lens lens, string? connection, string change, string? repo, string? file, string? detail, CancellationToken ct = default) + { + var session = lens.For(connection); + var code = session.Code; + var text = change.Trim(); + CodeChange c; + Func> versions; + if (PullRequestRef().Match(text) is { Success: true } pm) + { + c = await code.PullRequestAsync(repo is null ? null : (await code.RepoAsync(repo, session.Config.Project, ct)).Id, int.Parse(pm.Groups["n"].Value, CultureInfo.InvariantCulture), withFiles: true, ct); + versions = async f => (f.ChangeType.Contains("add") ? null : await code.GitFileAsync(c.RepoId!, f.OldPath ?? f.Path, c.BaseCommit, ct), + f.ChangeType.Contains("delete") ? null : await code.GitFileAsync(c.RepoId!, f.Path, c.HeadCommit, ct)); + } + else if (ChangesetRef().Match(text) is { Success: true } cm && !(text.Length >= 7 && CommitRef().IsMatch(text) && text.Any(char.IsLetter))) + { + var n = int.Parse(cm.Groups["n"].Value, CultureInfo.InvariantCulture); + c = await code.ChangesetAsync(n, ct); + versions = async f => (f.ChangeType.Contains("add") && !f.ChangeType.Contains("merge") ? null : await code.TfvcFileAsync(f.OldPath ?? f.Path, n - 1, ct), + f.ChangeType.Contains("delete") ? null : await code.TfvcFileAsync(f.Path, n, ct)); + } + else if (CommitRef().Match(text) is { Success: true } gm) + { + if (repo is null) throw new ArgumentException("a commit needs repo= (ado_trace shows it next to the commit)"); + var r = await code.RepoAsync(repo, session.Config.Project, ct); + c = await code.CommitAsync(r.Id, gm.Groups["sha"].Value, ct); + versions = async f => (f.ChangeType.Contains("add") ? null : await code.GitFileAsync(r.Id, f.OldPath ?? f.Path, c.Parent, ct), + f.ChangeType.Contains("delete") ? null : await code.GitFileAsync(r.Id, f.Path, c.Id, ct)); + } + else throw new ArgumentException("change is a changeset (523, C523), a pull request (PR 482) or a commit sha with repo"); + + var sb = new StringBuilder(); + var head = c.Kind == "pr" + ? $"{c.Label} {c.State}: {c.TargetBranch} <- {c.Branch}, \"{c.Message}\", {c.Author}" + : $"{c.Label} {Date(c.Date)} {c.Author}: \"{Formatting.Collapse(c.Message ?? "", 200)}\""; + sb.AppendLine(head); + + if (file is not null) + { + var matches = c.Files.Where(f => f.Path.EndsWith(file.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase)).ToList(); + if (matches.Count == 0) matches = c.Files.Where(f => f.Path.Contains(file, StringComparison.OrdinalIgnoreCase)).ToList(); + if (matches.Count == 0) return sb.Append($"no changed file matches '{file}'; files: {string.Join(", ", c.Files.Select(f => f.Path[(f.Path.LastIndexOf('/') + 1)..]).Take(30))}").ToString(); + if (matches.Count > 1) return sb.Append($"'{file}' matches {matches.Count} files: {string.Join("; ", matches.Select(f => f.Path).Take(15))} - give more of the path").ToString(); + var f = matches[0]; + var (before, after) = await versions(f); + sb.AppendLine($"{f.Path} ({f.ChangeType})"); + if (before is null && after is null) return sb.Append("content not readable (binary file or no access)").ToString(); + var max = detail == "full" ? 5000 : 400; + sb.Append(AotDiff.IsAot(f.Path) ? AotDiff.Detail(before, after, max) + : Diff.Unified(before ?? "", after ?? "", 3, max) ?? "no change in the text"); + return sb.ToString().TrimEnd(); + } + + sb.AppendLine($"{c.Files.Count} file(s):"); + var list = detail == "full" ? c.Files : c.Files.Take(40).ToList(); + // A one-line summary per X++ object: which methods were added, changed or removed. + var summaries = new Dictionary(); + if (detail != "summary") + await Parallel.ForEachAsync(list.Where(f => AotDiff.IsAot(f.Path)).Take(20), new ParallelOptions { MaxDegreeOfParallelism = 6, CancellationToken = ct }, + async (f, _) => + { + var (before, after) = await versions(f); + lock (summaries) summaries[f.Path] = AotDiff.Summary(before, after); + }); + foreach (var f in list) + sb.AppendLine($"- {(AotDiff.IsAot(f.Path) ? AotDiff.ObjectName(f.Path) : f.Path[(f.Path.LastIndexOf('/') + 1)..])} [{f.ChangeType}]" + + (summaries.TryGetValue(f.Path, out var sum) ? $": {sum}" : "")); + if (list.Count < c.Files.Count) sb.AppendLine($"- … {c.Files.Count - list.Count} more (detail=full)"); + sb.Append("file= shows the diff of one file (X++ per method)."); + return sb.ToString(); + } +} diff --git a/tests/AdoLens.Tests/SetupTests.cs b/tests/AdoLens.Tests/SetupTests.cs index d04b48d..78f8916 100644 --- a/tests/AdoLens.Tests/SetupTests.cs +++ b/tests/AdoLens.Tests/SetupTests.cs @@ -251,37 +251,37 @@ public async Task Throttling_is_retried_and_errors_are_readable() public class ToolBudgetTests { + static List RegisteredTools(Settings settings) + { + var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + Microsoft.Extensions.DependencyInjection.McpServerServiceCollectionExtensions.AddMcpServer(services).WithActiveTools(settings); + var sp = Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(services); + return Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetServices(sp).ToList(); + } + /// - /// Instructions and tool definitions are sent in every session; keep them small. Estimate: descriptions and names - /// plus the JSON schema around them (~100 characters per tool, ~40 per parameter), about 4 characters per token. + /// Instructions and tool definitions are sent in every session. A useful tool is worth its tokens, so this is no + /// cap on features - it is an alarm for accidental bloat (a description grown to half a page). Measured on what + /// Claude really gets: the instructions plus each tool's JSON as the MCP SDK writes it, about 4 characters per token. + /// Raise the limit on purpose when new tools need it. /// [Theory] - [InlineData("po", 3000)] - [InlineData("dev", 3500)] - public void Tool_definitions_stay_within_budget(string mode, int maxTokens) + [InlineData("po", 6000)] + [InlineData("dev", 6000)] + public void Tool_definitions_do_not_grow_by_accident(string mode, int maxTokens) { var settings = new Settings { Mode = mode }; - var chars = McpTools.Instructions(settings).Length; - foreach (var m in Toolsets.TypesFor(settings).SelectMany(t => t.GetMethods()).Where(m => m.GetCustomAttribute() is not null)) - { - chars += 100 + m.GetCustomAttribute()!.Name!.Length; - chars += m.GetCustomAttribute()?.Description.Length ?? 0; - foreach (var p in m.GetParameters().Where(p => p.ParameterType != typeof(Lens) && p.ParameterType != typeof(CancellationToken))) - chars += 40 + p.Name!.Length + (p.GetCustomAttribute()?.Description.Length ?? 0); - } + var chars = McpTools.Instructions(settings).Length + + RegisteredTools(settings).Sum(t => System.Text.Json.JsonSerializer.Serialize(t.ProtocolTool, ModelContextProtocol.McpJsonUtilities.DefaultOptions).Length); Assert.True(chars / 4 < maxTokens, $"mode {mode}: tool definitions take ~{chars / 4} tokens ({chars} characters)"); } [Fact] public void The_server_really_registers_the_tools_of_the_mode() { - var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); - Microsoft.Extensions.DependencyInjection.McpServerServiceCollectionExtensions.AddMcpServer(services).WithActiveTools(new Settings { Mode = "po" }); - using var sp = Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(services); - var names = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetServices(sp) - .Select(t => t.ProtocolTool.Name).Order().ToList(); - Assert.Equal(["ado_backlog", "ado_board", "ado_comment", "ado_create", "ado_fields", "ado_link", "ado_my_day", "ado_query", - "ado_sprint", "ado_status", "ado_update", "ado_wiki", "ado_wiki_write", "ado_work_item"], names); + 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_sprint", "ado_status", "ado_trace", "ado_update", "ado_wiki", "ado_wiki_write", "ado_work_item"], names); } [Fact] diff --git a/tests/AdoLens.Tests/TraceTests.cs b/tests/AdoLens.Tests/TraceTests.cs new file mode 100644 index 0000000..c2676e2 --- /dev/null +++ b/tests/AdoLens.Tests/TraceTests.cs @@ -0,0 +1,192 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; + +namespace AdoLens.Tests; + +public class DiffTests +{ + [Fact] + public void Unified_diff_shows_changes_with_context_and_line_numbers() + { + var before = string.Join('\n', Enumerable.Range(1, 20).Select(i => $"line {i}")); + var after = before.Replace("line 10", "line ten").Replace("line 20", "line 20\nline 21"); + var diff = Diff.Unified(before, after, context: 2)!.Replace("\r", ""); + + Assert.Equal("@@ -8,5 +8,5 @@\n line 8\n line 9\n-line 10\n+line ten\n line 11\n line 12\n@@ -19,2 +19,3 @@\n line 19\n line 20\n+line 21", diff); + Assert.Null(Diff.Unified("same\ntext", "same\ntext")); + } + + [Fact] + public void Diff_of_completely_different_texts_lists_both_sides() + { + var ops = Diff.Lines(["a", "b"], ["c"]); + Assert.Equal([(Diff.Op.Del, "a"), (Diff.Op.Del, "b"), (Diff.Op.Add, "c")], ops); + } + + const string FormV1 = """ + + DemoForm + + + init + old + + + SalesLine + validateWrite + + + + Old + + """; + + static string FormV2 => FormV1 + .Replace("return super();", "boolean ret = super();\n return ret && this.checkLimit();") + .Replace("old", "checkLimit") + .Replace("Old", "New"); + + [Fact] + public void Aot_changes_are_reported_per_method_with_data_source_names() + { + Assert.Equal("+checkLimit, ~SalesLine.validateWrite, -old, metadata changed", AotDiff.Summary(FormV1, FormV2)); + Assert.Equal("new, 3 method(s): init, old, SalesLine.validateWrite", AotDiff.Summary(null, FormV1)); + Assert.Equal("deleted", AotDiff.Summary(FormV1, null)); + + var detail = AotDiff.Detail(FormV1, FormV2).Replace("\r", ""); + Assert.Contains("~ SalesLine.validateWrite\n@@", detail); + Assert.Contains("+ return ret && this.checkLimit();", detail); + Assert.Contains("+ checkLimit (new)\n+boolean checkLimit() { return true; }", detail); + Assert.Contains("- old (removed)", detail); + Assert.Contains("- Old", detail); + Assert.DoesNotContain("super();\n+", detail.Split("metadata")[1]); + } + + [Theory] + [InlineData("$/P/Main/Metadata/M/M/AxClass/SalesTable_Extension.xml", "SalesTable_Extension (class)")] + [InlineData("/src/M/AxTableExtension/CustTable.Demo.xml", "CustTable.Demo (table extension)")] + [InlineData("/src/M/AxEdt/DemoAmount.xml", "DemoAmount (EDT)")] + [InlineData("/src/M/AxMenuItemDisplay/DemoForm.xml", "DemoForm (display menu item)")] + public void Aot_paths_become_object_names(string path, string expected) + { + Assert.True(AotDiff.IsAot(path)); + Assert.Equal(expected, AotDiff.ObjectName(path)); + } + + [Fact] + public void Non_aot_files_are_not_mistaken_for_aot() => Assert.False(AotDiff.IsAot("/web/Axios/config.xml")); +} + +public class TraceTests : IDisposable +{ + readonly string dir = Path.Combine(Path.GetTempPath(), $"adolens-trace-{Guid.NewGuid():N}"); + readonly DemoProject demo = new(); + readonly DemoItems items = new(); + readonly Lens lens; + + static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK) => + new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + + static string Content(string source) => + new JsonObject { ["path"] = "x", ["content"] = $"Foorun" }.ToJsonString(); + + public TraceTests() + { + var ado = items.Routes(demo.Routes(new FakeAdo())) + // Changes come in pages until an empty one. + .On("/contoso/_apis/tfvc/changesets/523/changes", r => Json(Uri.UnescapeDataString(r.RequestUri!.Query).Contains("$skip=0") ? """ + {"value":[{"item":{"path":"$/Demo/Main/Metadata/M/M/AxClass/Foo.xml"},"changeType":"edit"}, + {"item":{"path":"$/Demo/Main/Readme.txt"},"changeType":"edit"}]} + """ : """{"value":[]}""")) + .On("/contoso/_apis/tfvc/changesets/523", """{"changesetId":523,"author":{"displayName":"Ben Kowalski"},"createdDate":"2026-10-01T10:00:00Z","comment":"Fix login"}""") + .On("/contoso/Demo/_apis/tfvc/branches", """{"value":[{"path":"$/Demo/Main","children":[{"path":"$/Demo/Release"}]}]}""") + .On("/contoso/_apis/tfvc/items", r => Json(r.RequestUri!.Query.Contains("version=522") + ? Content("void run()\n{\n login();\n}") + : r.RequestUri!.Query.Contains("Readme") ? """{"content":"old readme"}""" : Content("void run()\n{\n if (this.check())\n login();\n}"))) + .On("/contoso/_apis/git/repositories/r1/pullrequests/482", """ + {"pullRequestId":482,"status":"completed","title":"Login form","createdBy":{"displayName":"Ben Kowalski"},"closedDate":"2026-10-02T10:00:00Z", + "sourceRefName":"refs/heads/feature/1-login","targetRefName":"refs/heads/dev","lastMergeCommit":{"commitId":"c-merge"}, + "repository":{"id":"r1","name":"portal","project":{"name":"Demo"}}} + """) + .On("/contoso/Demo/_apis/build/builds/900", """{"message":"The requested build 900 could not be found."}""", HttpStatusCode.NotFound) + .On("/contoso/Demo/_apis/release/deployments", """ + {"value":[ + {"releaseDefinition":{"name":"Main release"},"releaseEnvironment":{"name":"Stage 1"},"completedOn":"2026-10-03T01:00:00Z", + "release":{"artifacts":[{"isPrimary":true,"definitionReference":{"branch":{"id":"$/Demo/Main"},"sourceVersion":{"id":"600"},"version":{"name":"26.10.3.1"}}}]}}, + {"releaseDefinition":{"name":"Shop staging"},"releaseEnvironment":{"name":"lcs"},"completedOn":"2026-10-04T01:00:00Z", + "release":{"artifacts":[{"isPrimary":true,"definitionReference":{"branch":{"id":"refs/heads/staging"},"sourceVersion":{"id":"staging-sha"},"version":{"name":"2026.10.4.1"},"repository":{"id":"r1"}}}]}}, + {"releaseDefinition":{"name":"Shop master"},"releaseEnvironment":{"name":"lcs"},"completedOn":"2026-09-20T01:00:00Z", + "release":{"artifacts":[{"isPrimary":true,"definitionReference":{"branch":{"id":"refs/heads/master"},"sourceVersion":{"id":"master-sha"},"version":{"name":"2026.9.20.1"},"repository":{"id":"r1"}}}]}}, + {"releaseDefinition":{"name":"Other"},"releaseEnvironment":{"name":"x"},"completedOn":"2026-10-04T01:00:00Z", + "release":{"artifacts":[{"definitionReference":{"branch":{"id":"$/Demo/Release"},"sourceVersion":{"id":"700"}}}]}}]} + """) + .On("/contoso/_apis/git/repositories/r1/diffs/commits", r => + { + // c-merge is in staging (and the staging deployment) but not yet in master. + var q = Uri.UnescapeDataString(r.RequestUri!.Query); + return Json(q.Contains("targetVersion=staging") ? """{"commonCommit":"c-merge"}""" + : q.Contains("targetVersion=master") ? """{"commonCommit":"older"}""" + : q.Contains("staging-sha") ? """{"commonCommit":"c-merge"}""" : """{"commonCommit":"older"}"""); + }) + .On("/contoso/Demo/_apis/wit/workItems/1/updates", """ + {"value":[ + {"id":1,"revisedBy":{"displayName":"Anna Nowak"},"fields":{"System.ChangedDate":{"newValue":"2026-09-28T10:00:00Z"},"Microsoft.VSTS.Common.AcceptanceCriteria":{"newValue":"
a
"}}}, + {"id":2,"revisedBy":{"displayName":"Anna Nowak"},"fields":{"System.ChangedDate":{"newValue":"2026-10-05T10:00:00Z"},"Microsoft.VSTS.Common.AcceptanceCriteria":{"oldValue":"
a
","newValue":"
b
"}}}]} + """); + var s = new Settings { DefaultConnection = "contoso" }; + s.Connections["contoso"] = new Connection + { + Organization = "contoso", Project = "Demo", + Environments = new(StringComparer.OrdinalIgnoreCase) { ["staging"] = "UAT", ["master"] = "PROD" }, + }; + lens = new Lens(s, "0.0.0", dir, _ => null, _ => "pat", _ => ado.Client()); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + + [Fact] + public async Task Trace_puts_the_criteria_next_to_the_code_and_says_where_it_is() + { + var text = (await TraceQueries.TraceAsync(lens, null, 1, null)).Replace("\r", ""); + + Assert.Contains("ASKED\nacceptance criteria:\n- user can sign in", text); + Assert.Contains("- changeset 523 2026-10-01 Ben Kowalski: \"Fix login\" on $/Demo/Main", text); + Assert.Contains(" 2 file(s): Foo (class), Readme.txt", text); + Assert.Contains("- PR 482 (portal) completed 2026-10-02: dev <- feature/1-login, \"Login form\", Ben Kowalski", text); + Assert.Contains("- build 900: no longer kept (deleted by retention)", text); + Assert.Contains("- Main release / Stage 1: YES - deployed 2026-10-03 (build 26.10.3.1)", text); + Assert.Contains("- UAT (Shop staging / lcs): YES - deployed 2026-10-04 (build 2026.10.4.1)", text); + Assert.Contains("- PROD (Shop master / lcs): NOT YET - last deployment 2026-09-20 (build 2026.9.20.1) does not contain it", text); + Assert.Contains("- UAT branch staging (portal): merged", text); + Assert.Contains("- PROD branch master (portal): NOT merged yet", text); + Assert.DoesNotContain("Other / x", text); // a stage of a branch this change is not on + Assert.Contains("acceptance criteria changed after the first code change (2026-10-05 Anna Nowak)", text); + Assert.Contains("TASKS\n- #2 Task [To Do] Build the form", text); + } + + [Fact] + public async Task Changes_list_objects_with_methods_and_diff_one_file() + { + var list = (await TraceQueries.ChangesAsync(lens, null, "C523", null, null, null)).Replace("\r", ""); + Assert.Contains("- Foo (class) [edit]: ~run", list); + Assert.Contains("- Readme.txt [edit]", list); + + var diff = (await TraceQueries.ChangesAsync(lens, null, "523", null, "foo.xml", null)).Replace("\r", ""); + Assert.Contains("~ run\n@@ -1,4 +1,5 @@\n void run()\n {\n- login();\n+ if (this.check())\n+ login();\n }", diff); + + var none = await TraceQueries.ChangesAsync(lens, null, "523", null, "Bar.xml", null); + Assert.Contains("no changed file matches 'Bar.xml'; files: Foo.xml, Readme.txt", none); + await Assert.ThrowsAsync(() => TraceQueries.ChangesAsync(lens, null, "abc1234def", null, null, null)); + } +} From 171775caf2548f570dec89c0bde4d448327ea387 Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:28:18 +0200 Subject: [PATCH 2/6] Trace: per-stage PRs, release merges, merged PR commits Checked on a real Git flow (feature branch -> dev, -> staging, -> release branch -> master): - a stage has the work when any of its PRs or commits reached it; later changes not there yet are named (unless a later PR from the same source branch already is) - a PR and its merge commit are one change (the PR keeps the files) - merges of long-lived branches (release -> master) are one line and count as evidence, not as work - repeated file lists become "same N files as PR x" - a requirement uses its parent's text only when it has none of its own - empty bold markers from description templates are dropped --- src/AdoLens/Html.cs | 9 ++- src/AdoLens/Trace.cs | 121 ++++++++++++++++++++++-------- tests/AdoLens.Tests/TraceTests.cs | 21 +++++- 3 files changed, 116 insertions(+), 35 deletions(-) diff --git a/src/AdoLens/Html.cs b/src/AdoLens/Html.cs index 63319c6..2a8392f 100644 --- a/src/AdoLens/Html.cs +++ b/src/AdoLens/Html.cs @@ -22,7 +22,10 @@ public static partial class Html [GeneratedRegex(@"\b(href|src|alt)\s*=\s*(?:""([^""]*)""|'([^']*)'|([^\s>]+))", RegexOptions.IgnoreCase)] private static partial Regex Attribute(); - [GeneratedRegex(@"[ \t ]+")] + [GeneratedRegex(@"\*\*\s*\n\s*\*\*")] + private static partial Regex EmptyBold(); + + [GeneratedRegex(@"[ \t\u00A0]+")] private static partial Regex Spaces(); [GeneratedRegex(@"\n{3,}")] @@ -122,7 +125,9 @@ static string Tidy(string md) output.Add(l.Trim().Length == 0 ? "" : code || IsListItem(l.TrimStart()) ? l : l.TrimStart()); } var text = string.Join('\n', output); - text = text.Replace("****", "").Replace("** **", " "); + // Bold around nothing (empty template headings, bold line breaks) leaves stray markers. + text = EmptyBold().Replace(text.Replace("****", "").Replace("** **", " "), ""); + text = string.Join('\n', text.Split('\n').Where(l => l.Trim() is not ("**" or "*"))); return BlankLines().Replace(text, "\n\n").Trim(); } diff --git a/src/AdoLens/Trace.cs b/src/AdoLens/Trace.cs index 8097eb7..99dcc24 100644 --- a/src/AdoLens/Trace.cs +++ b/src/AdoLens/Trace.cs @@ -68,8 +68,10 @@ string Category(JsonElement x) => var criteria = Html.ToMarkdown(WorkItems.Field(wi, WorkItems.AcceptanceRef)); var description = Html.ToMarkdown(WorkItems.Field(wi, WorkItems.DescriptionRef) ?? WorkItems.Field(wi, WorkItems.ReproStepsRef)); sb.AppendLine("\nASKED"); - // A task rarely has criteria of its own: what was asked is in its parent (story, bug). - if (criteria.Length == 0 && Json.Arr(wi, "relations").FirstOrDefault(r => Json.Str(r, "rel") == "System.LinkTypes.Hierarchy-Reverse") is { ValueKind: JsonValueKind.Object } up + // A task rarely has criteria of its own: what was asked is in its parent (story, bug). A requirement's parent + // (a feature or epic) is broader than the requirement, so it is used only when the item says nothing at all. + var isTask = s.FindType(WorkItems.Field(wi, "System.WorkItemType") ?? "")?.Level == Levels.Task; + if (criteria.Length == 0 && (isTask || description.Length == 0) && Json.Arr(wi, "relations").FirstOrDefault(r => Json.Str(r, "rel") == "System.LinkTypes.Hierarchy-Reverse") is { ValueKind: JsonValueKind.Object } up && int.TryParse((Json.Str(up, "url") ?? "")[((Json.Str(up, "url") ?? "").LastIndexOf('/') + 1)..], out var parentId) && (await work.GetAsync([parentId], relations: false, ct: ct)).FirstOrDefault() is { ValueKind: JsonValueKind.Object } parent) { @@ -111,16 +113,29 @@ await Parallel.ForEachAsync(links.Where(l => l.Kind != "build/build" && l.Kind ! } catch (AdoApiException ex) { lock (failed) failed.Add($"{l.Kind.Split('/')[^1]} {l.Value.Split('/')[^1]}: {Formatting.Collapse(ex.Message, 100)}"); } }); - changes = changes.OrderBy(c => c.Change.Date).ToList(); + changes = Combine(changes.OrderBy(c => c.Change.Date).ToList()); + var env = session.Config.Environments; + // A PR from a long-lived branch (a release branch into master, staging into master…) carries many items at once: + // it is shown in one line and counts as evidence of where the work is, not as work of its own. + var prTargets = changes.Where(x => x.Change.Kind == "pr").Select(x => x.Change.TargetBranch).Where(t => t is not null).ToHashSet(StringComparer.OrdinalIgnoreCase); + bool IsPromotion(CodeChange x) => + x.Kind == "pr" && x.Branch is { } src && (env.ContainsKey(src) || prTargets.Contains(src) || x.Files.Count > 100); var branches = new Dictionary(); foreach (var cs in changes.Select(x => x.Change).Where(x => x.Kind == "changeset")) branches[cs.Id] = cs.Files.Count > 0 ? await code.TfvcBranchAsync(cs.Files[0].Path, ct) : null; sb.AppendLine("\nDONE"); if (changes.Count == 0) sb.AppendLine("no changesets, commits or pull requests linked to the item or its tasks"); + var seenFiles = new Dictionary(); foreach (var (c, from) in changes) { var via = from == id ? "" : $" (via #{from})"; + if (IsPromotion(c)) + { + sb.AppendLine($"- {c.Label} {c.State}{(c.State == "completed" ? $" {Date(c.Date)}" : "")}: {c.TargetBranch} <- {c.Branch} " + + $"(merge of branch {c.Branch}{(c.Files.Count > 0 ? $", {c.Files.Count} files" : "")}){via}"); + continue; + } var line = c.Kind switch { "pr" => $"{c.Label} {c.State}{(c.State == "completed" ? $" {Date(c.Date)}" : "")}: {c.TargetBranch} <- {c.Branch}, \"{Formatting.Collapse(c.Message ?? "", 80)}\", {c.Author}", @@ -131,6 +146,10 @@ await Parallel.ForEachAsync(links.Where(l => l.Kind != "build/build" && l.Kind ! if (!summary && c.Files.Count > 0) { var objects = Objects(c).ToList(); + // The same branch is often merged into dev, staging and a release branch: say so instead of repeating. + var key = string.Join('|', objects.Order(StringComparer.OrdinalIgnoreCase)); + if (seenFiles.TryGetValue(key, out var earlier)) { sb.AppendLine($" same {c.Files.Count} file(s) as {earlier}"); continue; } + seenFiles[key] = c.Label.Split(" (")[0]; var shown = full ? objects : objects.Take(12).ToList(); sb.AppendLine($" {c.Files.Count} file(s): {string.Join(", ", shown)}{(objects.Count > shown.Count ? $", … {objects.Count - shown.Count} more" : "")}"); } @@ -172,7 +191,6 @@ await Parallel.ForEachAsync(links.Where(l => l.Kind != "build/build" && l.Kind ! var projects = changes.Select(x => x.Change.Project).Append(project).Where(p => p is not null).Select(p => p!) .Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var deployments = (await Task.WhenAll(projects.Select(p => code.DeploymentsAsync(p, ct)))).SelectMany(x => x).ToList(); - var env = session.Config.Environments; string Label(string? branch, string def, string stage) { var key = branch is null ? "" : branch.StartsWith("$/", StringComparison.Ordinal) ? branch[(branch.LastIndexOf('/') + 1)..] : Code.Short(branch)!; @@ -180,50 +198,71 @@ string Label(string? branch, string def, string stage) } var where = new List(); var anyIn = false; + // Git: a stage has the work when any of its changes (a PR merge, a commit) is in what was deployed. Changes that + // are not there yet are named, unless a later change from the same source branch already is (it carries them). + var containsCache = new Dictionary<(string, string, bool), bool?>(); + async Task Contains(string repo, string commit, string target, bool isBranch) + { + if (!containsCache.TryGetValue((commit, target, isBranch), out var r)) + containsCache[(commit, target, isBranch)] = r = await code.ContainsAsync(repo, commit, target, isBranch, ct); + return r; + } + async Task<(List In, List NotYet)> GitStatus(string repo, string target, bool isBranch) + { + var git = changes.Select(x => x.Change).Where(x => x.Kind != "changeset" && string.Equals(x.RepoId, repo, StringComparison.OrdinalIgnoreCase)).ToList(); + var inside = new List(); + foreach (var g in git) + if (g.LandedCommit is { } commit && await Contains(repo, commit, target, isBranch) == true) inside.Add(g); + var notYet = git.Where(g => !inside.Contains(g) && !IsPromotion(g) + && !inside.Any(i => i.Branch is not null && i.Branch == g.Branch && i.Date >= g.Date)).ToList(); + return (inside, notYet); + } + static string Names(IEnumerable list) => + string.Join(", ", list.Select(x => x.Kind == "pr" ? $"PR {x.Id} ({x.TargetBranch}, {x.Date?.ToLocalTime():MM-dd})" : $"commit {x.Id[..Math.Min(7, x.Id.Length)]}")); + foreach (var d in deployments.OrderBy(d => d.Definition).ThenBy(d => d.Stage)) { - bool? contains = null; + var label = $"- {Label(d.Branch, d.Definition, d.Stage)}: "; + var deployed = $"{Date(d.When)} (build {d.BuildNumber})"; if (d.Branch?.StartsWith("$/", StringComparison.Ordinal) == true) { var onBranch = changes.Where(c => c.Change.Kind == "changeset" && string.Equals(branches.GetValueOrDefault(c.Change.Id), d.Branch, StringComparison.OrdinalIgnoreCase)) .Select(c => int.Parse(c.Change.Id, CultureInfo.InvariantCulture)).ToList(); if (onBranch.Count == 0) continue; - contains = int.TryParse(d.SourceVersion, out var v) ? v >= onBranch.Max() : null; + bool? contains = int.TryParse(d.SourceVersion, out var v) ? v >= onBranch.Max() : null; + anyIn |= contains == true; + where.Add(label + contains switch + { + true => $"YES - deployed {deployed}", + false => $"NOT YET - last deployment {deployed} does not contain it", + _ => $"unknown - last deployment {deployed}", + }); } - else if (d.SourceVersion is { } deployedCommit && d.Repository is { } repo) + else if (d.SourceVersion is { } deployedCommit && d.Repository is { } repo + && changes.Any(x => x.Change.Kind != "changeset" && string.Equals(x.Change.RepoId, repo, StringComparison.OrdinalIgnoreCase))) { - var gitChanges = changes.Select(x => x.Change) - .Where(x => x.Kind != "changeset" && string.Equals(x.RepoId, repo, StringComparison.OrdinalIgnoreCase)).ToList(); - if (gitChanges.Count == 0) continue; - var results = new List(); - // A PR not completed has not landed anywhere; everything else is checked against the deployed commit. - foreach (var g in gitChanges) - results.Add(g.LandedCommit is { } commit ? await code.ContainsAsync(repo, commit, deployedCommit, false, ct) : false); - contains = results.All(r => r == true) ? true : results.Any(r => r == false) ? false : null; + var (inside, notYet) = await GitStatus(repo, deployedCommit, false); + anyIn |= inside.Count > 0; + where.Add(label + (inside.Count > 0 + ? $"YES - deployed {deployed}: {Names(inside.Where(x => !IsPromotion(x)).DefaultIfEmpty(inside[0]))}" + (notYet.Count > 0 ? $"; not yet: {Names(notYet)}" : "") + : $"NOT YET - last deployment {deployed} has none of it")); } - else continue; - anyIn |= contains == true; - where.Add($"- {Label(d.Branch, d.Definition, d.Stage)}: " + contains switch - { - true => $"YES - deployed {Date(d.When)} (build {d.BuildNumber})", - false => $"NOT YET - last deployment {Date(d.When)} (build {d.BuildNumber}) does not contain it", - _ => $"unknown - last deployment {Date(d.When)} (build {d.BuildNumber})", - }); } - // Git branches named in the environment map, even without release pipelines: is the change merged there? - foreach (var (branch, label) in env.Where(e => !e.Key.Contains('/'))) + // Git branches named in the environment map, even without release pipelines: is the work merged there? + foreach (var (branch, envLabel) in env.Where(e => !e.Key.Contains('/'))) foreach (var group in changes.Select(x => x.Change).Where(x => x.Kind != "changeset" && x.RepoId is not null).GroupBy(x => x.RepoId!)) { - var commits = group.Select(x => x.LandedCommit).ToList(); - var merged = !commits.Any(x => x is null) - && (await Task.WhenAll(commits.Select(x => code.ContainsAsync(group.Key, x!, branch, true, ct)))).All(r => r == true); - where.Add($"- {label} branch {branch} ({group.First().Repo}): {(merged ? "merged" : "NOT merged yet")}"); + var (inside, notYet) = await GitStatus(group.Key, branch, true); + anyIn |= inside.Count > 0; + where.Add($"- {envLabel} branch {branch} ({group.First().Repo}): " + (inside.Count > 0 + ? $"merged: {Names(inside.Where(x => !IsPromotion(x)).DefaultIfEmpty(inside[0]))}" + (notYet.Count > 0 ? $"; not yet: {Names(notYet)}" : "") + : "NOT merged yet")); } if (where.Count > 0) sb.AppendLine("\nWHERE").AppendLine(string.Join('\n', where)); else if (changes.Count > 0) sb.AppendLine("\nWHERE").AppendLine("no release stage deploys the branches of this change" + (env.Count == 0 ? $"; map branches to environments with 'adolens config --connection {session.Config.Connection} --env staging=UAT'" : "")); - if (changes.Count > 0 && where.Count > 0 && !anyIn && !where.Any(w => w.Contains(": merged", StringComparison.Ordinal))) + if (changes.Count > 0 && where.Count > 0 && !anyIn) signals.Add("not deployed to any stage yet"); // 5. Tests linked to the requirement. @@ -241,6 +280,28 @@ string Label(string? branch, string def, string stage) return sb.ToString().TrimEnd(); } + [GeneratedRegex(@"^Merged PR (?\d+)")] + private static partial Regex MergedPr(); + + /// A completed PR and its merge commit are one change: the commit is dropped and its files go to the PR. + internal static List<(CodeChange Change, int From)> Combine(List<(CodeChange Change, int From)> changes) + { + var prs = changes.Select(x => x.Change).Where(x => x.Kind == "pr").ToList(); + CodeChange? PrOf(CodeChange commit) => + prs.FirstOrDefault(p => string.Equals(p.MergeCommit, commit.Id, StringComparison.OrdinalIgnoreCase)) + ?? (MergedPr().Match(commit.Message ?? "") is { Success: true } m ? prs.FirstOrDefault(p => p.Id == m.Groups["n"].Value) : null); + var result = new List<(CodeChange, int)>(); + foreach (var (c, from) in changes) + { + if (c.Kind == "commit" && PrOf(c) is not null) continue; + var commit = c.Kind == "pr" && c.Files.Count == 0 + ? changes.Select(x => x.Change).FirstOrDefault(x => x.Kind == "commit" && ReferenceEquals(PrOf(x), c)) + : null; + result.Add((commit is null ? c : c with { Files = commit.Files }, from)); + } + return result; + } + [GeneratedRegex(@"^(?:c|cs|changeset)?\s*#?(?\d+)$", RegexOptions.IgnoreCase)] private static partial Regex ChangesetRef(); diff --git a/tests/AdoLens.Tests/TraceTests.cs b/tests/AdoLens.Tests/TraceTests.cs index c2676e2..3103ffb 100644 --- a/tests/AdoLens.Tests/TraceTests.cs +++ b/tests/AdoLens.Tests/TraceTests.cs @@ -166,15 +166,30 @@ public async Task Trace_puts_the_criteria_next_to_the_code_and_says_where_it_is( Assert.Contains("- PR 482 (portal) completed 2026-10-02: dev <- feature/1-login, \"Login form\", Ben Kowalski", text); Assert.Contains("- build 900: no longer kept (deleted by retention)", text); Assert.Contains("- Main release / Stage 1: YES - deployed 2026-10-03 (build 26.10.3.1)", text); - Assert.Contains("- UAT (Shop staging / lcs): YES - deployed 2026-10-04 (build 2026.10.4.1)", text); - Assert.Contains("- PROD (Shop master / lcs): NOT YET - last deployment 2026-09-20 (build 2026.9.20.1) does not contain it", text); - Assert.Contains("- UAT branch staging (portal): merged", text); + Assert.Contains("- UAT (Shop staging / lcs): YES - deployed 2026-10-04 (build 2026.10.4.1): PR 482 (dev, 10-02)", text); + Assert.Contains("- PROD (Shop master / lcs): NOT YET - last deployment 2026-09-20 (build 2026.9.20.1) has none of it", text); + Assert.Contains("- UAT branch staging (portal): merged: PR 482 (dev, 10-02)", text); Assert.Contains("- PROD branch master (portal): NOT merged yet", text); Assert.DoesNotContain("Other / x", text); // a stage of a branch this change is not on Assert.Contains("acceptance criteria changed after the first code change (2026-10-05 Anna Nowak)", text); Assert.Contains("TASKS\n- #2 Task [To Do] Build the form", text); } + [Fact] + public void A_pull_request_and_its_merge_commit_are_one_change() + { + List files = [new("/src/M/AxClass/Foo.xml", "edit", null)]; + var pr = new CodeChange("pr", "482", "portal", "r1", "Ben", null, "Login form", "feature/x", "completed", "dev", "abc123", []); + var mergeCommit = new CodeChange("commit", "abc123", "portal", "r1", "Ben", null, "Merged PR 482: Login form", null, null, null, null, files); + var byMessage = new CodeChange("commit", "fff999", "portal", "r1", "Ben", null, "Merged PR 482: Login form", null, null, null, null, files); + var own = new CodeChange("commit", "def456", "portal", "r1", "Ben", null, "Fix typo", null, null, null, null, files); + + var result = TraceQueries.Combine([(pr, 1), (mergeCommit, 1), (byMessage, 2), (own, 1)]); + + Assert.Equal(["PR 482 (portal)", "commit def456 (portal)"], result.Select(r => r.Change.Label)); + Assert.Single(result[0].Change.Files); + } + [Fact] public async Task Changes_list_objects_with_methods_and_diff_one_file() { From 37851ac83396e3b68e6e2393a8e4d2f3ab9f74b0 Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:30:06 +0200 Subject: [PATCH 3/6] Environment map: any number of steps, release stages, flow order - keys are branches, release definitions or single stages ("Definition/Stage"), so one stage deploying to two environments at once gets one label - ado_trace lists the stages in the order of the map (e.g. DEV -> UAT -> PROD) - map keys that are no branch of the repository are skipped, not reported as "not merged" --- CHANGELOG.md | 6 +++++- README.md | 16 ++++++++++++---- README.pl.md | 16 ++++++++++++---- src/AdoLens/Trace.cs | 32 ++++++++++++++++++++++--------- tests/AdoLens.Tests/TraceTests.cs | 8 ++++++-- 5 files changed, 58 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eec452a..463bb52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,11 @@ Checking work against what was asked, and where it is. projects the code lives in; for Git, whether the change is merged into each mapped branch; tests, tasks, signals. - `ado_changes`: files of a changeset, pull request or commit with added / changed / removed X++ methods per object; the diff of one file — AOT XML per method plus the metadata without raw XML noise; other files as unified diffs. -- Environments per connection: `adolens config --connection --env staging=UAT` maps branches to environment names. +- Environments per connection: `adolens config --connection --env staging=UAT` maps branches, release definitions or + single release stages (`"Definition/Stage=UAT + SCM"`) to environment names; any number of steps, listed in that order. +- Git flows with a PR per step (feature -> dev, -> staging, -> release branch -> master): a stage has the work when any + of its PRs reached it, later changes not there yet are named; a PR and its merge commit are one change; release + merges are one line. - Skill `po-verify` (`skills/po-verify`): guides Claude through checking a work item for a Product Owner. - Server instructions keep only advice the tool descriptions do not give (fewer tokens per session); the tool size check measures the JSON Claude really receives and only guards against accidental growth. diff --git a/README.md b/README.md index 4239a62..ec1686d 100644 --- a/README.md +++ b/README.md @@ -96,10 +96,18 @@ adolens config --show-tokens true # end every answer with [~N tokens] adolens config --connection contoso --env staging=UAT --env master=PROD # what branches mean (for ado_trace) ``` -**Environments.** Azure DevOps does not know that the branch `staging` means UAT. Map branches to environment names per -connection (`--env branch=label`, `--remove-env branch`; for TFVC use the last part of the branch path, e.g. `Main`). -`ado_trace` then says "UAT: YES" instead of only naming the release stage, and for Git also checks whether the change is -merged into each mapped branch even when no release pipeline deploys it. +**Environments.** Azure DevOps does not know that the branch `staging` means UAT. Map your flow per connection, as many +steps as it has, in its order — `--env key=label`, `--remove-env key`. A key is a branch (`dev`, `staging`; for TFVC the +last part of the branch path, e.g. `Main`), a release definition, or one release stage as `"Definition/Stage"` — useful +when one stage deploys to two environments at once: + +```powershell +adolens config --connection contoso --env dev="DEV (sbx01)" --env staging="UAT (sbx03)" --env master=PROD +adolens config --connection fabrikam --env "Main-Deploy/Stage 1=UAT + SCM" +``` + +`ado_trace` then lists the stages in that order with their labels ("UAT (sbx03): YES") and, for Git, also checks whether +the work is merged into each mapped branch even when no release pipeline deploys it. Settings live in `%LOCALAPPDATA%\ado-lens\adolens.json` (no secrets) — they can be edited by hand too. diff --git a/README.pl.md b/README.pl.md index 9d455a2..725e4a9 100644 --- a/README.pl.md +++ b/README.pl.md @@ -96,10 +96,18 @@ adolens config --show-tokens true # każda odpowiedź kończy się [~ adolens config --connection contoso --env staging=UAT --env master=PROD # co znaczą gałęzie (dla ado_trace) ``` -**Środowiska.** Azure DevOps nie wie, że gałąź `staging` to UAT. Zmapuj gałęzie na nazwy środowisk dla połączenia -(`--env gałąź=etykieta`, `--remove-env gałąź`; dla TFVC użyj ostatniej części ścieżki gałęzi, np. `Main`). Wtedy -`ado_trace` mówi „UAT: TAK” zamiast samej nazwy etapu release'u, a dla Gita sprawdza też, czy zmiana jest scalona do -każdej zmapowanej gałęzi, nawet gdy żaden pipeline jej nie wdraża. +**Środowiska.** Azure DevOps nie wie, że gałąź `staging` to UAT. Zmapuj swój przepływ dla połączenia — tyle kroków, ile +ma, w jego kolejności — `--env klucz=etykieta`, `--remove-env klucz`. Kluczem jest gałąź (`dev`, `staging`; dla TFVC +ostatnia część ścieżki gałęzi, np. `Main`), definicja release'u albo jeden etap release'u jako `"Definicja/Etap"` — +przydatne, gdy jeden etap wdraża na dwa środowiska naraz: + +```powershell +adolens config --connection contoso --env dev="DEV (sbx01)" --env staging="UAT (sbx03)" --env master=PROD +adolens config --connection fabrikam --env "Main-Deploy/Stage 1=UAT + SCM" +``` + +Wtedy `ado_trace` wypisuje etapy w tej kolejności z ich etykietami („UAT (sbx03): YES”), a dla Gita sprawdza też, czy +praca jest scalona do każdej zmapowanej gałęzi, nawet gdy żaden pipeline jej nie wdraża. Ustawienia siedzą w `%LOCALAPPDATA%\ado-lens\adolens.json` (bez sekretów) — można je też edytować ręcznie. diff --git a/src/AdoLens/Trace.cs b/src/AdoLens/Trace.cs index 99dcc24..d5ba21d 100644 --- a/src/AdoLens/Trace.cs +++ b/src/AdoLens/Trace.cs @@ -191,11 +191,17 @@ bool IsPromotion(CodeChange x) => var projects = changes.Select(x => x.Change.Project).Append(project).Where(p => p is not null).Select(p => p!) .Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var deployments = (await Task.WhenAll(projects.Select(p => code.DeploymentsAsync(p, ct)))).SelectMany(x => x).ToList(); - string Label(string? branch, string def, string stage) + // The environment map names a release stage ("Definition/Stage", e.g. one stage deploying to UAT and SCM at once), + // a whole release definition, or a branch ("staging"; TFVC: the last part of the branch path, e.g. "Main"). + var envOrder = env.Keys.Select((k, i) => (k, i)).ToDictionary(x => x.k, x => x.i, StringComparer.OrdinalIgnoreCase); + string? EnvKey(string? branch, string def, string stage) { - var key = branch is null ? "" : branch.StartsWith("$/", StringComparison.Ordinal) ? branch[(branch.LastIndexOf('/') + 1)..] : Code.Short(branch)!; - return env.TryGetValue(key, out var l) ? $"{l} ({def} / {stage})" : $"{def} / {stage}"; + var branchKey = branch is null ? null : branch.StartsWith("$/", StringComparison.Ordinal) ? branch[(branch.LastIndexOf('/') + 1)..] : Code.Short(branch); + return new[] { $"{def}/{stage}", def, branchKey }.FirstOrDefault(k => k is not null && env.ContainsKey(k)); } + string Label(string? branch, string def, string stage) => + EnvKey(branch, def, stage) is { } k ? $"{env[k]} ({def} / {stage})" : $"{def} / {stage}"; + int Order(string? branch, string def, string stage) => EnvKey(branch, def, stage) is { } k ? envOrder[k] : int.MaxValue; var where = new List(); var anyIn = false; // Git: a stage has the work when any of its changes (a PR merge, a commit) is in what was deployed. Changes that @@ -207,20 +213,26 @@ string Label(string? branch, string def, string stage) containsCache[(commit, target, isBranch)] = r = await code.ContainsAsync(repo, commit, target, isBranch, ct); return r; } - async Task<(List In, List NotYet)> GitStatus(string repo, string target, bool isBranch) + async Task<(List In, List NotYet, bool Known)> GitStatus(string repo, string target, bool isBranch) { var git = changes.Select(x => x.Change).Where(x => x.Kind != "changeset" && string.Equals(x.RepoId, repo, StringComparison.OrdinalIgnoreCase)).ToList(); var inside = new List(); + var known = false; foreach (var g in git) - if (g.LandedCommit is { } commit && await Contains(repo, commit, target, isBranch) == true) inside.Add(g); + { + if (g.LandedCommit is not { } commit) continue; + var r = await Contains(repo, commit, target, isBranch); + known |= r is not null; + if (r == true) inside.Add(g); + } var notYet = git.Where(g => !inside.Contains(g) && !IsPromotion(g) && !inside.Any(i => i.Branch is not null && i.Branch == g.Branch && i.Date >= g.Date)).ToList(); - return (inside, notYet); + return (inside, notYet, known); } static string Names(IEnumerable list) => string.Join(", ", list.Select(x => x.Kind == "pr" ? $"PR {x.Id} ({x.TargetBranch}, {x.Date?.ToLocalTime():MM-dd})" : $"commit {x.Id[..Math.Min(7, x.Id.Length)]}")); - foreach (var d in deployments.OrderBy(d => d.Definition).ThenBy(d => d.Stage)) + foreach (var d in deployments.OrderBy(d => Order(d.Branch, d.Definition, d.Stage)).ThenBy(d => d.Definition).ThenBy(d => d.Stage)) { var label = $"- {Label(d.Branch, d.Definition, d.Stage)}: "; var deployed = $"{Date(d.When)} (build {d.BuildNumber})"; @@ -241,7 +253,7 @@ static string Names(IEnumerable list) => else if (d.SourceVersion is { } deployedCommit && d.Repository is { } repo && changes.Any(x => x.Change.Kind != "changeset" && string.Equals(x.Change.RepoId, repo, StringComparison.OrdinalIgnoreCase))) { - var (inside, notYet) = await GitStatus(repo, deployedCommit, false); + var (inside, notYet, _) = await GitStatus(repo, deployedCommit, false); anyIn |= inside.Count > 0; where.Add(label + (inside.Count > 0 ? $"YES - deployed {deployed}: {Names(inside.Where(x => !IsPromotion(x)).DefaultIfEmpty(inside[0]))}" + (notYet.Count > 0 ? $"; not yet: {Names(notYet)}" : "") @@ -249,10 +261,12 @@ static string Names(IEnumerable list) => } } // Git branches named in the environment map, even without release pipelines: is the work merged there? + // Keys that are no branch of the repository (a release definition, a TFVC branch) give no answer and are skipped. foreach (var (branch, envLabel) in env.Where(e => !e.Key.Contains('/'))) foreach (var group in changes.Select(x => x.Change).Where(x => x.Kind != "changeset" && x.RepoId is not null).GroupBy(x => x.RepoId!)) { - var (inside, notYet) = await GitStatus(group.Key, branch, true); + var (inside, notYet, known) = await GitStatus(group.Key, branch, true); + if (!known) continue; anyIn |= inside.Count > 0; where.Add($"- {envLabel} branch {branch} ({group.First().Repo}): " + (inside.Count > 0 ? $"merged: {Names(inside.Where(x => !IsPromotion(x)).DefaultIfEmpty(inside[0]))}" + (notYet.Count > 0 ? $"; not yet: {Names(notYet)}" : "") diff --git a/tests/AdoLens.Tests/TraceTests.cs b/tests/AdoLens.Tests/TraceTests.cs index 3103ffb..1003e80 100644 --- a/tests/AdoLens.Tests/TraceTests.cs +++ b/tests/AdoLens.Tests/TraceTests.cs @@ -144,7 +144,7 @@ public TraceTests() s.Connections["contoso"] = new Connection { Organization = "contoso", Project = "Demo", - Environments = new(StringComparer.OrdinalIgnoreCase) { ["staging"] = "UAT", ["master"] = "PROD" }, + Environments = new(StringComparer.OrdinalIgnoreCase) { ["staging"] = "UAT", ["master"] = "PROD", ["Main release/Stage 1"] = "UAT + SCM" }, }; lens = new Lens(s, "0.0.0", dir, _ => null, _ => "pat", _ => ado.Client()); } @@ -165,7 +165,11 @@ public async Task Trace_puts_the_criteria_next_to_the_code_and_says_where_it_is( Assert.Contains(" 2 file(s): Foo (class), Readme.txt", text); Assert.Contains("- PR 482 (portal) completed 2026-10-02: dev <- feature/1-login, \"Login form\", Ben Kowalski", text); Assert.Contains("- build 900: no longer kept (deleted by retention)", text); - Assert.Contains("- Main release / Stage 1: YES - deployed 2026-10-03 (build 26.10.3.1)", text); + // A release stage can be mapped by name (one stage deploying to two environments at once). + Assert.Contains("- UAT + SCM (Main release / Stage 1): YES - deployed 2026-10-03 (build 26.10.3.1)", text); + // Stages follow the order of the environment map, not the alphabet. + Assert.True(text.IndexOf("- UAT (Shop staging", StringComparison.Ordinal) < text.IndexOf("- PROD (Shop master", StringComparison.Ordinal) + && text.IndexOf("- PROD (Shop master", StringComparison.Ordinal) < text.IndexOf("- UAT + SCM (Main release", StringComparison.Ordinal), text); Assert.Contains("- UAT (Shop staging / lcs): YES - deployed 2026-10-04 (build 2026.10.4.1): PR 482 (dev, 10-02)", text); Assert.Contains("- PROD (Shop master / lcs): NOT YET - last deployment 2026-09-20 (build 2026.9.20.1) has none of it", text); Assert.Contains("- UAT branch staging (portal): merged: PR 482 (dev, 10-02)", text); From 8817bd71ad79b1244334cb3ce0ba0d2c9efe3f8d Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:42:01 +0200 Subject: [PATCH 4/6] Pull requests, pipelines and self-configured environments - ado_pr: PRs waiting for my review and mine; one PR with votes, checks, linked work items and the open threads people wrote (system notes and conflict resolution records left out) - ado_pr_update: create, comment on a file and line, reply, resolve, vote, publish, complete, abandon; @[name] mentions - ado_pipeline: recent builds; a failed build with its failed steps, errors and the error lines of the log without MSBuild noise; ado_pipeline_run - ado_settings saves the environment map for good; without a map ado_trace ends with a SETUP note (detected flow and a first guess) so Claude asks once - the tool size check registers Lens as a service like the server does, so it measures what clients really get - CLI: pr, pipeline --- CHANGELOG.md | 9 +- README.md | 24 ++- README.pl.md | 22 +- docs/PLAN.md | 14 ++ src/AdoLens/AdoClient.cs | 20 ++ src/AdoLens/Cli.cs | 22 ++ src/AdoLens/CommandLine.cs | 2 + src/AdoLens/Lens.cs | 15 +- src/AdoLens/McpTools.cs | 88 ++++++++ src/AdoLens/Pipelines.cs | 124 +++++++++++ src/AdoLens/Program.cs | 4 + src/AdoLens/PullRequests.cs | 278 +++++++++++++++++++++++++ src/AdoLens/Queries.cs | 28 +++ src/AdoLens/Trace.cs | 41 +++- tests/AdoLens.Tests/FakeAdo.cs | 2 +- tests/AdoLens.Tests/PrPipelineTests.cs | 173 +++++++++++++++ tests/AdoLens.Tests/SetupTests.cs | 17 +- 17 files changed, 862 insertions(+), 21 deletions(-) create mode 100644 src/AdoLens/Pipelines.cs create mode 100644 src/AdoLens/PullRequests.cs create mode 100644 tests/AdoLens.Tests/PrPipelineTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 463bb52..defe258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,14 @@ Checking work against what was asked, and where it is. - Skill `po-verify` (`skills/po-verify`): guides Claude through checking a work item for a Product Owner. - Server instructions keep only advice the tool descriptions do not give (fewer tokens per session); the tool size check measures the JSON Claude really receives and only guards against accidental growth. -- CLI: `trace`, `changes`. +- Pull requests (tool set `pr`): `ado_pr` (waiting for my review, mine, all active; one PR with votes, checks, work + items and the open threads people wrote) and `ado_pr_update` (create, comment on a file and line, reply, resolve, + vote, publish, complete, abandon; `@[name]` mentions). +- Pipelines (tool set `pipelines`): `ado_pipeline` (recent builds; a failed build with its failed steps, errors and the + error lines of the log without MSBuild noise) and `ado_pipeline_run`. +- `ado_settings`: shows the connection and saves the environment map; when the map is missing, `ado_trace` ends with a + SETUP note (the flow it found and a first guess) so Claude asks the user once and saves the answer. +- CLI: `trace`, `changes`, `pr`, `pipeline`. ## 0.1.0 — 2026-10-14 diff --git a/README.md b/README.md index ec1686d..b24b7b6 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ not thousands. Changes between versions: [CHANGELOG.md](CHANGELOG.md). The road Azure DevOps is a trademark of Microsoft. > Status: **0.x — in development.** Connections, Claude registration, the local work item structure, work items, -> sprints, boards, backlogs, the wiki and tracing work to code and deployments work; pull requests, pipelines and -> the installer follow the plan. +> sprints, boards, backlogs, the wiki, tracing work to code and deployments, pull requests and pipelines work; the +> installer follows the plan. ## What it does differently @@ -109,6 +109,10 @@ adolens config --connection fabrikam --env "Main-Deploy/Stage 1=UAT + SCM" `ado_trace` then lists the stages in that order with their labels ("UAT (sbx03): YES") and, for Git, also checks whether the work is merged into each mapped branch even when no release pipeline deploys it. +Nobody has to set this up in advance: when the map is missing, `ado_trace` ends with a short SETUP note listing the flow +it found (branches and release stages, with a first guess such as dev → DEV, staging → UAT, master → PROD). Claude asks +the user once how they call these environments and saves the answer with `ado_settings` — for good. + Settings live in `%LOCALAPPDATA%\ado-lens\adolens.json` (no secrets) — they can be edited by hand too. ### Modes and tool sets @@ -118,13 +122,15 @@ never a permission. | Tool set | Tools | po | dev | full | |---|---|:-:|:-:|:-:| -| `core` | `ado_status`, `ado_fields` | ✓ | ✓ | ✓ | +| `core` | `ado_status`, `ado_settings`, `ado_fields` | ✓ | ✓ | ✓ | | `work` | `ado_work_item`, `ado_query`, `ado_create`, `ado_update`, `ado_comment`, `ado_link` | ✓ | ✓ | ✓ | +| `pr` | `ado_pr`, `ado_pr_update` | – | ✓ | ✓ | +| `pipelines` | `ado_pipeline`, `ado_pipeline_run` | – | ✓ | ✓ | | `boards` | `ado_sprint`, `ado_board`, `ado_backlog`, `ado_my_day` | ✓ | – | ✓ | | `wiki` | `ado_wiki`, `ado_wiki_write` | ✓ | ✓ | ✓ | | `trace` | `ado_trace`, `ado_changes` | ✓ | ✓ | ✓ | | `test`, `recipes` | planned | ✓ | (recipes) | ✓ | -| `code`, `pr`, `pipelines` | planned | – | ✓ | ✓ | +| `code` | planned | – | ✓ | ✓ | ## Tool map @@ -133,6 +139,7 @@ What each MCP tool answers, when Claude should reach for it, and the CLI command | Tool | Answers | When | CLI | |---|---|---|---| | `ado_status` | version, connection, signed-in user, PAT source, metadata age | checking the setup | `adolens status` | +| `ado_settings` | the connection's settings; with `environments` saves for good what branches and release stages mean, in flow order (`dev=DEV (sbx01); staging=UAT (sbx03); master=PROD`) | `ado_trace` asks for it once (SETUP), the user answers in words | `adolens config --connection c --env dev=DEV` | | `ado_fields` | types by backlog level; for a type: states, transitions, fields, required flags, allowed values; for a field: where it is used and its values; structure changes over time | before writing fields or WIQL; "which fields does a Bug have", "what changed in our process this month" | `adolens fields --type Bug`, `adolens fields --field Severity`, `adolens fields --changes 30d` | | `ado_work_item` | one or more items: state, people, paths, filled fields, description and acceptance criteria as markdown, parent / children / related, linked changesets, commits, PRs and builds; with `include`: comments, history | "what is #123 about", "who changed the criteria and when" | `adolens item 123 456 --include comments,history --detail summary` | | `ado_query` | items found by WIQL (with `@Me`, `@CurrentIteration`…) or a saved query, one line each | lists, filters, "open bugs of the sprint" | `adolens query --wiql "SELECT …"`, `adolens query --query "Shared Queries/Open bugs"` | @@ -145,6 +152,10 @@ What each MCP tool answers, when Claude should reach for it, and the CLI command | `ado_backlog` | a team's backlog in priority order (requirement, portfolio, task, or by name); `move` + `above`/`below` reorders | refinement, priorities | `adolens backlog --top 20`, `adolens backlog --move 123 --above 456` | | `ado_trace` | was it done as asked, and where is it: the acceptance criteria (from the parent for a task) and whether they changed after coding started; changesets, commits and pull requests linked to the item and its tasks with the X++ objects they touch; builds (also those deleted by retention); every release stage of the code's branches with **YES / NOT YET**, labelled by the environment map (`staging` → UAT); for Git, whether the change is merged into each mapped branch; tests, tasks and warning signals | "did they do what I asked", "is it on UAT yet" | `adolens trace 123` | | `ado_changes` | the files of a changeset (`523`), pull request (`PR 482`) or commit (sha + `repo`) with X++ objects and their added / changed / removed methods; `file` gives the diff of one file — for AOT XML per method plus the metadata (fields, controls, properties) without raw XML noise | reading what exactly changed | `adolens changes 523`, `adolens changes 523 --file AxClass/SalesTable_Extension.xml` | +| `ado_pr` | without id: pull requests waiting for my review and mine (`list=active`: all); with id: status, reviewers and votes, checks (build, work item linking, reviewers), linked work items, open comment threads written by people (not system notes) | code review, "what is blocking my PR" | `adolens pr`, `adolens pr 482` | +| `ado_pr_update` | `create` (repo, source, target, title, markdown text, work items, reviewers, draft), `comment` (general or on a file and line), `reply`, `resolve`, `vote`, `publish`, `complete`, `abandon`; `@[name]` notifies | reviewing, opening and finishing PRs | — | +| `ado_pipeline` | recent builds (of a pipeline, a branch); with `build`: the failed stage, job and steps, their errors, and the lines of the log that report errors (MSBuild noise cut) | "why is the build red" | `adolens pipeline`, `adolens pipeline 900` | +| `ado_pipeline_run` | queues a pipeline by name or id, optionally on a branch, with variables | re-running a build | — | | `ado_wiki` | the project wiki: page tree (`depth`), one page as markdown with its version and subpages, or search across the project's wikis with snippets; long pages are cut unless `detail=full` | "how does X work", finding documentation | `adolens wiki`, `adolens wiki "Team/Setup"`, `adolens wiki --search "dual write"` | | `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` | @@ -176,7 +187,7 @@ Estimates are characters / 4; Claude's tokenizer differs a little (Polish text r ## CLI ``` -adolens item|query|create|update|comment|link|fields|sprint|board|backlog|myday|wiki|wiki-write|trace|changes … +adolens item|query|create|update|comment|link|fields|sprint|board|backlog|myday|wiki|wiki-write|trace|changes|pr|pipeline … adolens login|logout|connections|config|status|register|unregister|refresh|stats|api|mcp|version ``` @@ -239,6 +250,7 @@ regular expression per line, `!word` for allowed words. Without a list the relea | `SetupTests.cs` | settings, connections and PAT order (Credential Manager first), command line, Claude configuration edits, Credential Manager, HTTP retries and errors, tool definition budget per mode | | `MetadataTests.cs` | fetching the structure (levels, inherited process name, bugs on the requirement backlog), change history, compact `ado_fields` answers | | `BoardTests.cs` | sprint progress, capacity left with days off, attention list, picking sprints, ended sprints, board columns and WIP, moving cards with the state mapping, backlog order and reordering, my day | +| `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 | | `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 | @@ -254,6 +266,8 @@ issues or commits. | work item reads, writes, checks | `WorkItems.cs`, `WorkQueries.cs`; output in `WorkItemFormat.cs` | | sprints, capacity, boards, backlogs, my day | `Boards.cs` (API in `Boards`, answers in `BoardQueries`) | | wiki | `Wiki.cs` | +| pull requests | `PullRequests.cs` | +| builds and pipeline runs | `Pipelines.cs` | | changesets, commits, PRs, builds, deployments | `Code.cs`; tracing in `Trace.cs`; diffs and X++ per method in `Diff.cs` | | skills for Claude | `skills\\SKILL.md` | | work item structure (types, fields, states) | `MetadataService.cs` (fetch), `MetaStore.cs` (SQLite; raise `SchemaVersion` to refetch), `Metadata.cs` (change detection) | diff --git a/README.pl.md b/README.pl.md index 725e4a9..ca63b9c 100644 --- a/README.pl.md +++ b/README.pl.md @@ -13,7 +13,7 @@ a nie tysiące. Zmiany między wersjami: [CHANGELOG.md](CHANGELOG.md). Plan rozw Azure DevOps jest znakiem towarowym Microsoftu. > Stan: **0.x — w budowie.** Działają połączenia, rejestracja w Claude, lokalna struktura work itemów, work itemy, -> sprinty, tablice, backlogi, wiki oraz śledzenie pracy do kodu i wdrożeń; pull requesty, pipeline'y i instalator — +> sprinty, tablice, backlogi, wiki, śledzenie pracy do kodu i wdrożeń, pull requesty i pipeline'y; instalator — > według planu. ## Czym się różni @@ -109,6 +109,10 @@ adolens config --connection fabrikam --env "Main-Deploy/Stage 1=UAT + SCM" Wtedy `ado_trace` wypisuje etapy w tej kolejności z ich etykietami („UAT (sbx03): YES”), a dla Gita sprawdza też, czy praca jest scalona do każdej zmapowanej gałęzi, nawet gdy żaden pipeline jej nie wdraża. +Nikt nie musi tego ustawiać zawczasu: gdy mapy brak, `ado_trace` kończy się krótką notą SETUP z wykrytym przepływem +(gałęzie i etapy release'ów, z pierwszym przypuszczeniem typu dev → DEV, staging → UAT, master → PROD). Claude raz pyta +użytkownika, jak nazywa te środowiska, i zapisuje odpowiedź przez `ado_settings` — na stałe. + Ustawienia siedzą w `%LOCALAPPDATA%\ado-lens\adolens.json` (bez sekretów) — można je też edytować ręcznie. ### Tryby i zestawy narzędzi @@ -118,13 +122,15 @@ Nigdy nie jest uprawnieniem. | Zestaw | Narzędzia | po | dev | full | |---|---|:-:|:-:|:-:| -| `core` | `ado_status`, `ado_fields` | ✓ | ✓ | ✓ | +| `core` | `ado_status`, `ado_settings`, `ado_fields` | ✓ | ✓ | ✓ | | `work` | `ado_work_item`, `ado_query`, `ado_create`, `ado_update`, `ado_comment`, `ado_link` | ✓ | ✓ | ✓ | +| `pr` | `ado_pr`, `ado_pr_update` | – | ✓ | ✓ | +| `pipelines` | `ado_pipeline`, `ado_pipeline_run` | – | ✓ | ✓ | | `boards` | `ado_sprint`, `ado_board`, `ado_backlog`, `ado_my_day` | ✓ | – | ✓ | | `wiki` | `ado_wiki`, `ado_wiki_write` | ✓ | ✓ | ✓ | | `trace` | `ado_trace`, `ado_changes` | ✓ | ✓ | ✓ | | `test`, `recipes` | w planie | ✓ | (recipes) | ✓ | -| `code`, `pr`, `pipelines` | w planie | – | ✓ | ✓ | +| `code` | w planie | – | ✓ | ✓ | ## Mapa narzędzi @@ -133,6 +139,7 @@ Na co odpowiada każde narzędzie MCP, kiedy Claude powinien po nie sięgnąć i | Narzędzie | Odpowiada na | Kiedy | CLI | |---|---|---|---| | `ado_status` | wersja, połączenie, zalogowany użytkownik, źródło PAT, wiek metadanych | sprawdzenie konfiguracji | `adolens status` | +| `ado_settings` | ustawienia połączenia; z `environments` zapisuje na stałe, co znaczą gałęzie i etapy release'ów, w kolejności przepływu (`dev=DEV (sbx01); staging=UAT (sbx03); master=PROD`) | `ado_trace` pyta o to raz (SETUP), użytkownik odpowiada słowami | `adolens config --connection c --env dev=DEV` | | `ado_fields` | typy według poziomu backlogu; dla typu: stany, przejścia, pola, wymagalność, dozwolone wartości; dla pola: gdzie jest używane i jakie ma wartości; zmiany struktury w czasie | przed zapisem pól albo WIQL; „jakie pola ma Bug”, „co się zmieniło w procesie w tym miesiącu” | `adolens fields --type Bug`, `adolens fields --field Severity`, `adolens fields --changes 30d` | | `ado_work_item` | jeden lub kilka elementów: stan, osoby, ścieżki, wypełnione pola, opis i kryteria akceptacji jako markdown, rodzic / dzieci / powiązane, changesety, commity, PR-y i buildy; z `include`: komentarze, historia | „o co chodzi w #123”, „kto i kiedy zmienił kryteria” | `adolens item 123 456 --include comments,history --detail summary` | | `ado_query` | elementy z WIQL (z `@Me`, `@CurrentIteration`…) albo z zapisanego zapytania, po jednej linii | listy, filtry, „otwarte bugi sprintu” | `adolens query --wiql "SELECT …"`, `adolens query --query "Shared Queries/Open bugs"` | @@ -145,6 +152,10 @@ Na co odpowiada każde narzędzie MCP, kiedy Claude powinien po nie sięgnąć i | `ado_backlog` | backlog zespołu w kolejności priorytetu (wymagania, portfolio, zadania albo po nazwie); `move` + `above`/`below` zmienia kolejność | refinement, priorytety | `adolens backlog --top 20`, `adolens backlog --move 123 --above 456` | | `ado_trace` | czy zrobiono to, o co proszono, i gdzie to jest: kryteria akceptacji (dla zadania — z elementu nadrzędnego) i to, czy zmieniły się po rozpoczęciu prac; changesety, commity i pull requesty podpięte pod element i jego zadania, z obiektami X++, których dotyczą; buildy (także usunięte przez retencję); każdy etap release'u gałęzi tego kodu z **TAK / JESZCZE NIE** i etykietą z mapy środowisk (`staging` → UAT); dla Gita — czy zmiana jest scalona do każdej zmapowanej gałęzi; testy, zadania i sygnały ostrzegawcze | „czy zrobili to, o co prosiłem”, „czy to już jest na UAT” | `adolens trace 123` | | `ado_changes` | pliki changesetu (`523`), pull requesta (`PR 482`) albo commita (sha + `repo`) z obiektami X++ i ich dodanymi / zmienionymi / usuniętymi metodami; `file` daje diff jednego pliku — dla AOT XML per metoda plus metadane (pola, kontrolki, właściwości) bez szumu surowego XML-a | czytanie, co dokładnie się zmieniło | `adolens changes 523`, `adolens changes 523 --file AxClass/SalesTable_Extension.xml` | +| `ado_pr` | bez id: pull requesty czekające na mój przegląd i moje (`list=active`: wszystkie); z id: stan, recenzenci i głosy, kontrole (build, podpięty work item, recenzenci), podpięte work itemy, otwarte wątki pisane przez ludzi (bez wpisów systemowych) | code review, „co blokuje mój PR” | `adolens pr`, `adolens pr 482` | +| `ado_pr_update` | `create` (repo, źródło, cel, tytuł, opis w markdownie, work itemy, recenzenci, szkic), `comment` (ogólny albo na pliku i linii), `reply`, `resolve`, `vote`, `publish`, `complete`, `abandon`; `@[imię]` wysyła powiadomienie | przegląd, zakładanie i kończenie PR-ów | — | +| `ado_pipeline` | ostatnie buildy (pipeline'u, gałęzi); z `build`: nieudany etap, zadanie i kroki, ich błędy i linie logu zgłaszające błędy (bez szumu MSBuild) | „czemu build jest czerwony” | `adolens pipeline`, `adolens pipeline 900` | +| `ado_pipeline_run` | uruchamia pipeline po nazwie albo id, opcjonalnie na gałęzi, ze zmiennymi | ponowne puszczenie buildu | — | | `ado_wiki` | wiki projektu: drzewo stron (`depth`), strona jako markdown z wersją i podstronami albo wyszukiwanie we wszystkich wiki projektu z fragmentami; długie strony są ucinane, chyba że `detail=full` | „jak działa X”, szukanie dokumentacji | `adolens wiki`, `adolens wiki "Team/Setup"`, `adolens wiki --search "dual write"` | | `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` | @@ -176,7 +187,7 @@ Szacunek to znaki / 4; tokenizer Claude'a liczy trochę inaczej (polski tekst wy ## CLI ``` -adolens item|query|create|update|comment|link|fields|sprint|board|backlog|myday|wiki|wiki-write|trace|changes … +adolens item|query|create|update|comment|link|fields|sprint|board|backlog|myday|wiki|wiki-write|trace|changes|pr|pipeline … adolens login|logout|connections|config|status|register|unregister|refresh|stats|api|mcp|version ``` @@ -239,6 +250,7 @@ i publikuje go razem z `.sha256` i opisem z `CHANGELOG.md` (podgląd: `.\release | `SetupTests.cs` | ustawienia, połączenia i kolejność PAT (najpierw Menedżer poświadczeń), linia poleceń, edycja konfiguracji Claude'a, Menedżer poświadczeń, ponawianie i błędy HTTP, budżet definicji narzędzi na tryb, faktyczna rejestracja narzędzi | | `MetadataTests.cs` | pobieranie struktury (poziomy, nazwa procesu dziedziczonego, bugi na backlogu wymagań), historia zmian, zwięzłe odpowiedzi `ado_fields` | | `BoardTests.cs` | postęp sprintu, pozostała pojemność z dniami wolnymi, lista do uwagi, wybór sprintu, zakończone sprinty, kolumny tablicy i WIP, przesuwanie kart z mapowaniem stanów, kolejność backlogu i jej zmiana, mój dzień | +| `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` | | `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 | @@ -254,6 +266,8 @@ organizacji do testów, zgłoszeń ani commitów. | odczyt, zapis i kontrole work itemów | `WorkItems.cs`, `WorkQueries.cs`; wyjście w `WorkItemFormat.cs` | | sprinty, pojemność, tablice, backlogi, mój dzień | `Boards.cs` (API w `Boards`, odpowiedzi w `BoardQueries`) | | wiki | `Wiki.cs` | +| pull requesty | `PullRequests.cs` | +| buildy i uruchamianie pipeline'ów | `Pipelines.cs` | | changesety, commity, PR-y, buildy, wdrożenia | `Code.cs`; śledzenie w `Trace.cs`; diffy i X++ per metoda w `Diff.cs` | | skille dla Claude'a | `skills\\SKILL.md` | | struktura work itemów (typy, pola, stany) | `MetadataService.cs` (pobieranie), `MetaStore.cs` (SQLite; podnieś `SchemaVersion`, żeby pobrać od nowa), `Metadata.cs` (wykrywanie zmian) | diff --git a/docs/PLAN.md b/docs/PLAN.md index db31fad..bdc6aa5 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -434,6 +434,20 @@ pola konfiguracji w UI, sekrety w magazynie systemu. Nie obejmuje Claude Code an --- +### Środowiska (DEV / UAT / PROD…) dla użytkowników końcowych + +Mapa „gałąź / etap release'u → środowisko” musi być dla PO niewidoczna albo trywialna. Trzy warstwy, od najmniejszego +wysiłku: +1. **Wspólna mapa zespołu w Azure DevOps** (strona wiki „ADO Lens” albo plik `.adolens/environments.json` w repo — + do decyzji): ustawiana raz dla projektu, czytana automatycznie przez każdego użytkownika; lokalna konfiguracja tylko + ją nadpisuje. +2. **Wykrywanie z propozycją**: z nazw gałęzi (`dev`/`develop`/`sit` → DEV, `staging`/`uat`/`test` → UAT, + `master`/`main`/`prod` → PROD), definicji i etapów release'ów oraz kolejności wdrożeń; kreator i rozmowa pytają + tylko o nazwy („jak nazywacie te środowiska?” → „sbx01, sbx03, produkcja”). +3. **Zmiana w rozmowie** narzędziem ustawień (bez terminala): „najpierw sbx01, potem sbx03”, „ten release wdraża na + UAT i SCM naraz”. +Bez mapy `ado_trace` podpowiada jednym zdaniem wykryty przepływ i jak go zapisać. + ## 6. Bezpieczeństwo i prywatność - Sekrety tylko w Menedżerze poświadczeń (albo w zmiennej środowiskowej, jeśli użytkownik tak woli); `adolens doctor` wykrywa i przenosi PAT-y zapisane diff --git a/src/AdoLens/AdoClient.cs b/src/AdoLens/AdoClient.cs index 35aaafc..53a8cba 100644 --- a/src/AdoLens/AdoClient.cs +++ b/src/AdoLens/AdoClient.cs @@ -66,6 +66,26 @@ static string WithApiVersion(string url) => public Task GetAsync(string url, CancellationToken ct = default) => SendAsync(HttpMethod.Get, url, null, null, ct); + /// A plain text answer (build logs). + public async Task GetTextAsync(string url, CancellationToken ct = default) + { + url = WithApiVersion(url); + for (var attempt = 1; ; attempt++) + { + using var req = new HttpRequestMessage(HttpMethod.Get, url); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain")); + using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); + if (attempt < MaxAttempts && RetryDelay(res) is { } delay) + { + await Delay(delay, ct); + continue; + } + // A log is text/plain; the HTML check of EnsureSuccessAsync only applies to sign-in pages. + await EnsureSuccessAsync(res, ct); + return await res.Content.ReadAsStringAsync(ct); + } + } + public Task PostAsync(string url, object body, CancellationToken ct = default) => SendAsync(HttpMethod.Post, url, body, "application/json", ct); diff --git a/src/AdoLens/Cli.cs b/src/AdoLens/Cli.cs index e0f1ce3..212fac8 100644 --- a/src/AdoLens/Cli.cs +++ b/src/AdoLens/Cli.cs @@ -44,6 +44,10 @@ sprint [current|next|previous|] --team --detail summary|normal|full board [] --team --move --column --detail summary|normal|full backlog --level requirement|portfolio|task| --top 30 --move --above | --below myday --since 1d|3d|2w|yyyy-MM-dd (default: the previous working day) + trace was it done as asked, and where is it (DEV / UAT / PROD) --detail summary|full + changes <523 | C523 | "PR 482" | sha --repo > files, X++ methods; --file for a diff + pr [] PRs waiting for my review and mine (--list active: all); with id: reviewers, checks, threads + pipeline [] recent builds (--definition --branch --top 10); with id: why it failed wiki [] --search --depth 2 --detail full --wiki wiki-write --file (or standard input) --mode create|append|prepend|replace --version (for replace, from 'wiki ') --comment @@ -470,6 +474,24 @@ public async Task ChangesAsync() return 0; } + public async Task PrAsync() + { + var lens = NewLens(); + Print(Int(cl.Positional.FirstOrDefault()) is { } id + ? await PullRequestQueries.ShowAsync(lens, cl.Opt("connection"), id, cl.Opt("repo"), cl.Opt("detail")) + : await PullRequestQueries.ListAsync(lens, cl.Opt("connection"), cl.Opt("project"), cl.Opt("list"))); + return 0; + } + + public async Task PipelineAsync() + { + var lens = NewLens(); + Print(Int(cl.Positional.FirstOrDefault()) is { } build + ? await PipelineQueries.BuildAsync(lens, cl.Opt("connection"), cl.Opt("project"), build, cl.Opt("detail")) + : await PipelineQueries.ListAsync(lens, cl.Opt("connection"), cl.Opt("project"), cl.Opt("definition"), cl.Opt("branch"), Int(cl.Opt("top")) ?? 10)); + return 0; + } + public async Task WikiAsync() { Print(await WikiQueries.ReadAsync(NewLens(), cl.Opt("connection"), cl.Opt("project"), cl.Opt("wiki"), diff --git a/src/AdoLens/CommandLine.cs b/src/AdoLens/CommandLine.cs index 200eb4f..9273bb4 100644 --- a/src/AdoLens/CommandLine.cs +++ b/src/AdoLens/CommandLine.cs @@ -16,6 +16,8 @@ public sealed partial class CommandLine ["logout"] = ["connection="], ["connections"] = [], ["trace"] = ["connection=", "detail="], + ["pr"] = ["connection=", "project=", "repo=", "list=", "detail="], + ["pipeline"] = ["connection=", "project=", "definition=", "branch=", "top=", "detail="], ["changes"] = ["connection=", "repo=", "file=", "detail="], ["config"] = ["env=", "remove-env=", "connection=", "default-connection=", "project=", "team=", "mode=", "usage-log=", "add-toolset=", "remove-toolset=", "confirm-above=", "show-tokens="], ["item"] = ["connection=", "include=", "detail="], diff --git a/src/AdoLens/Lens.cs b/src/AdoLens/Lens.cs index 93344d7..71cc96f 100644 --- a/src/AdoLens/Lens.cs +++ b/src/AdoLens/Lens.cs @@ -24,15 +24,26 @@ public string Project(string? project) => /// created on first use so a server with several connections only talks to the ones Claude needs. /// public sealed class Lens(Settings settings, string version, string dataDir, - Func env, Func credential, Func? clientFactory = null) + Func env, Func credential, Func? clientFactory = null, string? settingsPath = null) { readonly ConcurrentDictionary _sessions = new(StringComparer.OrdinalIgnoreCase); public Settings Settings => settings; public string Version => version; + /// Where settings changed through tools (ado_settings) are saved. + public string SettingsPath => settingsPath ?? Path.Combine(dataDir, "adolens.json"); + public static Lens Default(string version) => - new(Settings.Load(Settings.DefaultPath), version, Settings.DataDir, Environment.GetEnvironmentVariable, CredentialStore.Read); + new(Settings.Load(Settings.DefaultPath), version, Settings.DataDir, Environment.GetEnvironmentVariable, CredentialStore.Read, + settingsPath: Settings.DefaultPath); + + /// Saves the settings and drops the cached sessions, so the next call sees the change. + public void SaveSettings() + { + settings.Save(SettingsPath); + _sessions.Clear(); + } public string MetaPath(string connection) => Path.Combine(dataDir, "meta", connection + ".db"); diff --git a/src/AdoLens/McpTools.cs b/src/AdoLens/McpTools.cs index 96eba60..ead2439 100644 --- a/src/AdoLens/McpTools.cs +++ b/src/AdoLens/McpTools.cs @@ -30,6 +30,12 @@ public static class McpTools [Toolsets.Trace] = """ - Checking work against what was asked: ado_trace, then read the diffs with ado_changes - never assume. """, + [Toolsets.Pr] = """ + - PR description: what and why with the work item, changes as bullets by area, how to check, risks only if any. + """, + [Toolsets.Pipelines] = """ + - A failed build: give the error lines from the log, not the whole log. + """, [Toolsets.Wiki] = """ - Replace a wiki page only after reading it whole (detail=full), with its version. """, @@ -87,6 +93,11 @@ public static Task Status(Lens lens, CancellationToken ct = default) => McpTools.Run("ado_status", new { connection }, () => Queries.StatusAsync(lens, connection, offline: false, ct)); + [McpServerTool(Name = "ado_settings")] + [Description("Shows the connection's settings; with environments saves for good what branches and release stages mean, in flow order: \"dev=DEV (sbx01); staging=UAT (sbx03); master=PROD\" (a stage: \"Release/Stage=UAT + SCM\").")] + public static Task Settings(Lens lens, string? environments = null, string? connection = null, CancellationToken ct = default) + => McpTools.Run("ado_settings", new { environments, connection }, () => Task.FromResult(Queries.SettingsAnswer(lens, connection, environments))); + [McpServerTool(Name = "ado_fields", ReadOnly = true, Idempotent = true)] [Description("Work item structure of the project: types by backlog level; with type: states, transitions, fields, required flags, allowed values; with field: one field across types; with changes: what changed in the structure.")] public static Task Fields(Lens lens, @@ -181,6 +192,83 @@ public static Task Changes(Lens lens, () => TraceQueries.ChangesAsync(lens, connection, change, repo, file, detail, ct)); } +[McpServerToolType, Toolset(Toolsets.Pr)] +public static class PrTools +{ + [McpServerTool(Name = "ado_pr", ReadOnly = true, Idempotent = true)] + [Description("Pull requests: without id the ones waiting for my review and mine (list=active: all active); with id: status, reviewers and votes, checks, work items, open comment threads.")] + public static Task Pr(Lens lens, + int? id = null, + [Description("Repository name (optional with id).")] string? repo = null, + [Description("review (default) | active.")] string? list = null, + [Description("full: all threads, whole description.")] string? detail = null, + string? project = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_pr", new { id, repo, list, detail, project, connection }, + () => id is { } n ? PullRequestQueries.ShowAsync(lens, connection, n, repo, detail, ct) : PullRequestQueries.ListAsync(lens, connection, project, list, ct)); + + [McpServerTool(Name = "ado_pr_update")] + [Description("Acts on pull requests: create (repo, source, target, title, text, workItems, reviewers, draft), comment (text, optional file and line), reply (thread, text), resolve (thread, status), vote (approve|suggestions|wait|reject|reset), publish, complete, abandon. @[name] in text notifies.")] + public static Task PrUpdate(Lens lens, + string action, + int? id = null, + string? repo = null, + [Description("Markdown: PR description or comment.")] string? text = null, + int? thread = null, + [Description("File path for a comment on code.")] string? file = null, + int? line = null, + string? vote = null, + [Description("fixed | closed | wontFix | byDesign | active.")] string? status = null, + string? source = null, + string? target = null, + string? title = null, + [Description("Work item ids: 123;456.")] string? workItems = null, + [Description("Names or e-mails separated by ';'.")] string? reviewers = null, + bool draft = false, + bool deleteSourceBranch = false, + [Description("noFastForward | squash | rebase | rebaseMerge.")] string? mergeStrategy = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_pr_update", new { action, id, repo, thread, file, line, vote, status, source, target, title, workItems, reviewers, draft, deleteSourceBranch, mergeStrategy, connection }, + () => PullRequestQueries.ActAsync(lens, connection, new PullRequestQueries.PrAction + { + Action = action, Id = id, Repo = repo, Text = text, Thread = thread, File = file, Line = line, Vote = vote, Status = status, + Source = source, Target = target, Title = title, WorkItems = workItems, Reviewers = reviewers, Draft = draft, + DeleteSourceBranch = deleteSourceBranch, MergeStrategy = mergeStrategy, + }, ct)); +} + +[McpServerToolType, Toolset(Toolsets.Pipelines)] +public static class PipelineTools +{ + [McpServerTool(Name = "ado_pipeline", ReadOnly = true, Idempotent = true)] + [Description("Builds: recent runs (optionally of one pipeline or branch); with build: why it failed - failed steps, errors and the error lines of the log.")] + public static Task Pipeline(Lens lens, + int? build = null, + [Description("Pipeline name or id.")] string? definition = null, + string? branch = null, + int top = 10, + [Description("full: more steps and log lines.")] string? detail = null, + string? project = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_pipeline", new { build, definition, branch, top, detail, project, connection }, + () => build is { } b ? PipelineQueries.BuildAsync(lens, connection, project, b, detail, ct) : PipelineQueries.ListAsync(lens, connection, project, definition, branch, top, ct)); + + [McpServerTool(Name = "ado_pipeline_run")] + [Description("Queues a run of a pipeline, optionally on a branch.")] + public static Task Run(Lens lens, + [Description("Pipeline name or id.")] string definition, + string? branch = null, + [Description("JSON object of pipeline variables.")] string? parameters = null, + string? project = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_pipeline_run", new { definition, branch, project, connection }, + () => PipelineQueries.RunAsync(lens, connection, project, definition, branch, parameters, ct)); +} + [McpServerToolType, Toolset(Toolsets.Wiki)] public static class WikiTools { diff --git a/src/AdoLens/Pipelines.cs b/src/AdoLens/Pipelines.cs new file mode 100644 index 0000000..049eea8 --- /dev/null +++ b/src/AdoLens/Pipelines.cs @@ -0,0 +1,124 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace AdoLens; + +/// Builds: recent runs, why a run failed (errors and the lines of the log around them), queuing a run. +public static partial class PipelineQueries +{ + static DateTime? Date(string? s) => + DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var d) ? d : null; + + static string When(JsonElement b) => + (Date(Json.Str(b, "finishTime")) ?? Date(Json.Str(b, "startTime")) ?? Date(Json.Str(b, "queueTime")))?.ToLocalTime().ToString("MM-dd HH:mm", CultureInfo.InvariantCulture) ?? ""; + + static string Duration(JsonElement b) => + Date(Json.Str(b, "startTime")) is { } s && Date(Json.Str(b, "finishTime")) is { } f ? $"{(int)(f - s).TotalMinutes} min" : ""; + + static string Line(JsonElement b) => + $"build {b.GetProperty("id").GetInt32()} {Json.Str(Json.Obj(b, "definition"), "name")} {Json.Str(b, "buildNumber")}: " + + $"{Json.Str(b, "result") ?? Json.Str(b, "status")}, {Code.Short(Json.Str(b, "sourceBranch"))}, " + + $"{Json.Str(Json.Obj(b, "requestedFor"), "displayName")}, {When(b)}{(Duration(b) is { Length: > 0 } d ? $", {d}" : "")}"; + + public static async Task ListAsync(Lens lens, string? connection, string? project, string? definition, string? branch, int top, CancellationToken ct = default) + { + var session = lens.For(connection); + var name = session.Project(project); + var query = new List { $"$top={Math.Clamp(top, 1, 100)}", "queryOrder=queueTimeDescending" }; + if (definition is not null) query.Add("definitions=" + (await DefinitionAsync(session, name, definition, ct)).Id); + if (branch is not null) query.Add("branchName=" + Uri.EscapeDataString(branch.StartsWith("refs/", StringComparison.Ordinal) || branch.StartsWith("$/", StringComparison.Ordinal) ? branch : "refs/heads/" + branch)); + var builds = await session.Client.GetListAsync($"{AdoClient.Scope(name)}_apis/build/builds?{string.Join('&', query)}", ct); + if (builds.Count == 0) return "no builds found"; + return $"recent builds in {name}:\n" + string.Join('\n', builds.Select(Line)) + "\nbuild= shows why a build failed."; + } + + static async Task<(int Id, string Name)> DefinitionAsync(Session session, string project, string definition, CancellationToken ct) + { + if (int.TryParse(definition, out var id)) return (id, definition); + var defs = await session.Client.GetListAsync($"{AdoClient.Scope(project)}_apis/build/definitions?name={Uri.EscapeDataString("*" + definition.Trim() + "*")}&$top=50", ct); + var exact = defs.FirstOrDefault(d => (Json.Str(d, "name") ?? "").Equals(definition.Trim(), StringComparison.OrdinalIgnoreCase)); + var pick = exact.ValueKind == JsonValueKind.Object ? exact : defs.Count == 1 ? defs[0] + : throw new InvalidOperationException(defs.Count == 0 ? $"no pipeline named like '{definition}'" + : $"'{definition}' matches {defs.Count} pipelines: {string.Join("; ", defs.Select(d => Json.Str(d, "name")).Take(15))}"); + return (pick.GetProperty("id").GetInt32(), Json.Str(pick, "name")!); + } + + [GeneratedRegex(@"^\d{4}-\d\d-\d\dT[\d:.]+Z ")] + private static partial Regex Timestamp(); + + [GeneratedRegex(@"(?i)(##\[error\]|\berror\b|\bfailed\b|\bexception\b)")] + private static partial Regex ErrorLine(); + + [GeneratedRegex(@"(?i)\b0 error|\berror\(s\)\s*:\s*0|warning")] + private static partial Regex NotAnError(); + + // MSBuild repeats where an error was reported from: "C:\...\x.targets(40,5): error : text [C:\...\x.proj]". + [GeneratedRegex(@"^\s*\S.*?\.(targets|props|proj)\(\d+,\d+\):\s*")] + private static partial Regex MsBuildPrefix(); + + [GeneratedRegex(@"\s*\[[^\]]+\.(proj|csproj|rnrproj|sln)\]\s*$")] + private static partial Regex MsBuildProject(); + + static string Clean(string line) => MsBuildProject().Replace(MsBuildPrefix().Replace(line, ""), ""); + + /// The lines of a log that say what went wrong, with their numbers; the end of the log when none do. + internal static string Excerpt(string log, int max) + { + var lines = log.Replace("\r", "").Split('\n').Select(l => Clean(Timestamp().Replace(l, ""))).ToList(); + var hits = lines.Select((l, i) => (l, i)).Where(x => ErrorLine().IsMatch(x.l) && !NotAnError().IsMatch(x.l)) + .DistinctBy(x => x.l.Trim()).Take(max).ToList(); + if (hits.Count == 0) + return string.Join('\n', lines.Select((l, i) => (l, i)).Where(x => x.l.Trim().Length > 0).TakeLast(Math.Min(max, 30)).Select(x => $"{x.i + 1}: {x.l.TrimEnd()}")); + return string.Join('\n', hits.Select(x => $"{x.i + 1}: {Formatting.Collapse(x.l, 300)}")); + } + + public static async Task BuildAsync(Lens lens, string? connection, string? project, int id, string? detail, CancellationToken ct = default) + { + var session = lens.For(connection); + var name = session.Project(project); + var scope = AdoClient.Scope(name); + var b = await session.Client.GetAsync($"{scope}_apis/build/builds/{id}", ct); + var sb = new StringBuilder(Line(b)).AppendLine(); + sb.AppendLine($"commit {Json.Str(b, "sourceVersion")?[..Math.Min(8, Json.Str(b, "sourceVersion")!.Length)]}, reason {Json.Str(b, "reason")}"); + if (Json.Str(Json.Obj(Json.Obj(b, "_links"), "web"), "href") is { } url) sb.AppendLine(url); + + var timeline = await session.Client.GetAsync($"{scope}_apis/build/builds/{id}/timeline", ct); + var records = Json.Arr(timeline, "records").ToList(); + var failed = records.Where(r => Json.Str(r, "result") is "failed" or "canceled").ToList(); + var tasks = failed.Where(r => Json.Str(r, "type") == "Task").ToList(); + if (failed.Count == 0) + { + var warnings = records.Sum(r => r.TryGetProperty("warningCount", out var w) && w.ValueKind == JsonValueKind.Number ? w.GetInt32() : 0); + sb.Append(Json.Str(b, "status") == "completed" ? $"nothing failed{(warnings > 0 ? $" ({warnings} warnings)" : "")}" : "still running"); + return sb.ToString(); + } + sb.AppendLine("failed: " + string.Join(" > ", failed.Where(r => Json.Str(r, "type") is "Stage" or "Job" or "Phase").Select(r => Json.Str(r, "name")).Distinct().Append(string.Join(", ", tasks.Select(t => Json.Str(t, "name")))))); + foreach (var t in tasks.Take(detail == "full" ? 10 : 2)) + { + sb.AppendLine($"\n{Json.Str(t, "name")}:"); + var errors = Json.Arr(t, "issues").Where(i => Json.Str(i, "type") == "error").Select(i => Formatting.Collapse(Json.Str(i, "message") ?? "", 300)).Distinct().ToList(); + foreach (var e in errors.Take(10)) sb.AppendLine($" error: {e}"); + if (Json.Obj(Json.Obj(t, "log"), "id") is { ValueKind: JsonValueKind.Number } logId) + { + var log = await session.Client.GetTextAsync($"{scope}_apis/build/builds/{id}/logs/{logId.GetInt32()}", ct); + sb.AppendLine(" log (lines that report errors):"); + sb.AppendLine(string.Join('\n', Excerpt(log, detail == "full" ? 200 : 40).Split('\n').Select(l => " " + l))); + } + } + return sb.ToString().TrimEnd(); + } + + public static async Task RunAsync(Lens lens, string? connection, string? project, string definition, string? branch, string? parameters, CancellationToken ct = default) + { + var session = lens.For(connection); + var name = session.Project(project); + var def = await DefinitionAsync(session, name, definition, ct); + var body = new Dictionary { ["definition"] = new { id = def.Id } }; + if (branch is not null) body["sourceBranch"] = branch.StartsWith("refs/", StringComparison.Ordinal) || branch.StartsWith("$/", StringComparison.Ordinal) ? branch : "refs/heads/" + branch; + if (parameters is not null) body["parameters"] = parameters; + var queued = await session.Client.PostAsync($"{AdoClient.Scope(name)}_apis/build/builds", body, ct); + return $"queued {Line(queued)}" + (Json.Str(Json.Obj(Json.Obj(queued, "_links"), "web"), "href") is { } url ? "\n" + url : ""); + } +} diff --git a/src/AdoLens/Program.cs b/src/AdoLens/Program.cs index a018d38..a337c65 100644 --- a/src/AdoLens/Program.cs +++ b/src/AdoLens/Program.cs @@ -76,6 +76,10 @@ return await cli.TraceAsync(); case "changes": return await cli.ChangesAsync(); + case "pr": + return await cli.PrAsync(); + case "pipeline": + return await cli.PipelineAsync(); case "wiki": return await cli.WikiAsync(); case "wiki-write": diff --git a/src/AdoLens/PullRequests.cs b/src/AdoLens/PullRequests.cs new file mode 100644 index 0000000..01d244e --- /dev/null +++ b/src/AdoLens/PullRequests.cs @@ -0,0 +1,278 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace AdoLens; + +/// Pull requests: lists, one PR with reviewers, policies, work items and human comment threads, and actions. +public static partial class PullRequestQueries +{ + public static readonly string[] Actions = ["create", "comment", "reply", "resolve", "vote", "publish", "complete", "abandon"]; + + static readonly Dictionary Votes = new(StringComparer.OrdinalIgnoreCase) + { + ["approve"] = 10, ["suggestions"] = 5, ["reset"] = 0, ["wait"] = -5, ["reject"] = -10, + }; + + static string VoteText(int vote) => vote switch + { + 10 => "approved", 5 => "approved with suggestions", -5 => "waiting for author", -10 => "rejected", _ => "no vote", + }; + + static string Ago(string? iso) + { + if (!DateTime.TryParse(iso, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var d)) return ""; + var span = DateTime.UtcNow - d; + return span.TotalDays >= 1 ? $"{(int)span.TotalDays}d" : span.TotalHours >= 1 ? $"{(int)span.TotalHours}h" : $"{Math.Max(1, (int)span.TotalMinutes)}m"; + } + + static string Line(JsonElement pr) + { + var votes = Json.Arr(pr, "reviewers").GroupBy(r => r.TryGetProperty("vote", out var v) ? v.GetInt32() : 0) + .OrderByDescending(g => g.Key).Select(g => $"{g.Count()} {VoteText(g.Key)}"); + var flags = new List(); + if (Json.Bool(pr, "isDraft")) flags.Add("draft"); + if (Json.Str(pr, "mergeStatus") is "conflicts" or "failure") flags.Add(Json.Str(pr, "mergeStatus")!); + return $"PR {pr.GetProperty("pullRequestId").GetInt32()} [{Json.Str(Json.Obj(pr, "repository"), "name")}] {Json.Str(pr, "title")}: " + + $"{Code.Short(Json.Str(pr, "targetRefName"))} <- {Code.Short(Json.Str(pr, "sourceRefName"))} " + + $"({Json.Str(Json.Obj(pr, "createdBy"), "displayName")}, {Ago(Json.Str(pr, "creationDate"))})" + + (votes.Any() ? $"; {string.Join(", ", votes)}" : "") + (flags.Count > 0 ? $"; {string.Join(", ", flags)}" : ""); + } + + public static async Task ListAsync(Lens lens, string? connection, string? project, string? list, CancellationToken ct = default) + { + var session = lens.For(connection); + var name = session.Project(project); + var (me, meName) = await new Boards(session).MeAsync(ct); + var all = await session.Client.GetListAsync($"{AdoClient.Scope(name)}_apis/git/pullrequests?searchCriteria.status=active&$top=200", ct); + var which = (list ?? "review").Trim().ToLowerInvariant(); + var review = all.Where(pr => Json.Arr(pr, "reviewers").Any(r => Json.Str(r, "id") == me && (!r.TryGetProperty("vote", out var v) || v.GetInt32() == 0)) && !Json.Bool(pr, "isDraft")).ToList(); + var mine = all.Where(pr => Json.Str(Json.Obj(pr, "createdBy"), "id") == me).ToList(); + var sb = new StringBuilder($"{meName} - active pull requests in {name}:\n"); + void Section(string title, List prs, int max) + { + sb.AppendLine($"{title} ({prs.Count}){(prs.Count == 0 ? ": none" : ":")}"); + foreach (var pr in prs.Take(max)) sb.AppendLine(" " + Line(pr)); + if (prs.Count > max) sb.AppendLine($" … {prs.Count - max} more"); + } + Section("waiting for your review", review, 20); + Section("yours", mine, 20); + if (which == "active") Section("all active", all.Except(review).Except(mine).ToList(), 40); + else sb.Append($"{all.Count} active in total; list=active shows all."); + return sb.ToString().TrimEnd(); + } + + static async Task GetAsync(Session session, string? repo, int id, CancellationToken ct) + { + var repoId = repo is null ? null : (await session.Code.RepoAsync(repo, session.Config.Project, ct)).Id; + return await session.Client.GetAsync(repoId is null ? $"_apis/git/pullrequests/{id}" : $"_apis/git/repositories/{repoId}/pullrequests/{id}", ct); + } + + /// Threads people wrote (not the system's notes about votes and pushes, nor conflict resolution records). + static List HumanThreads(List threads) => + threads.Where(t => !Json.Bool(t, "isDeleted") && Json.Arr(t, "comments").Any(c => Json.Str(c, "commentType") == "text" + && !(Json.Str(c, "content") ?? "").StartsWith("Submitted conflict resolution", StringComparison.Ordinal))).ToList(); + + [GeneratedRegex(@"\[([^\]]+)\]\([^)]+\)")] + private static partial Regex MarkdownLink(); + + static string Plain(string? markdown) => Formatting.Collapse(MarkdownLink().Replace(markdown ?? "", "$1"), 200); + + public static async Task ShowAsync(Lens lens, string? connection, int id, string? repo, string? detail, CancellationToken ct = default) + { + var session = lens.For(connection); + var client = session.Client; + var pr = await GetAsync(session, repo, id, ct); + var repoEl = Json.Obj(pr, "repository"); + var repoId = Json.Str(repoEl, "id")!; + var project = Json.Str(Json.Obj(repoEl, "project"), "name") ?? session.Project(null); + var projectId = Json.Str(Json.Obj(repoEl, "project"), "id"); + var baseUrl = $"_apis/git/repositories/{repoId}/pullRequests/{id}"; + + var threadsTask = client.GetListAsync(baseUrl + "/threads", ct); + var workTask = client.GetListAsync(baseUrl + "/workitems", ct); + var policyTask = projectId is null ? Task.FromResult(new List()) + : TryList(() => client.GetListAsync($"{AdoClient.Scope(project)}_apis/policy/evaluations?artifactId=vstfs:///CodeReview/CodeReviewId/{projectId}/{id}&api-version=7.1-preview.1", ct)); + + var sb = new StringBuilder(); + sb.AppendLine($"PR {id} [{Json.Str(repoEl, "name")}] {Json.Str(pr, "title")}"); + sb.AppendLine($"{Json.Str(pr, "status")}{(Json.Bool(pr, "isDraft") ? ", draft" : "")}: {Code.Short(Json.Str(pr, "targetRefName"))} <- {Code.Short(Json.Str(pr, "sourceRefName"))}, " + + $"by {Json.Str(Json.Obj(pr, "createdBy"), "displayName")} {Ago(Json.Str(pr, "creationDate"))} ago, merge {Json.Str(pr, "mergeStatus") ?? "?"}"); + sb.AppendLine($"https://dev.azure.com/{session.Config.Organization}/{Uri.EscapeDataString(project)}/_git/{Uri.EscapeDataString(Json.Str(repoEl, "name") ?? "")}/pullrequest/{id}"); + var description = Json.Str(pr, "description") ?? ""; + if (description.Length > 0) sb.AppendLine("description:").AppendLine(description.Length > 1500 && detail != "full" ? description[..1500] + "…" : description.TrimEnd()); + + var reviewers = Json.Arr(pr, "reviewers").ToList(); + if (reviewers.Count > 0) + sb.AppendLine("reviewers: " + string.Join("; ", reviewers.Select(r => $"{Json.Str(r, "displayName")} {VoteText(r.TryGetProperty("vote", out var v) ? v.GetInt32() : 0)}{(Json.Bool(r, "isRequired") ? " (required)" : "")}"))); + var policies = await policyTask; + if (policies.Count > 0) + sb.AppendLine("checks: " + string.Join("; ", policies.Select(p => $"{Json.Str(Json.Obj(Json.Obj(p, "configuration"), "type"), "displayName")} {Json.Str(p, "status")}"))); + var workIds = (await workTask).Select(w => int.TryParse(Json.Str(w, "id"), out var i) ? i : 0).Where(i => i > 0).ToList(); + if (workIds.Count > 0) + { + var items = await session.Work.GetAsync(workIds, relations: false, ["System.Id", "System.WorkItemType", "System.State", "System.Title", "System.TeamProject"], ct); + sb.AppendLine("work items: " + string.Join("; ", items.Select(WorkItemFormat.Header))); + } + else sb.AppendLine("work items: none linked"); + + var threads = HumanThreads(await threadsTask); + var open = threads.Where(t => Json.Str(t, "status") is "active" or "pending").ToList(); + sb.AppendLine($"comments: {open.Count} open thread(s), {threads.Count - open.Count} resolved"); + foreach (var t in (detail == "full" ? threads : open).Take(detail == "full" ? 200 : 20)) + { + var ctx = Json.Obj(t, "threadContext"); + var where = Json.Str(ctx, "filePath") is { } path + ? $"{(AotDiff.IsAot(path) ? AotDiff.ObjectName(path) : path[(path.LastIndexOf('/') + 1)..])}" + + (Json.Obj(Json.Obj(ctx, "rightFileStart"), "line") is { ValueKind: JsonValueKind.Number } line ? $":{line.GetInt32()}" : "") + : "general"; + var comments = Json.Arr(t, "comments").Where(c => Json.Str(c, "commentType") == "text").ToList(); + var first = comments[0]; + sb.AppendLine($"- thread {t.GetProperty("id").GetInt32()} [{Json.Str(t, "status")}] {where} - {Json.Str(Json.Obj(first, "author"), "displayName")}: " + + $"{Plain(Json.Str(first, "content"))}{(comments.Count > 1 ? $" ({comments.Count - 1} repl{(comments.Count == 2 ? "y" : "ies")}, last: {Plain(Json.Str(comments[^1], "content"))})" : "")}"); + } + sb.Append($"ado_changes change=\"PR {id}\" lists the files and diffs."); + return sb.ToString(); + } + + static async Task> TryList(Func>> f) + { + try { return await f(); } + catch (AdoApiException) { return []; } + } + + [GeneratedRegex(@"@\[(?[^\]]+)\]")] + private static partial Regex Mention(); + + /// "@[name]" in PR markdown becomes "@<id>", which notifies the person. + static async Task MentionsAsync(Identities people, string text, CancellationToken ct) + { + var result = text; + foreach (Match m in Mention().Matches(text)) + result = result.Replace(m.Value, $"@<{(await people.ResolveAsync(m.Groups["name"].Value, ct)).Id}>"); + return result; + } + + public sealed record PrAction + { + public required string Action { get; init; } + public int? Id { get; init; } + public string? Repo { get; init; } + public string? Text { get; init; } + public int? Thread { get; init; } + public string? File { get; init; } + public int? Line { get; init; } + public string? Vote { get; init; } + public string? Status { get; init; } + public string? Source { get; init; } + public string? Target { get; init; } + public string? Title { get; init; } + public string? WorkItems { get; init; } + public string? Reviewers { get; init; } + public bool Draft { get; init; } + public bool DeleteSourceBranch { get; init; } + public string? MergeStrategy { get; init; } + } + + public static async Task ActAsync(Lens lens, string? connection, PrAction a, CancellationToken ct = default) + { + var action = a.Action.Trim().ToLowerInvariant(); + if (!Actions.Contains(action)) throw new ArgumentException($"action is one of: {string.Join(", ", Actions)}"); + var session = lens.For(connection); + var client = session.Client; + + if (action == "create") + { + if (a.Repo is null || a.Source is null || a.Target is null || string.IsNullOrWhiteSpace(a.Title)) + throw new ArgumentException("create needs repo, source, target and title"); + var repo = await session.Code.RepoAsync(a.Repo, session.Config.Project, ct); + var reviewers = new List(); + foreach (var r in (a.Reviewers ?? "").Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + reviewers.Add(new { id = (await session.People.ResolveAsync(r, ct)).Id }); + var body = new Dictionary + { + ["sourceRefName"] = "refs/heads/" + a.Source.Replace("refs/heads/", ""), + ["targetRefName"] = "refs/heads/" + a.Target.Replace("refs/heads/", ""), + ["title"] = a.Title.Trim(), + ["description"] = a.Text is null ? null : await MentionsAsync(session.People, a.Text, ct), + ["isDraft"] = a.Draft, + ["workItemRefs"] = WorkQueries.ParseIds(a.WorkItems).Select(i => new { id = i.ToString(CultureInfo.InvariantCulture) }).ToArray(), + ["reviewers"] = reviewers, + }; + var pr = await client.PostAsync($"_apis/git/repositories/{repo.Id}/pullrequests", body, ct); + var words = Html.Words(a.Text); + return $"created {Line(pr)}" + (WorkQueries.ParseIds(a.WorkItems).Count == 0 ? "\nnote: no work item linked (branch policies often require one)" : "") + + (words > 150 ? $"\nnote: description has {words} words; usually ≤ 150 - what and why, changes, how to check" : ""); + } + + if (a.Id is not { } id) throw new ArgumentException($"{action} needs the PR id"); + var current = await GetAsync(session, a.Repo, id, ct); + var repoId = Json.Str(Json.Obj(current, "repository"), "id")!; + var baseUrl = $"_apis/git/repositories/{repoId}/pullrequests/{id}"; + switch (action) + { + case "comment": + { + if (string.IsNullOrWhiteSpace(a.Text)) throw new ArgumentException("comment needs text"); + var thread = new Dictionary + { + ["comments"] = new[] { new { parentCommentId = 0, content = await MentionsAsync(session.People, a.Text, ct), commentType = 1 } }, + ["status"] = 1, + }; + if (a.File is not null) + { + var path = "/" + a.File.Trim().TrimStart('/'); + thread["threadContext"] = a.Line is { } line + ? new { filePath = path, rightFileStart = new { line, offset = 1 }, rightFileEnd = new { line, offset = 1 } } + : new { filePath = path }; + } + var t = await client.PostAsync(baseUrl + "/threads", thread, ct); + return $"commented on PR {id} (thread {t.GetProperty("id").GetInt32()}{(a.File is null ? "" : $", {a.File}{(a.Line is { } l ? $":{l}" : "")}")})"; + } + case "reply": + if (a.Thread is not { } th || string.IsNullOrWhiteSpace(a.Text)) throw new ArgumentException("reply needs thread and text"); + await client.PostAsync($"{baseUrl}/threads/{th}/comments", new { parentCommentId = 1, content = await MentionsAsync(session.People, a.Text, ct), commentType = 1 }, ct); + return $"replied in thread {th} of PR {id}"; + case "resolve": + { + if (a.Thread is not { } th2) throw new ArgumentException("resolve needs thread"); + var status = (a.Status ?? "fixed").Trim().ToLowerInvariant(); + if (status is not ("fixed" or "closed" or "wontfix" or "bydesign" or "active" or "pending")) + throw new ArgumentException("status is fixed, closed, wontFix, byDesign, active or pending"); + await client.SendAsync(HttpMethod.Patch, $"{baseUrl}/threads/{th2}", new { status = status switch { "wontfix" => "wontFix", "bydesign" => "byDesign", _ => status } }, "application/json", ct); + return $"thread {th2} of PR {id} is now {status}"; + } + case "vote": + { + if (a.Vote is null || !Votes.TryGetValue(a.Vote.Trim(), out var vote)) throw new ArgumentException($"vote is one of: {string.Join(", ", Votes.Keys)}"); + var (me, _) = await new Boards(session).MeAsync(ct); + await client.SendAsync(HttpMethod.Put, $"{baseUrl}/reviewers/{me}", new { vote }, "application/json", ct); + return $"voted on PR {id}: {VoteText(vote)}"; + } + case "publish": + await client.SendAsync(HttpMethod.Patch, baseUrl, new { isDraft = false }, "application/json", ct); + return $"PR {id} is published (no longer a draft)"; + case "abandon": + await client.SendAsync(HttpMethod.Patch, baseUrl, new { status = "abandoned" }, "application/json", ct); + return $"PR {id} abandoned"; + default: // complete + { + var lastCommit = Json.Str(Json.Obj(current, "lastMergeSourceCommit"), "commitId"); + var body = new Dictionary + { + ["status"] = "completed", + ["lastMergeSourceCommit"] = new { commitId = lastCommit }, + ["completionOptions"] = new Dictionary + { + ["deleteSourceBranch"] = a.DeleteSourceBranch, + ["mergeStrategy"] = a.MergeStrategy, + ["transitionWorkItems"] = false, + }, + }; + var done = await client.SendAsync(HttpMethod.Patch, baseUrl, body, "application/json", ct); + return $"PR {id}: {Json.Str(done, "status")}{(Json.Str(done, "mergeStatus") is { } ms ? $", merge {ms}" : "")}"; + } + } + } +} diff --git a/src/AdoLens/Queries.cs b/src/AdoLens/Queries.cs index eca9cc3..f2ca198 100644 --- a/src/AdoLens/Queries.cs +++ b/src/AdoLens/Queries.cs @@ -44,6 +44,34 @@ public static async Task StatusAsync(Lens lens, string? connection, bool return sb.ToString(); } + /// + /// Shows the settings of a connection, or replaces its environment map ("dev=DEV (sbx01); staging=UAT; master=PROD", + /// in flow order; empty clears it). Saved for good, so the question is asked only once. + /// + public static string SettingsAnswer(Lens lens, string? connection, string? environments) + { + var cfg = lens.For(connection).Config; + if (!lens.Settings.Connections.TryGetValue(cfg.Connection, out var conn)) + throw new InvalidOperationException($"connection '{cfg.Connection}' comes from environment variables and cannot be changed here"); + if (environments is not null) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var part in environments.Split([';', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var eq = part.IndexOf('='); + if (eq <= 0 || eq == part.Length - 1) throw new ArgumentException($"'{part}' is not key=label (e.g. staging=UAT (sbx03))"); + map[part[..eq].Trim()] = part[(eq + 1)..].Trim(); + } + conn.Environments = map; + lens.SaveSettings(); + } + var sb = new StringBuilder(environments is null ? "" : "saved.\n"); + sb.AppendLine($"connection {cfg.Connection}: https://dev.azure.com/{conn.Organization}, project {conn.Project ?? "-"}, team {conn.Team ?? "-"}, mode {lens.Settings.Mode}"); + sb.Append(conn.Environments.Count == 0 ? "environments: not set (ado_trace proposes them)" + : "environments (in flow order): " + string.Join(" -> ", conn.Environments.Select(e => $"{e.Value} [{e.Key}]"))); + return sb.ToString(); + } + public static async Task FieldsAsync(Lens lens, string? connection, string? project, string? type, string? field, string? changes, bool all, bool refresh, CancellationToken ct = default) { diff --git a/src/AdoLens/Trace.cs b/src/AdoLens/Trace.cs index d5ba21d..ae2d089 100644 --- a/src/AdoLens/Trace.cs +++ b/src/AdoLens/Trace.cs @@ -273,9 +273,23 @@ static string Names(IEnumerable list) => : "NOT merged yet")); } if (where.Count > 0) sb.AppendLine("\nWHERE").AppendLine(string.Join('\n', where)); - else if (changes.Count > 0) - sb.AppendLine("\nWHERE").AppendLine("no release stage deploys the branches of this change" + - (env.Count == 0 ? $"; map branches to environments with 'adolens config --connection {session.Config.Connection} --env staging=UAT'" : "")); + else if (changes.Count > 0) sb.AppendLine("\nWHERE").AppendLine("no release stage deploys the branches of this change"); + + // No environment names yet: propose the flow found here, so Claude asks once and saves it for good. + if (env.Count == 0 && changes.Count > 0) + { + var candidates = deployments + .Where(d => where.Any(w => w.Contains($"{d.Definition} / {d.Stage}", StringComparison.Ordinal))) + .Select(d => d.Branch?.StartsWith("$/", StringComparison.Ordinal) == true ? $"{d.Definition}/{d.Stage}" : Code.Short(d.Branch)) + .Concat(changes.Select(x => x.Change.TargetBranch)) + .Where(k => !string.IsNullOrEmpty(k)).Select(k => k!).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var proposal = ProposeFlow(candidates); + if (proposal.Count > 0) + sb.AppendLine("\nSETUP").AppendLine( + $"environment names are not set for connection {session.Config.Connection}. Found: {string.Join(", ", proposal.Select(p => p.Guess is null ? p.Key : $"{p.Key} (looks like {p.Guess})"))}. " + + "Ask the user what each is called and in which order code moves through them (e.g. DEV sbx01 -> UAT sbx03 -> PROD), " + + "save it once with ado_settings environments=\"" + string.Join("; ", proposal.Select(p => $"{p.Key}={p.Guess ?? "…"}")) + "\", then answer with those names."); + } if (changes.Count > 0 && where.Count > 0 && !anyIn) signals.Add("not deployed to any stage yet"); @@ -294,6 +308,27 @@ static string Names(IEnumerable list) => return sb.ToString().TrimEnd(); } + /// + /// A first guess of the flow from branch and stage names: development branches, then test / staging, then + /// production; release branches (R350, release/2.1) are steps on the way, not environments. + /// + internal static List<(string Key, string? Guess)> ProposeFlow(IEnumerable keys) + { + static (int Order, string? Guess) Classify(string key) + { + var k = key.ToLowerInvariant(); + var last = k[(k.LastIndexOf('/') + 1)..]; + if (Regex.IsMatch(k, @"^(r\d+|release.*|hotfix.*|feature.*|bugfix.*|users/.*)$")) return (-1, null); + if (last is "dev" or "develop" or "development" or "sit" or "int" or "integration") return (0, "DEV"); + if (last is "staging" or "stage" or "uat" or "test" or "qa" or "preprod") return (1, "UAT"); + if (last is "master" or "main" or "prod" or "production") return (2, "PROD"); + return (3, null); + } + return keys.Select(k => (Key: k, C: Classify(k))).Where(x => x.C.Order >= 0) + .OrderBy(x => x.C.Order).ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .Select(x => (x.Key, x.C.Guess)).ToList(); + } + [GeneratedRegex(@"^Merged PR (?\d+)")] private static partial Regex MergedPr(); diff --git a/tests/AdoLens.Tests/FakeAdo.cs b/tests/AdoLens.Tests/FakeAdo.cs index f3c0e17..e0e6a1f 100644 --- a/tests/AdoLens.Tests/FakeAdo.cs +++ b/tests/AdoLens.Tests/FakeAdo.cs @@ -33,7 +33,7 @@ protected override Task SendAsync(HttpRequestMessage reques var path = url.Split('?')[0]; // Longest prefix first, so "/x/_apis/wit/workitemtypes/Bug/fields" beats "/x/_apis/wit/workitemtypes". foreach (var (prefix, respond) in routes.OrderByDescending(r => r.Prefix.Length)) - if (path.StartsWith(prefix, StringComparison.Ordinal)) + if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { var res = respond(request); res.RequestMessage = request; diff --git a/tests/AdoLens.Tests/PrPipelineTests.cs b/tests/AdoLens.Tests/PrPipelineTests.cs new file mode 100644 index 0000000..e23d514 --- /dev/null +++ b/tests/AdoLens.Tests/PrPipelineTests.cs @@ -0,0 +1,173 @@ +using System.Net; +using System.Text; + +namespace AdoLens.Tests; + +public class PrPipelineTests : IDisposable +{ + readonly string dir = Path.Combine(Path.GetTempPath(), $"adolens-pr-{Guid.NewGuid():N}"); + readonly DemoProject demo = new(); + readonly DemoItems items = new(); + readonly List<(string Method, string Path, string Body)> writes = []; + readonly Settings settings; + readonly Lens lens; + + static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + + HttpResponseMessage Record(HttpRequestMessage r, string answer) + { + lock (writes) writes.Add((r.Method.Method, r.RequestUri!.AbsolutePath, r.Content?.ReadAsStringAsync().Result ?? "")); + return Json(answer); + } + + const string Pr = """ + {"pullRequestId":482,"status":"active","title":"Login form","isDraft":false,"mergeStatus":"succeeded","description":"Adds the form.", + "creationDate":"2026-10-14T08:00:00Z","createdBy":{"id":"ben","displayName":"Ben Kowalski"}, + "sourceRefName":"refs/heads/feature/1-login","targetRefName":"refs/heads/dev","lastMergeSourceCommit":{"commitId":"head1"}, + "repository":{"id":"r1","name":"portal","project":{"id":"p1","name":"Demo"}}, + "reviewers":[{"id":"me-1","displayName":"Anna Nowak","vote":0,"isRequired":true},{"id":"eva","displayName":"Eva Kowalska","vote":10}]} + """; + + public PrPipelineTests() + { + var ado = items.Routes(demo.Routes(new FakeAdo())) + .On("/contoso/_apis/connectionData", """{"authenticatedUser":{"id":"me-1","providerDisplayName":"Anna Nowak"}}""") + .On("/contoso/Demo/_apis/git/pullrequests", "{\"value\":[" + Pr + "]}") + .On("/contoso/_apis/git/pullrequests/482", Pr) + .On("/contoso/_apis/git/repositories/r1/pullRequests/482/threads", r => r.Method == HttpMethod.Post + ? Record(r, """{"id":77}""") + : Json(""" + {"value":[ + {"id":1,"status":"active","threadContext":{"filePath":"/src/M/AxClass/LoginService.xml","rightFileStart":{"line":42}}, + "comments":[{"commentType":"text","author":{"displayName":"Eva Kowalska"},"content":"Check the [limit](https://x/y) here"}, + {"commentType":"text","author":{"displayName":"Ben Kowalski"},"content":"Done"}]}, + {"id":2,"status":"active","comments":[{"commentType":"text","author":{"displayName":"Ben"},"content":"Submitted conflict resolution for the file(s)."}]}, + {"id":3,"status":"fixed","comments":[{"commentType":"text","author":{"displayName":"Eva Kowalska"},"content":"Typo"}]}, + {"id":4,"status":"active","comments":[{"commentType":"system","content":"Ben voted 10"}]}]} + """)) + .On("/contoso/_apis/git/repositories/r1/pullRequests/482/workitems", """{"value":[{"id":"1"}]}""") + .On("/contoso/_apis/git/repositories/r1/pullrequests/482/reviewers/me-1", r => Record(r, "{}")) + .On("/contoso/_apis/git/repositories/r1/pullrequests", r => Record(r, Pr)) + .On("/contoso/Demo/_apis/git/repositories/portal", """{"id":"r1","name":"portal","project":{"name":"Demo"}}""") + .On("/contoso/Demo/_apis/policy/evaluations", """{"value":[{"configuration":{"type":{"displayName":"Build"}},"status":"rejected"},{"configuration":{"type":{"displayName":"Work item linking"}},"status":"approved"}]}""") + .On("/contoso/Demo/_apis/build/builds/900/timeline", """ + {"records":[ + {"type":"Job","name":"Build","result":"failed"}, + {"type":"Task","name":"Build the solution","result":"failed","log":{"id":7}, + "issues":[{"type":"warning","message":"Package folder already exists"},{"type":"error","message":"Process 'msbuild.exe' exited with code '1'."}]}, + {"type":"Task","name":"Restore","result":"succeeded"}]} + """) + .On("/contoso/Demo/_apis/build/builds/900/logs/7", _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + 2026-10-14T20:48:25.1234567Z Starting build + 2026-10-14T20:48:26.0000000Z warning: package folder exists + 2026-10-14T20:50:00.0000000Z C:\SDK\Build.targets(40,5): error : Fatal: dynamics://Table/DemoTable - metadata error [C:\SDK\BuildMetadata.proj] + 2026-10-14T20:50:00.0000000Z C:\SDK\Build.targets(40,5): error : Fatal: dynamics://Table/DemoTable - metadata error [C:\SDK\BuildMetadata.proj] + 2026-10-14T20:50:01.0000000Z Compilation failed. + 2026-10-14T20:50:02.0000000Z Build complete: 0 error(s)? no + """, Encoding.UTF8, "text/plain"), + }) + .On("/contoso/Demo/_apis/build/builds/900", """ + {"id":900,"buildNumber":"2026.10.14.1","status":"completed","result":"failed","sourceBranch":"refs/heads/dev","sourceVersion":"abcdef1234", + "reason":"individualCI","definition":{"name":"Demo CI"},"requestedFor":{"displayName":"Ben Kowalski"}, + "startTime":"2026-10-14T20:40:00Z","finishTime":"2026-10-14T20:59:00Z"} + """) + .On("/contoso/Demo/_apis/build/definitions", """{"value":[{"id":5,"name":"Demo CI"},{"id":6,"name":"Demo CI nightly"}]}""") + .On("/contoso/Demo/_apis/build/builds", r => r.Method == HttpMethod.Post + ? Record(r, """{"id":901,"buildNumber":"2026.10.14.2","status":"notStarted","sourceBranch":"refs/heads/dev","definition":{"name":"Demo CI"},"requestedFor":{"displayName":"Anna Nowak"},"queueTime":"2026-10-14T21:00:00Z"}""") + : Json("""{"value":[{"id":900,"buildNumber":"2026.10.14.1","result":"failed","sourceBranch":"refs/heads/dev","definition":{"name":"Demo CI"},"requestedFor":{"displayName":"Ben Kowalski"},"finishTime":"2026-10-14T20:59:00Z"}]}""")); + settings = new Settings { DefaultConnection = "contoso" }; + settings.Connections["contoso"] = new Connection { Organization = "contoso", Project = "Demo" }; + lens = new Lens(settings, "0.0.0", dir, _ => null, _ => "pat", _ => ado.Client()); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + + [Fact] + public async Task Pull_requests_waiting_for_me_are_listed_first() + { + var text = await PullRequestQueries.ListAsync(lens, null, null, null); + Assert.Contains("waiting for your review (1):\n PR 482 [portal] Login form: dev <- feature/1-login (Ben Kowalski,", text.Replace("\r", "")); + Assert.Contains("1 approved, 1 no vote", text); + } + + [Fact] + public async Task One_pull_request_shows_votes_checks_work_items_and_human_threads_only() + { + var text = (await PullRequestQueries.ShowAsync(lens, null, 482, null, null)).Replace("\r", ""); + Assert.Contains("reviewers: Anna Nowak no vote (required); Eva Kowalska approved", text); + Assert.Contains("checks: Build rejected; Work item linking approved", text); + Assert.Contains("work items: #1 Product Backlog Item [New] Login page", text); + Assert.Contains("comments: 1 open thread(s), 1 resolved", text); + Assert.Contains("- thread 1 [active] LoginService (class):42 - Eva Kowalska: Check the limit here (1 reply, last: Done)", text); + Assert.DoesNotContain("conflict resolution", text); + Assert.DoesNotContain("voted", text); + } + + [Fact] + public async Task Pull_request_actions_send_what_azure_devops_expects() + { + Assert.Equal("voted on PR 482: approved", await PullRequestQueries.ActAsync(lens, null, new() { Action = "vote", Id = 482, Vote = "approve" })); + Assert.Contains("\"vote\":10", writes.Single(w => w.Method == "PUT").Body); + + await PullRequestQueries.ActAsync(lens, null, new() { Action = "comment", Id = 482, Text = "@[Anna] see line", File = "src/M/AxClass/LoginService.xml", Line = 42 }); + var comment = writes.Single(w => w.Path.EndsWith("/threads")).Body; + Assert.Contains("@\\u003Cp-anna\\u003E see line", comment); + Assert.Contains("\"filePath\":\"/src/M/AxClass/LoginService.xml\",\"rightFileStart\":{\"line\":42", comment); + + var created = await PullRequestQueries.ActAsync(lens, null, new() + { Action = "create", Repo = "portal", Source = "feature/1-login", Target = "dev", Title = "Login form", Text = "Adds the form.", WorkItems = "1" }); + var body = writes.Last(w => w.Path.EndsWith("/pullrequests")).Body; + Assert.Contains("\"sourceRefName\":\"refs/heads/feature/1-login\"", body); + Assert.Contains("\"workItemRefs\":[{\"id\":\"1\"}]", body); + Assert.StartsWith("created PR 482", created); + + await Assert.ThrowsAsync(() => PullRequestQueries.ActAsync(lens, null, new() { Action = "merge", Id = 482 })); + } + + [Fact] + public async Task A_failed_build_shows_errors_and_the_error_lines_of_its_log() + { + var text = (await PipelineQueries.BuildAsync(lens, null, null, 900, null)).Replace("\r", ""); + Assert.StartsWith("build 900 Demo CI 2026.10.14.1: failed, dev, Ben Kowalski,", text); + Assert.Contains("failed: Build > Build the solution", text); + Assert.Contains(" error: Process 'msbuild.exe' exited with code '1'.", text); + Assert.DoesNotContain("Package folder already exists", text); + Assert.Contains(" 3: error : Fatal: dynamics://Table/DemoTable - metadata error\n", text + "\n"); + Assert.Contains(" 5: Compilation failed.", text); + Assert.Single(text.Split('\n'), l => l.Contains("DemoTable")); + } + + [Fact] + public async Task Pipelines_are_listed_and_run_by_name() + { + Assert.Contains("build 900 Demo CI 2026.10.14.1: failed, dev", await PipelineQueries.ListAsync(lens, null, null, null, null, 10)); + var queued = await PipelineQueries.RunAsync(lens, null, null, "demo ci", "dev", null); + Assert.StartsWith("queued build 901 Demo CI", queued); + Assert.Contains("\"definition\":{\"id\":5},\"sourceBranch\":\"refs/heads/dev\"", writes.Single().Body); + var ambiguous = await Assert.ThrowsAsync(() => PipelineQueries.RunAsync(lens, null, null, "nightly x", null, null)); + Assert.Contains("matches 2 pipelines", ambiguous.Message); + } + + [Fact] + public void Environments_are_saved_for_good_in_flow_order() + { + var answer = Queries.SettingsAnswer(lens, null, "dev=DEV (sbx01); staging=UAT (sbx03); master=PROD"); + Assert.Contains("environments (in flow order): DEV (sbx01) [dev] -> UAT (sbx03) [staging] -> PROD [master]", answer); + var saved = Settings.Load(lens.SettingsPath); + Assert.Equal(["dev", "staging", "master"], saved.Connections["contoso"].Environments.Keys); + Assert.Throws(() => Queries.SettingsAnswer(lens, null, "staging")); + } + + [Fact] + public void The_flow_is_guessed_from_branch_and_stage_names() + { + var flow = TraceQueries.ProposeFlow(["master", "R350", "staging", "dev", "Main-Deploy/Stage 1", "feature/x"]); + Assert.Equal([("dev", "DEV"), ("staging", "UAT"), ("master", "PROD"), ("Main-Deploy/Stage 1", (string?)null)], flow); + } +} diff --git a/tests/AdoLens.Tests/SetupTests.cs b/tests/AdoLens.Tests/SetupTests.cs index 78f8916..479ad99 100644 --- a/tests/AdoLens.Tests/SetupTests.cs +++ b/tests/AdoLens.Tests/SetupTests.cs @@ -253,7 +253,10 @@ public class ToolBudgetTests { static List RegisteredTools(Settings settings) { + // As in Program.cs: Lens is a service, so it is injected and never part of a tool's parameters. var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddSingleton(services, + new Lens(settings, "0.0.0", Path.GetTempPath(), _ => null, _ => null)); Microsoft.Extensions.DependencyInjection.McpServerServiceCollectionExtensions.AddMcpServer(services).WithActiveTools(settings); var sp = Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(services); return Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetServices(sp).ToList(); @@ -266,21 +269,25 @@ static List RegisteredTools(Settings settings) /// Raise the limit on purpose when new tools need it. /// [Theory] - [InlineData("po", 6000)] - [InlineData("dev", 6000)] + [InlineData("po", 8000)] + [InlineData("dev", 8000)] public void Tool_definitions_do_not_grow_by_accident(string mode, int maxTokens) { var settings = new Settings { Mode = mode }; + // What a client gets per tool: its name, description and input schema (plus a few annotation flags). var chars = McpTools.Instructions(settings).Length - + RegisteredTools(settings).Sum(t => System.Text.Json.JsonSerializer.Serialize(t.ProtocolTool, ModelContextProtocol.McpJsonUtilities.DefaultOptions).Length); - Assert.True(chars / 4 < maxTokens, $"mode {mode}: tool definitions take ~{chars / 4} tokens ({chars} characters)"); + + RegisteredTools(settings).Sum(t => t.ProtocolTool.Name.Length + (t.ProtocolTool.Description?.Length ?? 0) + + System.Text.Json.JsonSerializer.Serialize(t.ProtocolTool.InputSchema).Length + 60); + var detail = string.Join(", ", RegisteredTools(settings).Select(t => $"{t.ProtocolTool.Name}={System.Text.Json.JsonSerializer.Serialize(t.ProtocolTool.InputSchema).Length}")); + Assert.DoesNotContain("\"lens\"", string.Join("", RegisteredTools(settings).Select(t => t.ProtocolTool.InputSchema.GetRawText()))); + Assert.True(chars / 4 < maxTokens, $"mode {mode}: tool definitions take ~{chars / 4} tokens ({chars} characters): {detail}"); } [Fact] 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", + 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_wiki", "ado_wiki_write", "ado_work_item"], names); } From f4de5ce5cef275b45601b20cc427a9d82040047b Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:42:43 +0200 Subject: [PATCH 5/6] Trace SETUP: skip stale release stages, clear placeholder Stages without a deployment for half a year are leftovers, not part of the flow, so the proposal leaves them out; the placeholder for an unknown name is . --- src/AdoLens/Trace.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/AdoLens/Trace.cs b/src/AdoLens/Trace.cs index ae2d089..5e0fe07 100644 --- a/src/AdoLens/Trace.cs +++ b/src/AdoLens/Trace.cs @@ -278,7 +278,9 @@ static string Names(IEnumerable list) => // No environment names yet: propose the flow found here, so Claude asks once and saves it for good. if (env.Count == 0 && changes.Count > 0) { + // Stages not deployed for half a year are leftovers, not part of the flow. var candidates = deployments + .Where(d => d.When is { } w && w > DateTime.UtcNow.AddDays(-183)) .Where(d => where.Any(w => w.Contains($"{d.Definition} / {d.Stage}", StringComparison.Ordinal))) .Select(d => d.Branch?.StartsWith("$/", StringComparison.Ordinal) == true ? $"{d.Definition}/{d.Stage}" : Code.Short(d.Branch)) .Concat(changes.Select(x => x.Change.TargetBranch)) @@ -288,7 +290,7 @@ static string Names(IEnumerable list) => sb.AppendLine("\nSETUP").AppendLine( $"environment names are not set for connection {session.Config.Connection}. Found: {string.Join(", ", proposal.Select(p => p.Guess is null ? p.Key : $"{p.Key} (looks like {p.Guess})"))}. " + "Ask the user what each is called and in which order code moves through them (e.g. DEV sbx01 -> UAT sbx03 -> PROD), " + - "save it once with ado_settings environments=\"" + string.Join("; ", proposal.Select(p => $"{p.Key}={p.Guess ?? "…"}")) + "\", then answer with those names."); + "save it once with ado_settings environments=\"" + string.Join("; ", proposal.Select(p => $"{p.Key}={p.Guess ?? ""}")) + "\", then answer with those names."); } if (changes.Count > 0 && where.Count > 0 && !anyIn) signals.Add("not deployed to any stage yet"); From 941efa3cc10a2ba7c26446a8e80d16b08ed58610 Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:48:06 +0200 Subject: [PATCH 6/6] ADO Lens 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking work against what was asked, and where it is. - `ado_trace` (tool set `trace`): the acceptance criteria (from the parent for a task) and whether they changed after coding started; changesets, commits and pull requests linked to the item and its tasks, with the X++ objects they touch; builds, also those deleted by retention; every release stage of the code's branches with YES / NOT YET, in all projects the code lives in; for Git, whether the change is merged into each mapped branch; tests, tasks, signals. - `ado_changes`: files of a changeset, pull request or commit with added / changed / removed X++ methods per object; the diff of one file — AOT XML per method plus the metadata without raw XML noise; other files as unified diffs. - Environments per connection: `adolens config --connection --env staging=UAT` maps branches, release definitions or single release stages (`"Definition/Stage=UAT + SCM"`) to environment names; any number of steps, listed in that order. - Git flows with a PR per step (feature -> dev, -> staging, -> release branch -> master): a stage has the work when any of its PRs reached it, later changes not there yet are named; a PR and its merge commit are one change; release merges are one line. - Skill `po-verify` (`skills/po-verify`): guides Claude through checking a work item for a Product Owner. - Server instructions keep only advice the tool descriptions do not give (fewer tokens per session); the tool size check measures the JSON Claude really receives and only guards against accidental growth. - Pull requests (tool set `pr`): `ado_pr` (waiting for my review, mine, all active; one PR with votes, checks, work items and the open threads people wrote) and `ado_pr_update` (create, comment on a file and line, reply, resolve, vote, publish, complete, abandon; `@[name]` mentions). - Pipelines (tool set `pipelines`): `ado_pipeline` (recent builds; a failed build with its failed steps, errors and the error lines of the log without MSBuild noise) and `ado_pipeline_run`. - `ado_settings`: shows the connection and saves the environment map; when the map is missing, `ado_trace` ends with a SETUP note (the flow it found and a first guess) so Claude asks the user once and saves the answer. - CLI: `trace`, `changes`, `pr`, `pipeline`. --- CHANGELOG.md | 2 +- src/AdoLens/AdoLens.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index defe258..684036b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.2.0 — unreleased +## 0.2.0 — 2026-10-14 Checking work against what was asked, and where it is. diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index 5ca885f..8dab814 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.1.0 + 0.2.0