From 96b8c1527613a4eb897e2bcca373df68b075d965 Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:42:00 +0200 Subject: [PATCH 1/3] Test cases, team templates and rules, installer with a wizard, Claude Desktop extension Phase 5: ado_tests and ado_test_create (tool set test); ado_create template and templates/rules in ado_fields; Inno Setup installer (packaging/inno) and .mcpb extension (packaging/mcpb) built by pack.ps1, in CI and releases; login --pat-env for unattended setups. --- .github/install-inno.ps1 | 11 + .github/workflows/ci.yml | 24 +++ .github/workflows/release.yml | 10 +- CHANGELOG.md | 20 ++ README.md | 20 +- README.pl.md | 20 +- SECURITY.md | 5 + pack.ps1 | 76 +++++-- packaging/inno/ado-lens.iss | 295 +++++++++++++++++++++++++++ packaging/mcpb/manifest.json | 66 ++++++ src/AdoLens/Cli.cs | 38 +++- src/AdoLens/CommandLine.cs | 6 +- src/AdoLens/McpTools.cs | 32 ++- src/AdoLens/Program.cs | 10 + src/AdoLens/Queries.cs | 2 +- src/AdoLens/Settings.cs | 3 +- src/AdoLens/Templates.cs | 83 ++++++++ src/AdoLens/TestCases.cs | 239 ++++++++++++++++++++++ src/AdoLens/WorkItems.cs | 2 + src/AdoLens/WorkQueries.cs | 38 ++++ tests/AdoLens.Tests/FakeAdo.cs | 6 +- tests/AdoLens.Tests/SetupTests.cs | 3 +- tests/AdoLens.Tests/TestCaseTests.cs | 117 +++++++++++ 23 files changed, 1097 insertions(+), 29 deletions(-) create mode 100644 .github/install-inno.ps1 create mode 100644 packaging/inno/ado-lens.iss create mode 100644 packaging/mcpb/manifest.json create mode 100644 src/AdoLens/Templates.cs create mode 100644 src/AdoLens/TestCases.cs create mode 100644 tests/AdoLens.Tests/TestCaseTests.cs diff --git a/.github/install-inno.ps1 b/.github/install-inno.ps1 new file mode 100644 index 0000000..70ebc3f --- /dev/null +++ b/.github/install-inno.ps1 @@ -0,0 +1,11 @@ +# Makes sure Inno Setup 6 (ISCC.exe) is available on a GitHub runner: uses the preinstalled one, else installs it. +$ErrorActionPreference = 'Stop' +$known = @("${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe") +$iscc = $known | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $iscc) { + choco install innosetup --no-progress -y + if ($LASTEXITCODE -ne 0) { throw 'installing Inno Setup failed' } + $iscc = $known | Where-Object { Test-Path $_ } | Select-Object -First 1 +} +if (-not $iscc) { throw 'Inno Setup 6 (ISCC.exe) not found after installing' } +Write-Host "Inno Setup: $iscc" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f1121e..56595ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,27 @@ jobs: - name: Test run: dotnet test AdoLens.sln -c Release --no-build --logger "console;verbosity=normal" + + # The packages as a release builds them: the ZIP, the Claude Desktop extension and the installer (Inno Setup). + package: + name: package + needs: build-test + runs-on: windows-latest + timeout-minutes: 20 + env: + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_NOLOGO: '1' + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: 9.0.x + + - name: Inno Setup + shell: pwsh + run: ./.github/install-inno.ps1 + + - name: Package + shell: powershell + run: .\pack.ps1 -OutDir dist -Installer diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00281cc..45a9b25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,9 +41,13 @@ jobs: shell: powershell run: .\release-notes.ps1 -Version $env:VERSION -OutFile dist\notes.md + - name: Inno Setup + shell: pwsh + run: ./.github/install-inno.ps1 + - name: Package shell: powershell - run: .\pack.ps1 -OutDir dist + run: .\pack.ps1 -OutDir dist -Installer # vX.Y.Z-beta.N is a test release: marked as pre-release, never "Latest". - name: Publish the release @@ -52,4 +56,6 @@ jobs: GH_TOKEN: ${{ github.token }} run: | $kind = if ($env:TAG -match '-') { '--prerelease' } else { '--latest' } - gh release create $env:TAG "dist/ado-lens-$env:VERSION.zip" "dist/ado-lens-$env:VERSION.zip.sha256" --title "ADO Lens $env:VERSION" --notes-file dist/notes.md --verify-tag $kind + $assets = "zip", "zip.sha256", "mcpb", "mcpb.sha256" | ForEach-Object { "dist/ado-lens-$env:VERSION.$_" } + $assets += "dist/ado-lens-setup-$env:VERSION.exe", "dist/ado-lens-setup-$env:VERSION.exe.sha256" + gh release create $env:TAG @assets --title "ADO Lens $env:VERSION" --notes-file dist/notes.md --verify-tag $kind diff --git a/CHANGELOG.md b/CHANGELOG.md index 9adbf30..abbbcd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.5.0 — unreleased + +Tests, templates, and two more ways to install. + +- `ado_tests` (tool set `test`): the Test Cases of work items or of a sprint's requirements and bugs, with the latest + outcome per plan, suite and configuration; items without tests; steps with `detail=full`. +- `ado_test_create`: Test Cases from markdown (`## title`, `1. action => expected`), linked as tested-by, in the item's + area and iteration, optionally added to a suite; recorded for `ado_undo`. +- `ado_create template=`: a team's work item template fills the values not given; `ado_fields type=…` lists the + team's templates and the custom rules of the type in an inherited process. +- Installer with a wizard (`ado-lens-setup-X.Y.Z.exe`, Inno Setup, per user, no administrator rights): Azure DevOps + link and PAT checked before installing (the PAT goes to Credential Manager over an environment variable, never on a + command line), mode, skills for Claude Code and ZIPs for Claude Desktop, registration (asks to quit Claude Desktop + when needed); updates skip the questions; uninstall from Settings → Apps. +- Claude Desktop extension (`ado-lens-X.Y.Z.mcpb`): project link, PAT (kept by Claude Desktop in secure storage) and + mode in the extension settings. +- `adolens login --pat-env ` for unattended setups; without a project and with several in the organization it + lists them and stops (exit code 4) instead of asking. +- Releases publish the installer and the extension next to the ZIP, each with its SHA-256; CI builds all three. + ## 0.4.0 — 2026-10-15 How it works and why, and repeatable changes. diff --git a/README.md b/README.md index 2b43e19..c6bb1cd 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,18 @@ signed) program. Nothing else is needed on the machine, not even .NET. The install package is not part of the repository (`dist\` is excluded by `.gitignore`). Other ways: +**With a wizard (installer EXE).** Download `ado-lens-setup-X.Y.Z.exe` from the **Releases** tab and run it — no +administrator rights. It asks for the Azure DevOps link and the PAT (checked before anything is installed), who uses +it (Product Owner / developer / everything), installs the skills and registers ADO Lens in Claude; an update skips the +questions. Until the program is signed, Windows SmartScreen may show "Windows protected your PC" for a file downloaded +in a browser: *More info → Run anyway* (or use the one-line install above, which avoids it). Uninstall it from +*Settings → Apps*. + +**Claude Desktop only (extension).** `ado-lens-X.Y.Z.mcpb` from **Releases**: open it with Claude Desktop (or drag it +into *Settings → Extensions*) and fill in the project link and the PAT, which Claude Desktop keeps in the system's secure +storage. It does not set up Claude Code or the skills; when ADO Lens is already signed in on the machine, leave the +fields empty. + **A. From a release package** — nothing needs to be installed on the target machine, not even .NET. 1. Download `ado-lens-X.Y.Z.zip` from the **Releases** tab of this repository. @@ -162,6 +174,7 @@ never a permission. | `wiki` | `ado_wiki`, `ado_wiki_write` | ✓ | ✓ | ✓ | | `trace` | `ado_trace`, `ado_changes`, `ado_file` | ✓ | ✓ | ✓ | | `recipes` | `ado_recipe` | ✓ | ✓ | ✓ | +| `test` | `ado_tests`, `ado_test_create` | ✓ | – | ✓ | | `test`, `recipes` | planned | ✓ | (recipes) | ✓ | | `code` | planned | – | ✓ | ✓ | @@ -177,7 +190,7 @@ What each MCP tool answers, when Claude should reach for it, and the CLI command | `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"` | -| `ado_create` | creates an item from markdown; required fields, allowed values and people are checked first; soft length notes | writing stories, bugs, tasks | `adolens create --type Bug --title "…" --description "…" --field "Severity=2 - High"` | +| `ado_create` | creates an item from markdown; required fields, allowed values and people are checked first; `template` fills what is not given from a team template; soft length notes | writing stories, bugs, tasks | `adolens create --type Bug --title "…" --description "…" --field "Severity=2 - High"` | | `ado_update` | changes one or many items (ids or WIQL): fields, state along the transitions, people, tags `+a; -b`, iteration `current`, a comment; many items → preview + `confirm` token | closing, moving to a sprint, mass changes | `adolens update 123 --state Done --comment "…"`, `adolens update --wiql "…" --tags "+reviewed"` | | `ado_comment` | markdown comment; `@[name]` mentions notify the person | asking, deciding, reporting | `adolens comment 123 "@[Anna] please confirm"` | | `ado_link` | parent, child, related, predecessor, successor, duplicate, duplicate-of, tested-by, tests; `remove` unlinks | structuring the backlog | `adolens link 123 456 --type child` | @@ -192,6 +205,8 @@ What each MCP tool answers, when Claude should reach for it, and the CLI command | `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_tests` | the tests of work items (ids) or of a sprint's requirements and bugs: their Test Cases with the latest outcome per plan, suite and configuration (who, when), items without tests; `detail=full` adds the steps | "is it tested", sprint review | `adolens tests 123`, `adolens tests` (current sprint) | +| `ado_test_create` | Test Cases for a work item from markdown (`## title`, steps `1. action => expected`), linked as tested-by, in the item's area and iteration; `plan` + `suite` (id or name) add them to a suite | turning acceptance criteria into tests | `adolens test-create 123 --file cases.md --plan R1 --suite Login` | | `ado_my_day` | since the previous working day (or `since`): where you were mentioned (all projects), your items and followed items changed by others, pull requests waiting for your review | the start of the day | `adolens myday --since 3d` | | `ado_search` | one search over work items (full text, with the field that matched), wiki pages and code (Git and TFVC, X++ objects named), grouped; a part that is not available (e.g. Code Search not installed) is reported and the rest still answers | "how does X work", "where is Y" | `adolens search "credit limit" --scope work;wiki` | | `ado_file` | a file by TFVC path, Git path + repo, or just its name (found through Code Search); X++ objects as their list of methods, `part` shows one method; `history=true`: who changed it, when, through which pull request and for which work items | "what does this object do", "why is it like this" | `adolens file CreditCheck.xml --part validate`, `adolens file CreditCheck.xml --history` | @@ -248,6 +263,8 @@ pack.ps1 release package (ZIP + SHA-256) release.ps1 release-notes.ps1 releases (see below); .github\ CI and Release workflows, issue forms install.ps1 uninstall.ps1 installation; get.ps1 is the one-line download and install skills\ skills for Claude (po-verify, po-story, po-sprint, po-explain) +packaging\inno\ installer with a wizard (Inno Setup script, built by pack.ps1 when ISCC.exe is there) +packaging\mcpb\ Claude Desktop extension manifest docs\PLAN.md road map build\ dist\ output (keep nothing of your own there) ``` @@ -294,6 +311,7 @@ regular expression per line, `!word` for allowed words. Without a list the relea | `SetupFlowTests.cs` | release versions and packages for updates, the team's wiki table (round trip, hand-edited pages), detecting the flow from releases and branches, the team map on the wiki with its version, local map over the team's | | `PrPipelineTests.cs` | PR lists and details (votes, checks, human threads only), PR actions (vote, comment on a line with a mention, create), failed build errors and log lines, running pipelines by name, saving environments, guessing the flow | | `TraceTests.cs` | line diff (Myers, unified hunks), X++ per method with form data sources, object names from AOT paths, `ado_trace` across TFVC and Git (deployed stages YES / NOT YET, environment labels, merged branches, deleted builds, criteria changed after coding), `ado_changes` lists and file diffs | +| `TestCaseTests.cs` | test cases from markdown and their steps XML, creating and adding them to a suite, test status per item with outcomes, team templates on create, process rules in `ado_fields` | | `KnowledgeTests.cs` | search across work items, wiki and code (a missing part still answers), files found by name with X++ methods, file history with pull requests and work items, undo (values back, fields changed by others kept, comments deleted), backlog health, release notes, recipes (save, preview, run) | | `WikiTests.cs` | page tree, page with version, replace only with the current version (412 never overwrites), append, create over an existing page, search snippets | | `WorkItemTests.cs` | work item answers, required fields and allowed values before writing, transitions, preview and confirm of bulk updates, mentions, links, queries, HTML ⇄ markdown, PAT never sent to other hosts | diff --git a/README.pl.md b/README.pl.md index 82a6001..2f7137a 100644 --- a/README.pl.md +++ b/README.pl.md @@ -43,6 +43,18 @@ niepodpisanego) programu. Na komputerze nie trzeba niczego więcej, nawet .NET. Paczki instalacyjnej nie ma w repozytorium (`dist\` jest pomijany przez `.gitignore`). Inne drogi: +**Z kreatorem (instalator EXE).** Pobierz `ado-lens-setup-X.Y.Z.exe` z zakładki **Releases** i uruchom — bez uprawnień +administratora. Pyta o link do Azure DevOps i PAT (sprawdzane, zanim cokolwiek się zainstaluje), kto używa (Product +Owner / programista / wszystko), instaluje skille i rejestruje ADO Lens w Claudzie; aktualizacja pomija pytania. Dopóki +program nie jest podpisany, Windows SmartScreen może przy pliku pobranym przeglądarką pokazać „System Windows ochronił +ten komputer”: *Więcej informacji → Uruchom mimo to* (albo użyj instalacji jedną linijką powyżej, która tego unika). +Odinstalowanie: *Ustawienia → Aplikacje*. + +**Tylko Claude Desktop (rozszerzenie).** `ado-lens-X.Y.Z.mcpb` z **Releases**: otwórz go w Claude Desktop (albo +przeciągnij do *Settings → Extensions*) i wpisz link do projektu oraz PAT, który Claude Desktop trzyma w bezpiecznym +magazynie systemu. Nie konfiguruje Claude Code ani skilli; gdy ADO Lens jest już zalogowany na tym komputerze, zostaw +pola puste. + **A. Z gotowej paczki** — na maszynie docelowej nie trzeba niczego instalować, nawet .NET. 1. Pobierz `ado-lens-X.Y.Z.zip` z zakładki **Releases** tego repozytorium. @@ -164,6 +176,7 @@ Nigdy nie jest uprawnieniem. | `wiki` | `ado_wiki`, `ado_wiki_write` | ✓ | ✓ | ✓ | | `trace` | `ado_trace`, `ado_changes`, `ado_file` | ✓ | ✓ | ✓ | | `recipes` | `ado_recipe` | ✓ | ✓ | ✓ | +| `test` | `ado_tests`, `ado_test_create` | ✓ | – | ✓ | | `test`, `recipes` | w planie | ✓ | (recipes) | ✓ | | `code` | w planie | – | ✓ | ✓ | @@ -179,7 +192,7 @@ Na co odpowiada każde narzędzie MCP, kiedy Claude powinien po nie sięgnąć i | `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"` | -| `ado_create` | tworzy element z markdownu; najpierw sprawdza pola wymagane, dozwolone wartości i osoby; miękkie uwagi o długości | pisanie historii, bugów, zadań | `adolens create --type Bug --title "…" --description "…" --field "Severity=2 - High"` | +| `ado_create` | tworzy element z markdownu; najpierw sprawdza pola wymagane, dozwolone wartości i osoby; `template` uzupełnia brakujące wartości z szablonu zespołu; miękkie uwagi o długości | pisanie historii, bugów, zadań | `adolens create --type Bug --title "…" --description "…" --field "Severity=2 - High"` | | `ado_update` | zmienia jeden lub wiele elementów (id albo WIQL): pola, stan zgodnie z przejściami, osoby, tagi `+a; -b`, iteracja `current`, komentarz; wiele elementów → podgląd + token `confirm` | zamykanie, przenoszenie do sprintu, zmiany masowe | `adolens update 123 --state Done --comment "…"`, `adolens update --wiql "…" --tags "+reviewed"` | | `ado_comment` | komentarz w markdownie; `@[imię]` oznacza osobę i wysyła jej powiadomienie | pytania, decyzje, raporty | `adolens comment 123 "@[Anna] potwierdź proszę"` | | `ado_link` | rodzic, dziecko, powiązany, poprzednik, następnik, duplikat, duplikat-czego, testowany-przez, testuje; `remove` usuwa | porządkowanie backlogu | `adolens link 123 456 --type child` | @@ -194,6 +207,8 @@ Na co odpowiada każde narzędzie MCP, kiedy Claude powinien po nie sięgnąć i | `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_tests` | testy work itemów (id) albo wymagań i błędów sprintu: ich Test Case'y z ostatnim wynikiem w każdym planie, zestawie i konfiguracji (kto, kiedy), elementy bez testów; `detail=full` dodaje kroki | „czy to przetestowane”, przegląd sprintu | `adolens tests 123`, `adolens tests` (bieżący sprint) | +| `ado_test_create` | Test Case'y dla work itemu z markdownu (`## tytuł`, kroki `1. akcja => oczekiwany wynik`), podpięte jako tested-by, w obszarze i iteracji elementu; `plan` + `suite` (id albo nazwa) dodają je do zestawu | kryteria akceptacji zamienione w testy | `adolens test-create 123 --file przypadki.md --plan R1 --suite Logowanie` | | `ado_my_day` | od poprzedniego dnia roboczego (albo `since`): gdzie cię oznaczono (wszystkie projekty), twoje i obserwowane elementy zmienione przez innych, pull requesty czekające na twój przegląd | początek dnia | `adolens myday --since 3d` | | `ado_search` | jedno wyszukiwanie po work itemach (pełny tekst, z polem, w którym znaleziono), stronach wiki i kodzie (Git i TFVC, z nazwami obiektów X++), pogrupowane; niedostępna część (np. brak Code Search) jest zgłaszana, reszta odpowiada | „jak działa X”, „gdzie jest Y” | `adolens search "limit kredytowy" --scope work;wiki` | | `ado_file` | plik po ścieżce TFVC, ścieżce Git + repo albo samej nazwie (szukanej przez Code Search); obiekty X++ jako lista metod, `part` pokazuje jedną metodę; `history=true`: kto zmieniał, kiedy, w którym pull requeście i dla jakich work itemów | „co robi ten obiekt”, „dlaczego tak działa” | `adolens file CreditCheck.xml --part validate`, `adolens file CreditCheck.xml --history` | @@ -250,6 +265,8 @@ pack.ps1 paczka wydania (ZIP + SHA-256) release.ps1 release-notes.ps1 wydania (zob. niżej); .github\ workflowy CI i Release, formularze zgłoszeń install.ps1 uninstall.ps1 instalacja; get.ps1 to pobranie i instalacja jedną linijką skills\ skille dla Claude'a (po-verify, po-story, po-sprint, po-explain) +packaging\inno\ instalator z kreatorem (skrypt Inno Setup, budowany przez pack.ps1, gdy jest ISCC.exe) +packaging\mcpb\ manifest rozszerzenia Claude Desktop docs\PLAN.md plan rozwoju build\ dist\ wyniki (nie trzymaj tu niczego własnego) ``` @@ -296,6 +313,7 @@ i publikuje go razem z `.sha256` i opisem z `CHANGELOG.md` (podgląd: `.\release | `SetupFlowTests.cs` | wersje i paczki wydań do aktualizacji, tabela zespołu na wiki (w obie strony, strony poprawiane ręcznie), wykrywanie przepływu z release'ów i gałęzi, mapa zespołu na wiki z wersją, mapa lokalna ponad zespołową | | `PrPipelineTests.cs` | listy i szczegóły PR-ów (głosy, kontrole, tylko wątki ludzi), akcje na PR (głos, komentarz na linii ze wzmianką, tworzenie), błędy nieudanego buildu i linie logu, uruchamianie pipeline'ów po nazwie, zapis środowisk, odgadywanie przepływu | | `TraceTests.cs` | diff linii (Myers, bloki unified), X++ per metoda ze źródłami danych formularza, nazwy obiektów ze ścieżek AOT, `ado_trace` w TFVC i Gicie (wdrożone etapy TAK / JESZCZE NIE, etykiety środowisk, scalone gałęzie, usunięte buildy, kryteria zmienione po rozpoczęciu prac), listy i diffy plików w `ado_changes` | +| `TestCaseTests.cs` | przypadki testowe z markdownu i XML kroków, tworzenie i dodawanie do zestawu, stan testów elementów z wynikami, szablony zespołu przy tworzeniu, reguły procesu w `ado_fields` | | `KnowledgeTests.cs` | wyszukiwanie po work itemach, wiki i kodzie (brak jednej części nie psuje reszty), pliki po nazwie z metodami X++, historia pliku z pull requestami i work itemami, cofanie (wartości wracają, pola zmienione przez innych zostają, komentarze usuwane), higiena backlogu, notatki wydania, przepisy (zapis, podgląd, uruchomienie) | | `WikiTests.cs` | drzewo stron, strona z wersją, zastąpienie tylko z aktualną wersją (412 nigdy nie nadpisuje), dopisywanie, tworzenie na istniejącej stronie, fragmenty wyszukiwania | | `WorkItemTests.cs` | odpowiedzi o work itemach, pola wymagane i dozwolone wartości przed zapisem, przejścia stanów, podgląd i potwierdzanie zmian wsadowych, wzmianki, linki, zapytania, HTML ⇄ markdown, PAT nigdy do innych hostów | diff --git a/SECURITY.md b/SECURITY.md index e82b1be..f6893da 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,6 +33,11 @@ organization and a `Demo` project) is enough. - **Never in files or command lines:** the PAT is typed with hidden input (or piped through standard input with `--pat-stdin`). It is not accepted as a command-line argument, so it does not end up in shell history or process listings. It is not written to `adolens.json`, to the Claude configuration, to the metadata or usage files, or to logs. + The installer EXE checks the PAT by running `adolens login --pat-env` with the PAT in an environment variable of that + one process, cleared right after. +- **Claude Desktop extension (`.mcpb`):** there the PAT is a setting of the extension marked as sensitive; Claude + Desktop keeps it in the operating system's secure storage and hands it to the server as `AZURE_DEVOPS_PAT`. A PAT in + Credential Manager still wins. Prefer the installer when Claude Code is used too. - **Other servers:** `adolens status` reports other MCP entries in the Claude configuration that keep a PAT in plain text (for example an older Azure DevOps server). It never changes them; remove the PAT there yourself. - **Network:** the server talks only to `https://dev.azure.com/` (and the `vsrm` / `vssps` hosts of the diff --git a/pack.ps1 b/pack.ps1 index 1fb46c7..9a5471f 100644 --- a/pack.ps1 +++ b/pack.ps1 @@ -1,10 +1,14 @@ <# .SYNOPSIS - Builds the ADO Lens distribution package (ZIP) for installing on another machine. + Builds the ADO Lens distribution packages: the ZIP (install.ps1), the installer EXE and the Claude Desktop extension. .DESCRIPTION Publishes a self-contained single EXE (nothing to install on the target machine, not even .NET), compiled ahead with ReadyToRun so the server starts fast every time Claude opens a session. + Outputs in OutDir, each with a .sha256 file: + ado-lens-X.Y.Z.zip program, scripts and skills (get.ps1 / install.ps1, updates) + ado-lens-setup-X.Y.Z.exe installer with a wizard (Inno Setup; skipped when ISCC is not found, unless -Installer) + ado-lens-X.Y.Z.mcpb Claude Desktop extension (double-click in Claude Desktop) .EXAMPLE .\pack.ps1 @@ -12,7 +16,9 @@ [CmdletBinding()] param( [string] $OutDir = (Join-Path $PSScriptRoot 'dist'), - [switch] $NoZip + [switch] $NoZip, + # Fail when Inno Setup (ISCC.exe) is not found instead of skipping the installer (CI). + [switch] $Installer ) $ErrorActionPreference = 'Stop' @@ -46,16 +52,23 @@ 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 -if (-not $NoZip) { - $version = ([xml](Get-Content $proj -Raw)).Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 - $zip = Join-Path $OutDir "ado-lens-$version.zip" - if (Test-Path $zip) { Remove-Item $zip -Force } - # Entries with '/' as the ZIP format requires (Compress-Archive in Windows PowerShell 5.1 writes '\'). +function Write-Checksum([string] $file, [string] $label) { + $hash = (Get-FileHash $file -Algorithm SHA256).Hash.ToLowerInvariant() + Set-Content -Path "$file.sha256" -Value "$hash $(Split-Path $file -Leaf)" -Encoding ascii + Write-Host "${label}: $file ($([math]::Round((Get-Item $file).Length / 1MB, 1)) MB, sha256 $hash)" -ForegroundColor Cyan +} + +# Entries with '/' as the ZIP format requires (Compress-Archive in Windows PowerShell 5.1 writes '\'). +# $withFolder: entries start with the folder's own name (a skill ZIP holds "/SKILL.md"). +function New-Zip([string] $folder, [string] $zip, [bool] $withFolder) { Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem + if (Test-Path $zip) { Remove-Item $zip -Force } + # The long form of the path: entry names are cut from the files' full (long) paths. + $folder = (Get-Item -LiteralPath $folder).FullName.TrimEnd('\') + $root = if ($withFolder) { Split-Path $folder -Parent } else { $folder } $archive = [IO.Compression.ZipFile]::Open($zip, [IO.Compression.ZipArchiveMode]::Create) try { - $root = Split-Path $stage -Parent - foreach ($file in Get-ChildItem $stage -Recurse -File) { + foreach ($file in Get-ChildItem $folder -Recurse -File) { $entry = $file.FullName.Substring($root.Length + 1).Replace('\', '/') [void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile($archive, $file.FullName, $entry, [IO.Compression.CompressionLevel]::Optimal) } @@ -63,9 +76,46 @@ if (-not $NoZip) { finally { $archive.Dispose() } - $hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() - Set-Content -Path "$zip.sha256" -Value "$hash $(Split-Path $zip -Leaf)" -Encoding ascii - Write-Host "zip: $zip ($([math]::Round((Get-Item $zip).Length / 1MB, 1)) MB, sha256 $hash)" -ForegroundColor Cyan } -Write-Host "`nOn the target machine: unzip and run install.ps1" -ForegroundColor Green +if (-not $NoZip) { + $version = ([xml](Get-Content $proj -Raw)).Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 + $zip = Join-Path $OutDir "ado-lens-$version.zip" + New-Zip $stage $zip $true + Write-Checksum $zip 'zip' + + # Claude Desktop extension: the manifest (version filled in) and the program. + $mcpbStage = Join-Path $OutDir 'mcpb' + if (Test-Path $mcpbStage) { Remove-Item $mcpbStage -Recurse -Force } + New-Item -ItemType Directory -Force -Path $mcpbStage | Out-Null + $manifest = (Get-Content (Join-Path $PSScriptRoot 'packaging\mcpb\manifest.json') -Raw -Encoding UTF8).Replace('{{VERSION}}', $version) + [IO.File]::WriteAllText((Join-Path $mcpbStage 'manifest.json'), $manifest, (New-Object Text.UTF8Encoding $false)) + Copy-Item (Join-Path $stage 'bin') $mcpbStage -Recurse + Copy-Item (Join-Path $stage 'LICENSE') $mcpbStage + $mcpb = Join-Path $OutDir "ado-lens-$version.mcpb" + New-Zip $mcpbStage $mcpb $false + Write-Checksum $mcpb 'mcpb' + + # Skills as ZIPs for Claude Desktop (Settings -> Capabilities -> Skills), shipped by the installer. + $desktopSkills = Join-Path $OutDir 'desktop-skills' + if (Test-Path $desktopSkills) { Remove-Item $desktopSkills -Recurse -Force } + New-Item -ItemType Directory -Force -Path $desktopSkills | Out-Null + foreach ($skill in Get-ChildItem (Join-Path $stage 'skills') -Directory) { + New-Zip $skill.FullName (Join-Path $desktopSkills "$($skill.Name).zip") $true + } + + # Installer with a wizard (Inno Setup 6). + $iscc = @((Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source, + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe", + "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe") | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1 + if ($iscc) { + Write-Host 'building the installer (Inno Setup)...' -ForegroundColor Cyan + & $iscc /Q "/DVersion=$version" "/DSource=$stage" "/DDesktopSkills=$desktopSkills" "/DOutDir=$OutDir" (Join-Path $PSScriptRoot 'packaging\inno\ado-lens.iss') + if ($LASTEXITCODE -ne 0) { throw 'Inno Setup failed' } + Write-Checksum (Join-Path $OutDir "ado-lens-setup-$version.exe") 'installer' + } + elseif ($Installer) { throw 'Inno Setup 6 (ISCC.exe) not found' } + else { Write-Host 'installer skipped: Inno Setup 6 (ISCC.exe) not found' -ForegroundColor Yellow } +} + +Write-Host "`nOn the target machine: run the installer EXE, or unzip and run install.ps1" -ForegroundColor Green diff --git a/packaging/inno/ado-lens.iss b/packaging/inno/ado-lens.iss new file mode 100644 index 0000000..3b0cb7a --- /dev/null +++ b/packaging/inno/ado-lens.iss @@ -0,0 +1,295 @@ +; ADO Lens installer: per user, no administrator rights, a wizard with windows. +; Built by pack.ps1: ISCC /DVersion=X.Y.Z /DSource= /DDesktopSkills= ado-lens.iss +; The wizard calls adolens.exe for the real work (login, mode, register), so the same steps stay scriptable. + +#ifndef Version + #define Version "0.0.0" +#endif +#ifndef Source + #define Source "..\..\dist\ado-lens" +#endif +#ifndef DesktopSkills + #define DesktopSkills "..\..\dist\desktop-skills" +#endif +#ifndef OutDir + #define OutDir "..\..\dist" +#endif + +[Setup] +AppId={{6F1C2B9E-4D7A-4E21-9B8C-3A5D0E7F1A42} +AppName=ADO Lens +AppVersion={#Version} +AppVerName=ADO Lens {#Version} +AppPublisher=WillowDev +AppPublisherURL=https://github.com/willowdev-code/ado-lens +AppSupportURL=https://github.com/willowdev-code/ado-lens/issues +DefaultDirName={localappdata}\Programs\ADO Lens +DisableDirPage=yes +DisableProgramGroupPage=yes +DisableReadyPage=yes +PrivilegesRequired=lowest +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +OutputDir={#OutDir} +OutputBaseFilename=ado-lens-setup-{#Version} +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +UninstallDisplayName=ADO Lens +UninstallDisplayIcon={app}\bin\adolens.exe +LicenseFile={#Source}\LICENSE +ShowLanguageDialog=auto +CloseApplications=no +VersionInfoVersion={#Version} + +[Languages] +Name: "en"; MessagesFile: "compiler:Default.isl" +Name: "pl"; MessagesFile: "compiler:Languages\Polish.isl" + +[Files] +Source: "{#Source}\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion +Source: "{#DesktopSkills}\*.zip"; DestDir: "{app}\skills-for-claude-desktop"; Flags: ignoreversion skipifsourcedoesntexist +; Claude Code reads skills from %USERPROFILE%\.claude\skills (only when Claude Code is there); removed on uninstall. +Source: "{#Source}\skills\*"; DestDir: "{%USERPROFILE}\.claude\skills"; Flags: recursesubdirs ignoreversion; Check: HasClaudeCode + +[UninstallRun] +Filename: "{app}\bin\adolens.exe"; Parameters: "unregister"; Flags: runhidden waituntilterminated; RunOnceId: "unregister" + +[Run] +Filename: "{app}\skills-for-claude-desktop"; Description: "{cm:OpenSkills}"; Flags: postinstall shellexec skipifsilent unchecked +Filename: "https://github.com/willowdev-code/ado-lens#readme"; Description: "{cm:OpenReadme}"; Flags: postinstall shellexec skipifsilent unchecked + +[CustomMessages] +en.ConnTitle=Azure DevOps +en.ConnSub=Where your work items are +en.ConnText=Paste a link to your Azure DevOps project from the browser (for example https://dev.azure.com/contoso/Sales), or the organization name. +en.ConnOrg=Organization or project link: +en.ConnProject=Project (optional when the link has it): +en.PatTitle=Personal access token +en.PatSub=ADO Lens signs in with a personal access token (PAT) +en.PatText=Create one in Azure DevOps: your picture (top right) > Personal access tokens > New Token. Scopes: Work Items (Read and Write), Code (Read), Wiki (Read and Write), Build (Read), Release (Read), Test Management (Read and Write), Project and Team (Read), Identity (Read). The token is kept in Windows Credential Manager, never in a file. +en.PatLabel=Token: +en.PatOpen=Open the token page +en.ModeTitle=Who uses ADO Lens +en.ModeSub=This only chooses which tools Claude sees; it can be changed later +en.ModePo=Product Owner / analyst: work items, sprints, board, wiki, checking work, tests +en.ModeDev=Developer: work items, wiki, code, pull requests, pipelines +en.ModeFull=Everything +en.Checking=Checking the connection... +en.NeedOrg=Give the organization or a project link. +en.NeedPat=Paste the token. +en.NeedProject=The organization has several projects - type the one you work in into the Project field (back one page):%n%n%1 +en.LoginFailed=The connection did not work:%n%n%1%n%nCheck the link and the token, then try again. +en.QuitDesktop=Claude Desktop is running and would overwrite its configuration.%n%nQuit it (tray icon next to the clock > Quit), then click OK. Cancel skips Claude Desktop for now (run "adolens register" later). +en.Registered=ADO Lens is ready. Open Claude and ask, for example: "show my current sprint". +en.OpenSkills=Open the skills for Claude Desktop (upload them in Settings > Capabilities > Skills) +en.OpenReadme=Read how to use ADO Lens +pl.ConnTitle=Azure DevOps +pl.ConnSub=Gdzie są twoje work itemy +pl.ConnText=Wklej link do projektu Azure DevOps z przeglądarki (np. https://dev.azure.com/contoso/Sprzedaz) albo nazwę organizacji. +pl.ConnOrg=Organizacja lub link do projektu: +pl.ConnProject=Projekt (niepotrzebny, gdy jest w linku): +pl.PatTitle=Token dostępu +pl.PatSub=ADO Lens loguje się osobistym tokenem dostępu (PAT) +pl.PatText=Utwórz go w Azure DevOps: twoje zdjęcie (prawy górny róg) > Personal access tokens > New Token. Zakresy: Work Items (Read and Write), Code (Read), Wiki (Read and Write), Build (Read), Release (Read), Test Management (Read and Write), Project and Team (Read), Identity (Read). Token trafia do Menedżera poświadczeń Windows, nigdy do pliku. +pl.PatLabel=Token: +pl.PatOpen=Otwórz stronę tokenów +pl.ModeTitle=Kto używa ADO Lens +pl.ModeSub=To tylko wybór narzędzi widocznych dla Claude'a; można zmienić później +pl.ModePo=Product Owner / analityk: work itemy, sprinty, tablica, wiki, sprawdzanie wykonania, testy +pl.ModeDev=Programista: work itemy, wiki, kod, pull requesty, pipeline'y +pl.ModeFull=Wszystko +pl.Checking=Sprawdzanie połączenia... +pl.NeedOrg=Podaj organizację albo link do projektu. +pl.NeedPat=Wklej token. +pl.NeedProject=Organizacja ma kilka projektów - wpisz swój w polu Projekt (strona wstecz):%n%n%1 +pl.LoginFailed=Połączenie nie działa:%n%n%1%n%nSprawdź link i token, potem spróbuj ponownie. +pl.QuitDesktop=Claude Desktop jest uruchomiony i nadpisałby swoją konfigurację.%n%nZamknij go (ikona przy zegarze > Quit), potem kliknij OK. Anuluj pomija na razie Claude Desktop (później: "adolens register"). +pl.Registered=ADO Lens jest gotowy. Otwórz Claude'a i zapytaj np.: "pokaż mój bieżący sprint". +pl.OpenSkills=Otwórz skille dla Claude Desktop (wgraj je w Ustawienia > Capabilities > Skills) +pl.OpenReadme=Przeczytaj, jak używać ADO Lens + +[Code] +var + ConnPage: TInputQueryWizardPage; + PatPage: TInputQueryWizardPage; + ModePage: TInputOptionWizardPage; + HasConnection: Boolean; + +function SetEnvironmentVariable(lpName: String; lpValue: String): Boolean; + external 'SetEnvironmentVariableW@kernel32.dll stdcall'; + +function HasClaudeCode: Boolean; +begin + Result := DirExists(ExpandConstant('{%USERPROFILE}\.claude')); +end; + +function SettingsFile: String; +begin + Result := ExpandConstant('{localappdata}\ado-lens\adolens.json'); +end; + +{ An installation that already has a connection (an update) skips the questions. } +function ExistingConnection: Boolean; +var + Text: AnsiString; +begin + Result := LoadStringFromFile(SettingsFile, Text) and (Pos('"organization"', Lowercase(String(Text))) > 0); +end; + +{ Runs adolens.exe with its output in a file; returns the exit code and the output. } +function RunAdoLens(const Exe, Params: String; var Output: String): Integer; +var + OutFile: String; + Text: AnsiString; + Code: Integer; +begin + OutFile := ExpandConstant('{tmp}\adolens-out.txt'); + DeleteFile(OutFile); + if not Exec(ExpandConstant('{cmd}'), '/C ""' + Exe + '" ' + Params + ' > "' + OutFile + '" 2>&1"', '', SW_HIDE, ewWaitUntilTerminated, Code) then + Code := -1; + Output := ''; + if LoadStringFromFile(OutFile, Text) then Output := Trim(String(Text)); + Result := Code; +end; + +procedure OpenTokenPage(Sender: TObject); +var + Org: String; + Code: Integer; +begin + Org := Trim(ConnPage.Values[0]); + if Pos('dev.azure.com/', Lowercase(Org)) > 0 then + begin + Delete(Org, 1, Pos('dev.azure.com/', Lowercase(Org)) + Length('dev.azure.com/') - 1); + if Pos('/', Org) > 0 then Org := Copy(Org, 1, Pos('/', Org) - 1); + end; + if Org = '' then + ShellExec('open', 'https://dev.azure.com', '', '', SW_SHOWNORMAL, ewNoWait, Code) + else + ShellExec('open', 'https://dev.azure.com/' + Org + '/_usersSettings/tokens', '', '', SW_SHOWNORMAL, ewNoWait, Code); +end; + +procedure InitializeWizard; +var + Button: TNewButton; +begin + HasConnection := ExistingConnection; + + ConnPage := CreateInputQueryPage(wpLicense, CustomMessage('ConnTitle'), CustomMessage('ConnSub'), CustomMessage('ConnText')); + ConnPage.Add(CustomMessage('ConnOrg'), False); + ConnPage.Add(CustomMessage('ConnProject'), False); + + PatPage := CreateInputQueryPage(ConnPage.ID, CustomMessage('PatTitle'), CustomMessage('PatSub'), CustomMessage('PatText')); + PatPage.Add(CustomMessage('PatLabel'), True); + Button := TNewButton.Create(PatPage); + Button.Parent := PatPage.Surface; + Button.Caption := CustomMessage('PatOpen'); + Button.Width := ScaleX(200); + Button.Height := ScaleY(25); + Button.Top := PatPage.Edits[0].Top + PatPage.Edits[0].Height + ScaleY(12); + Button.OnClick := @OpenTokenPage; + + ModePage := CreateInputOptionPage(PatPage.ID, CustomMessage('ModeTitle'), CustomMessage('ModeSub'), '', True, False); + ModePage.Add(CustomMessage('ModePo')); + ModePage.Add(CustomMessage('ModeDev')); + ModePage.Add(CustomMessage('ModeFull')); + ModePage.SelectedValueIndex := 0; +end; + +function ShouldSkipPage(PageID: Integer): Boolean; +begin + Result := HasConnection and ((PageID = ConnPage.ID) or (PageID = PatPage.ID) or (PageID = ModePage.ID)); +end; + +{ The token is checked before anything is installed: adolens.exe runs from the temporary folder, signs in and keeps + the token in Windows Credential Manager. It goes over an environment variable, never on a command line. } +function NextButtonClick(CurPageID: Integer): Boolean; +var + Exe, Params, Output: String; + Code: Integer; +begin + Result := True; + if CurPageID = ConnPage.ID then + begin + if Trim(ConnPage.Values[0]) = '' then + begin + MsgBox(CustomMessage('NeedOrg'), mbError, MB_OK); + Result := False; + end; + end + else if CurPageID = PatPage.ID then + begin + if Trim(PatPage.Values[0]) = '' then + begin + MsgBox(CustomMessage('NeedPat'), mbError, MB_OK); + Result := False; + Exit; + end; + ExtractTemporaryFile('adolens.exe'); + Exe := ExpandConstant('{tmp}\adolens.exe'); + SetEnvironmentVariable('ADOLENS_SETUP_PAT', Trim(PatPage.Values[0])); + Params := 'login --org "' + Trim(ConnPage.Values[0]) + '" --pat-env ADOLENS_SETUP_PAT --default'; + if Trim(ConnPage.Values[1]) <> '' then Params := Params + ' --project "' + Trim(ConnPage.Values[1]) + '"'; + try + Code := RunAdoLens(Exe, Params, Output); + if Code = 4 then + begin + MsgBox(FmtMessage(CustomMessage('NeedProject'), [Output]), mbInformation, MB_OK); + Result := False; + end + else if Code <> 0 then + begin + MsgBox(FmtMessage(CustomMessage('LoginFailed'), [Output]), mbError, MB_OK); + Result := False; + end; + finally + SetEnvironmentVariable('ADOLENS_SETUP_PAT', ''); + end; + end; +end; + +{ A running adolens.exe (an open Claude session) cannot be overwritten but can be renamed; the server removes *.old + files when it starts. } +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + Exe: String; +begin + Result := ''; + Exe := ExpandConstant('{app}\bin\adolens.exe'); + if FileExists(Exe) then + RenameFile(Exe, Exe + '.' + GetDateTimeString('yyyymmddhhnnss', #0, #0) + '.old'); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + Exe, Output, Mode: String; + Code: Integer; +begin + if CurStep <> ssPostInstall then Exit; + Exe := ExpandConstant('{app}\bin\adolens.exe'); + if not HasConnection then + begin + case ModePage.SelectedValueIndex of + 1: Mode := 'dev'; + 2: Mode := 'full'; + else + Mode := 'po'; + end; + RunAdoLens(Exe, 'config --mode ' + Mode, Output); + end; + { 7: Claude Desktop is running and would write its old configuration back. } + Code := RunAdoLens(Exe, 'register', Output); + while (Code = 7) and not WizardSilent do + begin + if MsgBox(CustomMessage('QuitDesktop'), mbInformation, MB_OKCANCEL) <> IDOK then Break; + Code := RunAdoLens(Exe, 'register', Output); + end; + if (Code = 7) or ((Code <> 0) and not WizardSilent) then RunAdoLens(Exe, 'register --code', Output); +end; + +procedure CurPageChanged(CurPageID: Integer); +begin + if CurPageID = wpFinished then + WizardForm.FinishedLabel.Caption := WizardForm.FinishedLabel.Caption + #13#10#13#10 + CustomMessage('Registered'); +end; diff --git a/packaging/mcpb/manifest.json b/packaging/mcpb/manifest.json new file mode 100644 index 0000000..5c53897 --- /dev/null +++ b/packaging/mcpb/manifest.json @@ -0,0 +1,66 @@ +{ + "manifest_version": "0.2", + "name": "ado-lens", + "display_name": "ADO Lens for Azure DevOps", + "version": "{{VERSION}}", + "description": "Work items, sprints, boards, wiki, Git and TFVC of Azure DevOps for Claude - compact answers, few tokens.", + "long_description": "ADO Lens answers what a Product Owner or developer asks about Azure DevOps: work items with their acceptance criteria, sprints and boards, the wiki, whether a work item was done as asked and where it is deployed (DEV / UAT / PROD), how a feature works, pull requests and pipelines. Answers are compact text instead of raw JSON. This extension covers Claude Desktop only; the full installer also sets up Claude Code and the skills.", + "author": { + "name": "WillowDev", + "url": "https://github.com/willowdev-code" + }, + "repository": { + "type": "git", + "url": "https://github.com/willowdev-code/ado-lens" + }, + "homepage": "https://github.com/willowdev-code/ado-lens", + "documentation": "https://github.com/willowdev-code/ado-lens#readme", + "support": "https://github.com/willowdev-code/ado-lens/issues", + "license": "MIT", + "keywords": ["azure devops", "work items", "sprint", "wiki", "tfvc", "git", "pull requests"], + "server": { + "type": "binary", + "entry_point": "bin/adolens.exe", + "mcp_config": { + "command": "${__dirname}/bin/adolens.exe", + "args": ["mcp"], + "env": { + "AZURE_DEVOPS_ORG": "${user_config.organization}", + "AZURE_DEVOPS_PROJECT": "${user_config.project}", + "AZURE_DEVOPS_PAT": "${user_config.pat}", + "ADOLENS_MODE": "${user_config.mode}" + } + } + }, + "compatibility": { + "platforms": ["win32"] + }, + "user_config": { + "organization": { + "type": "string", + "title": "Organization or project link", + "description": "A link to your project from the browser (https://dev.azure.com/contoso/Sales) or the organization name. Leave empty when ADO Lens is already signed in on this computer (adolens login).", + "required": false + }, + "project": { + "type": "string", + "title": "Project", + "description": "Default project; not needed when the link has it.", + "required": false + }, + "pat": { + "type": "string", + "title": "Personal access token", + "description": "Azure DevOps: your picture > Personal access tokens > New Token (Work Items, Code, Wiki, Build, Release, Test Management, Project and Team, Identity). Claude Desktop keeps it in the system's secure storage.", + "sensitive": true, + "required": false + }, + "mode": { + "type": "string", + "title": "Who uses it", + "description": "po (Product Owner, analyst), dev (developer) or full - only which tools Claude sees.", + "default": "po", + "required": false + } + } +} diff --git a/src/AdoLens/Cli.cs b/src/AdoLens/Cli.cs index 6f7e6ee..e371266 100644 --- a/src/AdoLens/Cli.cs +++ b/src/AdoLens/Cli.cs @@ -9,6 +9,8 @@ sealed class Cli(CommandLine cl, string version, bool showTokens = false) public const int RegistrationNeeded = 3; public const int NotConfigured = 4; public const int ConnectionFailed = 5; + /// login without a project when the organization has several (unattended: the installer asks). + public const int ProjectNeeded = 4; public const int ClaudeRunning = 7; public static void PrintUsage() => Console.WriteLine(""" @@ -113,7 +115,12 @@ public async Task LoginAsync() var conn = settings.Connections.TryGetValue(name, out var stored) ? stored : new Connection(); conn.Organization = org; - var pat = cl.Flag("pat-stdin") || Console.IsInputRedirected + // --pat-env NAME: the installer passes the PAT in an environment variable (never on a command line) and nobody + // can answer questions, so nothing is asked. + var patEnv = cl.Opt("pat-env"); + var unattended = patEnv is not null; + var pat = patEnv is not null ? Environment.GetEnvironmentVariable(patEnv) + : cl.Flag("pat-stdin") || Console.IsInputRedirected ? Console.In.ReadLine() : ReadSecret($"PAT for {org} (create it at https://dev.azure.com/{org}/_usersSettings/tokens; input hidden): "); pat = pat?.Trim(); @@ -137,8 +144,15 @@ public async Task LoginAsync() return ConnectionFailed; } - var project = cl.Opt("project") ?? urlProject ?? conn.Project; - if (project is null && projects.Count > 0 && !Console.IsInputRedirected) + var project = NullIfEmpty(cl.Opt("project")) ?? urlProject ?? conn.Project; + if (project is null && projects.Count == 1) project = projects[0]; + else if (project is null && projects.Count > 1 && unattended) + { + Console.Error.WriteLine($"which project? {string.Join(", ", projects.Take(30))}{(projects.Count > 30 ? ", …" : "")}"); + Console.Error.WriteLine("nothing was saved"); + return ProjectNeeded; + } + else if (project is null && projects.Count > 0 && !Console.IsInputRedirected) project = PickProject(projects); if (project is not null && projects.Count > 0 && !projects.Contains(project, StringComparer.OrdinalIgnoreCase)) Console.WriteLine($"note: project '{project}' is not visible with this PAT"); @@ -406,7 +420,7 @@ public async Task CreateAsync() { Title = cl.Opt("title"), Description = cl.Opt("description"), AcceptanceCriteria = cl.Opt("criteria"), AssignedTo = cl.Opt("assign"), Parent = int.TryParse(cl.Opt("parent"), out var p) ? p : null, Iteration = cl.Opt("iteration"), Area = cl.Opt("area"), - Tags = cl.Opt("tags"), State = cl.Opt("state"), Fields = FieldOptions(), + Tags = cl.Opt("tags"), State = cl.Opt("state"), Fields = FieldOptions(), Template = cl.Opt("template"), }; Print(await WorkQueries.CreateAsync(NewLens(), cl.Opt("connection"), cl.Opt("project"), type, r)); return 0; @@ -635,6 +649,22 @@ public async Task FileAsync() return 0; } + public async Task TestsAsync() + { + var ids = cl.Positional.Count > 0 && cl.Positional.All(p => WorkQueries.ParseIds(p).Count > 0) ? string.Join(';', cl.Positional) : null; + Print(await TestCases.StatusAsync(NewLens(), cl.Opt("connection"), ids, ids is null ? cl.Positional.FirstOrDefault() : null, cl.Opt("team"), cl.Opt("detail"))); + return 0; + } + + /// test-create <id> --file cases.md (or the markdown from standard input). + public async Task TestCreateAsync() + { + if (Int(cl.Positional.FirstOrDefault()) is not { } id) { Console.Error.WriteLine("usage: adolens test-create --file [--plan

--suite ]"); return 2; } + var cases = cl.Opt("file") is { } file ? await File.ReadAllTextAsync(file) : await Console.In.ReadToEndAsync(); + Print(await TestCases.CreateAsync(NewLens(), cl.Opt("connection"), id, cases, cl.Opt("plan"), cl.Opt("suite"))); + return 0; + } + public async Task HealthAsync() { Print(await BoardQueries.HealthAsync(NewLens(), cl.Opt("connection"), cl.Opt("project"), cl.Opt("team"))); diff --git a/src/AdoLens/CommandLine.cs b/src/AdoLens/CommandLine.cs index fe5af9e..1212b13 100644 --- a/src/AdoLens/CommandLine.cs +++ b/src/AdoLens/CommandLine.cs @@ -14,7 +14,9 @@ public sealed partial class CommandLine ["mcp"] = [], ["upgrade"] = ["install", "pre"], ["setup"] = ["mode="], - ["login"] = ["connection=", "org=", "project=", "team=", "pat-stdin", "default"], + ["login"] = ["connection=", "org=", "project=", "team=", "pat-stdin", "pat-env=", "default"], + ["tests"] = ["connection=", "team=", "detail="], + ["test-create"] = ["connection=", "file=", "plan=", "suite="], ["logout"] = ["connection="], ["connections"] = [], ["trace"] = ["connection=", "detail="], @@ -24,7 +26,7 @@ public sealed partial class CommandLine ["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="], + ["create"] = ["connection=", "project=", "type=", "title=", "description=", "criteria=", "assign=", "parent=", "iteration=", "area=", "tags=", "state=", "field=", "template="], ["update"] = ["connection=", "ids=", "wiql=", "state=", "reason=", "title=", "description=", "criteria=", "assign=", "iteration=", "area=", "tags=", "field=", "comment=", "confirm="], ["comment"] = ["connection="], ["sprint"] = ["connection=", "project=", "team=", "detail="], diff --git a/src/AdoLens/McpTools.cs b/src/AdoLens/McpTools.cs index 36c4cb6..e9421b5 100644 --- a/src/AdoLens/McpTools.cs +++ b/src/AdoLens/McpTools.cs @@ -25,6 +25,9 @@ public static class McpTools steps, where. Task: one sentence. Comment: decision or question first. - "How does X work": ado_search, then read the sources; say what comes from code and what from documentation. """, + [Toolsets.Test] = """ + - Test Cases from acceptance criteria: one case per criterion, short steps with an expected result each; show them before creating. + """, [Toolsets.Recipes] = """ - A change the user repeats (close a sprint, new customer setup): offer to save it with ado_recipe. """, @@ -400,14 +403,15 @@ public static Task Create(Lens lens, [Description("Tags separated by ';'.")] string? tags = null, string? state = null, [Description("Other fields by name or reference name.")] Dictionary? fields = null, + [Description("A team template (ado_fields type=… lists them) for the values not given.")] string? template = null, string? project = null, string? connection = null, CancellationToken ct = default) - => McpTools.Run("ado_create", new { type, title, parent, iteration, area, tags, state, fields, project, connection }, + => McpTools.Run("ado_create", new { type, title, parent, iteration, area, tags, state, fields, template, project, connection }, () => WorkQueries.CreateAsync(lens, connection, project, type, new WriteRequest { Title = title, Description = description, AcceptanceCriteria = acceptanceCriteria, AssignedTo = assignedTo, - Parent = parent, Iteration = iteration, Area = area, Tags = tags, State = state, Fields = fields, + Parent = parent, Iteration = iteration, Area = area, Tags = tags, State = state, Fields = fields, Template = template, }, ct)); [McpServerTool(Name = "ado_update")] @@ -469,6 +473,30 @@ public static Task Undo(Lens lens, string? confirm = null, bool list = f () => list ? Task.FromResult(Audit.List(lens, connection, 10)) : Audit.UndoAsync(lens, connection, confirm, ct)); } +[McpServerToolType, Toolset(Toolsets.Test)] +public static class TestTools +{ + [McpServerTool(Name = "ado_tests", ReadOnly = true, Idempotent = true)] + [Description("Tests of work items (ids) or of a sprint's items: their Test Cases with the latest outcome per plan and suite; items without tests.")] + public static Task Tests(Lens lens, + [Description("Ids: 123;456; without ids: the sprint.")] string? ids = null, + [Description("current (default), previous, next or a name.")] string? sprint = null, + string? team = null, + [Description("full: every case with its steps.")] string? detail = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_tests", new { ids, sprint, team, detail, connection }, () => TestCases.StatusAsync(lens, connection, ids, sprint, team, detail, ct)); + + [McpServerTool(Name = "ado_test_create")] + [Description("Creates Test Cases for a work item, linked as tested-by: markdown with '## ' and steps '1. action => expected'; plan and suite (id or name) add them to a suite.")] + public static Task TestCreate(Lens lens, int id, string cases, + string? plan = null, + string? suite = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_test_create", new { id, plan, suite, connection }, () => TestCases.CreateAsync(lens, connection, id, cases, plan, suite, ct)); +} + [McpServerToolType, Toolset(Toolsets.Recipes)] public static class RecipeTools { diff --git a/src/AdoLens/Program.cs b/src/AdoLens/Program.cs index 7eba701..74c94f6 100644 --- a/src/AdoLens/Program.cs +++ b/src/AdoLens/Program.cs @@ -92,6 +92,10 @@ return await cli.SearchAsync(); case "file": return await cli.FileAsync(); + case "tests": + return await cli.TestsAsync(); + case "test-create": + return await cli.TestCreateAsync(); case "health": return await cli.HealthAsync(); case "release-notes": @@ -118,6 +122,12 @@ async Task RunServerAsync() { var lens = Lens.Default(version); + // The Claude Desktop extension (.mcpb) chooses the mode in its settings. + if (Environment.GetEnvironmentVariable("ADOLENS_MODE") is { Length: > 0 } mode) + { + try { lens.Settings.Mode = Settings.NormalizeMode(mode); } + catch (ArgumentException) { } + } Usage.Enabled &= lens.Settings.UsageLog; Updater.RemoveLeftovers(); Updater.CheckInBackground(); diff --git a/src/AdoLens/Queries.cs b/src/AdoLens/Queries.cs index c4d7859..876627b 100644 --- a/src/AdoLens/Queries.cs +++ b/src/AdoLens/Queries.cs @@ -143,7 +143,7 @@ public static async Task FieldsAsync(Lens lens, string? connection, stri answer = Formatting.Field(snap, field, refreshing); else if (type is not null) answer = snap.FindType(type) is { } t - ? Formatting.Type(snap, t, all, refreshing) + ? (Formatting.Type(snap, t, all, refreshing) + "\n" + await Templates.SectionAsync(session, snap, t, ct)).TrimEnd() : $"no work item type '{type}' in {snap.Project.Name}; types: " + string.Join(", ", snap.Types.Where(x => !x.Disabled && x.Level != Levels.Hidden).Select(x => x.Name).Order(StringComparer.OrdinalIgnoreCase)); else diff --git a/src/AdoLens/Settings.cs b/src/AdoLens/Settings.cs index b88dff1..8832781 100644 --- a/src/AdoLens/Settings.cs +++ b/src/AdoLens/Settings.cs @@ -156,7 +156,8 @@ public static Config Resolve(Settings settings, string? connection, Func 1) diff --git a/src/AdoLens/Templates.cs b/src/AdoLens/Templates.cs new file mode 100644 index 0000000..2885ccc --- /dev/null +++ b/src/AdoLens/Templates.cs @@ -0,0 +1,83 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace AdoLens; + +///

The team's work item templates (default values) and the process rules of a type. +public static partial class Templates +{ + static string Scope(string project, string team) => $"{AdoClient.Scope(project)}{Uri.EscapeDataString(team)}/"; + + /// Templates of the team for a type: id and name. + public static async Task> ListAsync(Session session, string project, string type, CancellationToken ct) + { + var team = await session.Work.TeamAsync(project, ct); + try + { + var list = await session.Client.GetListAsync($"{Scope(project, team)}_apis/wit/templates?workitemtypename={Uri.EscapeDataString(type)}", ct); + return list.Select(t => (Json.Str(t, "id") ?? "", Json.Str(t, "name") ?? "")).Where(t => t.Item1.Length > 0).ToList(); + } + catch (AdoApiException) { return []; } + } + + /// The field values of a template of the team, by its name (or id). + public static async Task> FieldsAsync(Session session, string project, string type, string template, CancellationToken ct) + { + var list = await ListAsync(session, project, type, ct); + var hit = list.FirstOrDefault(t => t.Name.Equals(template.Trim(), StringComparison.OrdinalIgnoreCase) || t.Id.Equals(template.Trim(), StringComparison.OrdinalIgnoreCase)); + if (hit.Id is null) + throw new InvalidOperationException($"no template '{template}' for {type}; templates: {(list.Count == 0 ? "none" : string.Join(", ", list.Select(t => t.Name)))}"); + var team = await session.Work.TeamAsync(project, ct); + var t = await session.Client.GetAsync($"{Scope(project, team)}_apis/wit/templates/{hit.Id}", ct); + var fields = Json.Obj(t, "fields"); + return fields.ValueKind == JsonValueKind.Object + ? fields.EnumerateObject().ToDictionary(p => p.Name, p => Json.Scalar(p.Value) ?? "", StringComparer.OrdinalIgnoreCase) + : []; + } + + [GeneratedRegex("(?<=[a-z])(?=[A-Z])")] + private static partial Regex CamelBreak(); + + static string Words(string? camel) => camel is null ? "" : CamelBreak().Replace(camel, " ").ToLowerInvariant(); + + /// Custom rules of the type in an inherited process, one line each ("when State = Done: make required Effort"). + public static async Task> RulesAsync(Session session, Snapshot s, TypeInfo type, CancellationToken ct) + { + try + { + var props = await session.Client.GetListAsync($"_apis/projects/{s.Project.Id}/properties", ct); + var processId = props.Where(p => Json.Str(p, "name") == "System.ProcessTemplateType").Select(p => Json.Str(p, "value")).FirstOrDefault(); + if (processId is null) return []; + var rules = await session.Client.GetListAsync($"_apis/work/processes/{processId}/workItemTypes/{Uri.EscapeDataString(type.Ref)}/rules", ct); + string Name(string? reference) => + reference is null ? "" : s.Fields.Values.FirstOrDefault(f => f.Ref.Equals(reference, StringComparison.OrdinalIgnoreCase))?.Name ?? reference; + return rules.Where(r => !Json.Bool(r, "isDisabled") && Json.Str(r, "customizationType") != "system").Select(r => + { + static string Part(params string?[] words) => string.Join(' ', words.Where(w => !string.IsNullOrWhiteSpace(w))); + var when = string.Join(" and ", Json.Arr(r, "conditions").Select(c => + Part(Words(Json.Str(c, "conditionType")), Name(Json.Str(c, "field")), Json.Str(c, "value") is { Length: > 0 } v ? $"= {v}" : null))); + var then = string.Join(", ", Json.Arr(r, "actions").Select(a => + Part(Words(Json.Str(a, "actionType")), Name(Json.Str(a, "targetField")), Json.Str(a, "value") is { Length: > 0 } v ? $"= {Formatting.Collapse(v, 40)}" : null))); + return $"{(when.Length > 0 ? when : "always")}: {then}"; + }).ToList(); + } + catch (AdoApiException) { return []; } + } + + /// Templates and rules of a type for the ado_fields answer; empty when there are none. + public static async Task SectionAsync(Session session, Snapshot s, TypeInfo type, CancellationToken ct) + { + var templates = ListAsync(session, s.Project.Name, type.Name, ct); + var rules = await RulesAsync(session, s, type, ct); + var sb = new StringBuilder(); + if ((await templates) is { Count: > 0 } list) sb.AppendLine($"team templates: {string.Join(", ", list.Select(t => t.Name))} (ado_create template=)"); + if (rules.Count > 0) + { + sb.AppendLine($"rules ({rules.Count}):"); + foreach (var r in rules.Take(20)) sb.AppendLine("- " + r); + if (rules.Count > 20) sb.AppendLine($"- … {rules.Count - 20} more"); + } + return sb.ToString().TrimEnd(); + } +} diff --git a/src/AdoLens/TestCases.cs b/src/AdoLens/TestCases.cs new file mode 100644 index 0000000..c81c69e --- /dev/null +++ b/src/AdoLens/TestCases.cs @@ -0,0 +1,239 @@ +using System.Globalization; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace AdoLens; + +/// Acceptance tests: Test Cases written from criteria, and how the tests of an item or a sprint stand. +public static partial class TestCases +{ + public const string StepsRef = "Microsoft.VSTS.TCM.Steps"; + const string TestedBy = "Microsoft.VSTS.Common.TestedBy-Forward"; + const string Tests = "Microsoft.VSTS.Common.TestedBy-Reverse"; + + public sealed record Case(string Title, List<(string Action, string Expected)> Steps); + + [GeneratedRegex(@"^\s*#{1,3}\s+(?.+?)\s*$")] + private static partial Regex Heading(); + + [GeneratedRegex(@"^\s*(?:\d+[.)]|[-*])\s+(?<step>.+?)\s*$")] + private static partial Regex StepLine(); + + [GeneratedRegex(@"\s*(?:=>|->|→|\|)\s*")] + private static partial Regex Arrow(); + + /// <summary> + /// "## Title" starts a case; "1. action => expected" (also "->", "→" or "|") is a step; a step without an expected + /// result is fine. + /// </summary> + public static List<Case> Parse(string markdown) + { + var cases = new List<Case>(); + Case? current = null; + foreach (var line in markdown.Replace("\r", "").Split('\n')) + { + if (Heading().Match(line) is { Success: true } h) cases.Add(current = new Case(h.Groups["title"].Value, [])); + else if (StepLine().Match(line) is { Success: true } s) + { + if (current is null) throw new ArgumentException("start each test case with a heading: '## <title>', then numbered steps '1. action => expected'"); + var parts = Arrow().Split(s.Groups["step"].Value, 2); + current.Steps.Add((parts[0].Trim(), parts.Length > 1 ? parts[1].Trim() : "")); + } + } + if (cases.Count == 0) throw new ArgumentException("no test cases: write '## <title>' and numbered steps '1. action => expected'"); + if (cases.FirstOrDefault(c => c.Steps.Count == 0) is { } empty) throw new ArgumentException($"test case '{empty.Title}' has no steps"); + return cases; + } + + /// <summary>The Steps field of a Test Case: XML whose step texts are HTML.</summary> + public static string StepsXml(IReadOnlyList<(string Action, string Expected)> steps) + { + static XElement Text(string s) => new("parameterizedString", new XAttribute("isformatted", "true"), + string.IsNullOrEmpty(s) ? "<DIV><P></P></DIV>" : $"<DIV><P>{WebUtility.HtmlEncode(s)}</P></DIV>"); + var root = new XElement("steps", new XAttribute("id", "0"), new XAttribute("last", (steps.Count + 1).ToString(CultureInfo.InvariantCulture))); + for (var i = 0; i < steps.Count; i++) + root.Add(new XElement("step", new XAttribute("id", (i + 2).ToString(CultureInfo.InvariantCulture)), + new XAttribute("type", string.IsNullOrEmpty(steps[i].Expected) ? "ActionStep" : "ValidateStep"), + Text(steps[i].Action), Text(steps[i].Expected), new XElement("description"))); + return root.ToString(SaveOptions.DisableFormatting); + } + + /// <summary>The steps of a Test Case as plain text.</summary> + public static List<(string Action, string Expected)> ReadSteps(string? xml) + { + if (string.IsNullOrWhiteSpace(xml)) return []; + try + { + return XDocument.Parse(xml).Descendants("step").Select(s => + { + var texts = s.Elements("parameterizedString").Select(p => Formatting.Collapse(p.Value, 300)).ToList(); + return (texts.ElementAtOrDefault(0) ?? "", texts.ElementAtOrDefault(1) ?? ""); + }).ToList(); + } + catch (System.Xml.XmlException) { return []; } + } + + /// <summary> + /// Creates Test Cases for a work item from markdown, linked as "tested by", in the item's area and iteration; + /// optionally added to a test suite (plan and suite by id or name). + /// </summary> + public static async Task<string> CreateAsync(Lens lens, string? connection, int id, string cases, string? plan, string? suite, CancellationToken ct = default) + { + var parsed = Parse(cases); + var session = lens.For(connection); + var work = session.Work; + var items = await work.GetAsync([id], relations: false, ct: ct); + if (items.Count == 0) return $"no work item #{id} visible with this PAT"; + var item = items[0]; + var project = WorkItems.Field(item, "System.TeamProject")!; + var s = (await session.Meta.GetAsync(project, ct)).Snapshot; + var type = s.Types.FirstOrDefault(t => !t.Disabled && s.FieldsOf(t.Name).Any(f => f.Ref == StepsRef) && t.Name.Contains("Test Case", StringComparison.OrdinalIgnoreCase)) + ?? s.Types.FirstOrDefault(t => !t.Disabled && s.FieldsOf(t.Name).Any(f => f.Ref == StepsRef)) + ?? throw new InvalidOperationException($"{project} has no Test Case type (Test Plans may be off for this project)"); + + (int Plan, int Suite)? target = plan is null && suite is null ? null : await SuiteAsync(session.Client, project, plan, suite, ct); + var batch = Audit.NewBatch(); + var created = new List<string>(); + foreach (var c in parsed) + { + var ops = new List<object> + { + new { op = "add", path = "/fields/System.Title", value = c.Title }, + new { op = "add", path = "/fields/" + StepsRef, value = StepsXml(c.Steps) }, + new { op = "add", path = "/relations/-", value = new { rel = Tests, url = $"{work.WebBase}_apis/wit/workItems/{id}" } }, + }; + foreach (var f in new[] { "System.AreaPath", "System.IterationPath" }) + if (WorkItems.Field(item, f) is { } v) ops.Add(new { op = "add", path = "/fields/" + f, value = v }); + var tc = await work.CreateAsync(project, type.Name, ops, ct); + var tcId = WorkItems.Id(tc); + Audit.Write(lens, session.Config.Connection, [new AuditEntry { Batch = batch, Tool = "ado_test_create", Kind = "create", Id = tcId, Project = project, Title = c.Title }]); + if (target is { } t) + await session.Client.PostAsync($"{AdoClient.Scope(project)}_apis/testplan/Plans/{t.Plan}/Suites/{t.Suite}/TestCase", + new[] { new { workItem = new { id = tcId } } }, ct); + created.Add($"#{tcId} {c.Title} ({c.Steps.Count} steps)"); + } + return $"created {created.Count} test case(s) for {WorkItemFormat.Header(item)}{(target is { } x ? $", added to suite {x.Suite} of plan {x.Plan}" : "")}:\n" + + string.Join('\n', created.Select(l => "- " + l)); + } + + static async Task<(int Plan, int Suite)> SuiteAsync(AdoClient client, string project, string? plan, string? suite, CancellationToken ct) + { + if (plan is null || suite is null) throw new ArgumentException("give both plan and suite (id or name), or neither"); + var scope = AdoClient.Scope(project); + int planId; + if (!int.TryParse(plan, out planId)) + { + var plans = await client.GetListAsync($"{scope}_apis/testplan/plans?filterActivePlans=true", ct); + var hit = plans.FirstOrDefault(p => string.Equals(Json.Str(p, "name"), plan.Trim(), StringComparison.OrdinalIgnoreCase)); + planId = hit.ValueKind == JsonValueKind.Object && hit.TryGetProperty("id", out var pid) ? pid.GetInt32() + : throw new InvalidOperationException($"no active test plan '{plan}'; plans: {string.Join(", ", plans.Select(p => Json.Str(p, "name")))}"); + } + if (int.TryParse(suite, out var suiteId)) return (planId, suiteId); + var suites = await client.GetListAsync($"{scope}_apis/testplan/Plans/{planId}/suites", ct); + var s = suites.FirstOrDefault(x => string.Equals(Json.Str(x, "name"), suite.Trim(), StringComparison.OrdinalIgnoreCase)); + return s.ValueKind == JsonValueKind.Object && s.TryGetProperty("id", out var sid) ? (planId, sid.GetInt32()) + : throw new InvalidOperationException($"no suite '{suite}' in plan {planId}; suites: {string.Join(", ", suites.Select(x => Json.Str(x, "name")))}"); + } + + sealed record Point(int TestCase, string Outcome, string? Plan, string? Suite, string? Configuration, DateTime? When, string? Tester); + + static async Task<List<Point>> PointsAsync(AdoClient client, string project, IReadOnlyCollection<int> testCases, CancellationToken ct) + { + if (testCases.Count == 0) return []; + JsonElement root; + try + { + root = await client.PostAsync($"{AdoClient.Scope(project)}_apis/test/points", new { pointsFilter = new { testcaseIds = testCases } }, ct); + } + catch (AdoApiException) { return []; } + var list = root.TryGetProperty("points", out var p) && p.ValueKind == JsonValueKind.Array ? p.EnumerateArray() : Json.Arr(root, "value"); + return list.Select(x => + { + // The id is a string in this API ("123"), a number in others. + var tc = Json.Obj(x, "testCase").TryGetProperty("id", out var idEl) + ? idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var i) ? i : 0 + : 0; + var outcome = Json.Str(x, "outcome") is { Length: > 0 } o && o != "Unspecified" ? o : "Not run"; + var when = DateTime.TryParse(Json.Str(Json.Obj(x, "lastResultDetails"), "dateCompleted") ?? Json.Str(x, "lastUpdatedDate"), + CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var d) ? d : (DateTime?)null; + return new Point(tc, outcome, Json.Str(Json.Obj(x, "testPlan"), "name"), Json.Str(Json.Obj(x, "suite"), "name"), + Json.Str(Json.Obj(x, "configuration"), "name"), when, + Json.Str(Json.Obj(Json.Obj(x, "lastResultDetails"), "runBy"), "displayName") ?? Json.Str(Json.Obj(x, "assignedTo"), "displayName")); + }).Where(x => x.TestCase > 0).ToList(); + } + + static IEnumerable<int> Linked(JsonElement wi, string rel) => + Json.Arr(wi, "relations").Where(r => Json.Str(r, "rel") == rel).Select(r => Audit.TargetOf(Json.Str(r, "url")) ?? 0).Where(i => i > 0); + + /// <summary> + /// The tests of work items (ids) or of the items of a sprint: the Test Cases that test each one and their latest + /// outcome in every plan and suite. + /// </summary> + public static async Task<string> StatusAsync(Lens lens, string? connection, string? ids, string? sprint, string? team, string? detail, CancellationToken ct = default) + { + var session = lens.For(connection); + var work = session.Work; + List<int> wanted; + string title; + if (WorkQueries.ParseIds(ids) is { Count: > 0 } list) { wanted = list; title = ""; } + else + { + var project = session.Project(null); + var boards = new Boards(session); + var t = await boards.TeamAsync(project, team, ct); + var it = Boards.Pick(await boards.IterationsAsync(project, t, ct), sprint, DateTime.Now.Date); + var s = (await session.Meta.GetAsync(project, ct)).Snapshot; + var (sprintIds, _) = await boards.SprintItemsAsync(project, t, it, ct); + var heads = await work.GetAsync(sprintIds, relations: false, ["System.Id", "System.WorkItemType"], ct); + wanted = heads.Where(w => s.FindType(WorkItems.Field(w, "System.WorkItemType") ?? "")?.Level is Levels.Requirement or Levels.Bug).Select(WorkItems.Id).ToList(); + title = $"tests of {it.Name} ({t}): "; + if (wanted.Count == 0) return title + "no requirements or bugs in this sprint"; + } + + var items = await work.GetAsync(wanted, relations: true, ct: ct); + if (items.Count == 0) return "no work items visible with this PAT"; + var caseIds = items.SelectMany(w => Linked(w, TestedBy)).Concat(items.Where(w => WorkItems.Field(w, StepsRef) is not null || Json.Arr(w, "relations").Any(r => Json.Str(r, "rel") == Tests)) + .Select(WorkItems.Id)).Distinct().ToList(); + var cases = caseIds.Count == 0 ? new Dictionary<int, JsonElement>() : (await work.GetAsync(caseIds, relations: false, ct: ct)).ToDictionary(WorkItems.Id); + var byProject = cases.Values.GroupBy(c => WorkItems.Field(c, "System.TeamProject") ?? ""); + var points = new List<Point>(); + foreach (var g in byProject) points.AddRange(await PointsAsync(session.Client, g.Key, g.Select(WorkItems.Id).ToList(), ct)); + var pointsOf = points.GroupBy(p => p.TestCase).ToDictionary(g => g.Key, g => g.ToList()); + var full = detail == "full"; + + string Outcome(int tc) => pointsOf.TryGetValue(tc, out var ps) && ps.Count > 0 + ? string.Join("; ", ps.OrderByDescending(p => p.When).Select(p => $"{p.Outcome}{(p.When is { } w ? $" {w:yyyy-MM-dd}" : "")}" + + $"{(p.Tester is null ? "" : $" {p.Tester}")} ({p.Plan} / {p.Suite}{(p.Configuration is null ? "" : $", {p.Configuration}")})")) + : "not in any test plan"; + string Bucket(int tc) => pointsOf.TryGetValue(tc, out var ps) && ps.Count > 0 + ? ps.OrderByDescending(p => p.When).First().Outcome switch { "Passed" => "passed", "Failed" => "failed", "Blocked" => "blocked", _ => "not run" } + : "not planned"; + + var sb = new StringBuilder(title); + var untested = new List<string>(); + var summary = new Dictionary<string, int>(); + foreach (var w in items) + { + var isCase = cases.ContainsKey(WorkItems.Id(w)) && !Linked(w, TestedBy).Any(); + var tcs = isCase ? [WorkItems.Id(w)] : Linked(w, TestedBy).Where(cases.ContainsKey).ToList(); + if (tcs.Count == 0) { untested.Add($"#{WorkItems.Id(w)} {Formatting.Collapse(WorkItems.Field(w, "System.Title") ?? "", 50)}"); continue; } + var buckets = tcs.GroupBy(Bucket).ToDictionary(g => g.Key, g => g.Count()); + foreach (var (k, v) in buckets) summary[k] = summary.GetValueOrDefault(k) + v; + sb.AppendLine($"{WorkItemFormat.Header(w)}: {tcs.Count} test case(s) - {string.Join(", ", buckets.Select(b => $"{b.Key} {b.Value}"))}"); + if (title.Length > 0 && !full && !buckets.Keys.Any(k => k is "failed" or "blocked")) continue; + foreach (var tc in tcs) + { + sb.AppendLine($" - #{tc} {WorkItems.Field(cases[tc], "System.Title")}: {Outcome(tc)}"); + if (full) + foreach (var (step, i) in ReadSteps(WorkItems.Field(cases[tc], StepsRef)).Select((x, i) => (x, i + 1))) + sb.AppendLine($" {i}. {step.Action}{(step.Expected.Length > 0 ? $" => {step.Expected}" : "")}"); + } + } + if (title.Length > 0 && summary.Count > 0) sb.Insert(title.Length, string.Join(", ", summary.Select(x => $"{x.Key} {x.Value}")) + "\n"); + if (untested.Count > 0) sb.AppendLine($"without test cases ({untested.Count}): {string.Join("; ", untested.Take(10))}{(untested.Count > 10 ? "; …" : "")}"); + return sb.ToString().TrimEnd(); + } +} diff --git a/src/AdoLens/WorkItems.cs b/src/AdoLens/WorkItems.cs index 0f70975..e536cea 100644 --- a/src/AdoLens/WorkItems.cs +++ b/src/AdoLens/WorkItems.cs @@ -20,6 +20,8 @@ public sealed record WriteRequest public string? Tags { get; init; } public Dictionary<string, string>? Fields { get; init; } public int? Parent { get; init; } + /// <summary>On create: a work item template of the team whose values fill the fields not given.</summary> + public string? Template { get; init; } public bool IsEmpty => Title is null && Description is null && AcceptanceCriteria is null && State is null && Reason is null && AssignedTo is null && Area is null && Iteration is null && Tags is null && Parent is null diff --git a/src/AdoLens/WorkQueries.cs b/src/AdoLens/WorkQueries.cs index 8ba72e4..94b40a2 100644 --- a/src/AdoLens/WorkQueries.cs +++ b/src/AdoLens/WorkQueries.cs @@ -85,6 +85,44 @@ public static async Task<string> CreateAsync(Lens lens, string? connection, stri if (t.Disabled) throw new InvalidOperationException($"{t.Name} is disabled in this process"); var notes = new List<string>(); + if (r.Template is { } template) + { + // The template's values go in as fields given by name; what the call sets itself wins. + var values = await Templates.FieldsAsync(session, s.Project.Name, t.Name, template, ct); + var given = new Dictionary<string, string>(r.Fields ?? [], StringComparer.OrdinalIgnoreCase); + foreach (var (reference, value) in values) + { + if (string.IsNullOrEmpty(value)) continue; + switch (reference) + { + case WorkItems.TitleRef or "System.State" or "System.Reason": + break; + case WorkItems.DescriptionRef or WorkItems.ReproStepsRef: + r = r with { Description = r.Description ?? Html.ToMarkdown(value) }; + break; + case WorkItems.AcceptanceRef: + r = r with { AcceptanceCriteria = r.AcceptanceCriteria ?? Html.ToMarkdown(value) }; + break; + case "System.Tags": + r = r with { Tags = r.Tags is null ? value : r.Tags.TrimStart().StartsWith('+') || r.Tags.TrimStart().StartsWith('-') ? r.Tags : $"{value}; {r.Tags}" }; + break; + case "System.AssignedTo": + r = r with { AssignedTo = r.AssignedTo ?? value }; + break; + case "System.AreaPath": + r = r with { Area = r.Area ?? value }; + break; + case "System.IterationPath": + r = r with { Iteration = r.Iteration ?? value }; + break; + default: + if (!given.ContainsKey(reference) && s.FieldsOf(t.Name).Any(f => f.Ref.Equals(reference, StringComparison.OrdinalIgnoreCase))) + given[reference] = value; + break; + } + } + r = r with { Fields = given, Template = null }; + } var ops = await session.Work.BuildOpsAsync(s, t, r, null, notes, ct); var created = await session.Work.CreateAsync(s.Project.Name, t.Name, ops, ct); Audit.Write(lens, session.Config.Connection, [new AuditEntry { Batch = Audit.NewBatch(), Tool = "ado_create", Kind = "create", diff --git a/tests/AdoLens.Tests/FakeAdo.cs b/tests/AdoLens.Tests/FakeAdo.cs index e0e6a1f..29370cc 100644 --- a/tests/AdoLens.Tests/FakeAdo.cs +++ b/tests/AdoLens.Tests/FakeAdo.cs @@ -53,6 +53,7 @@ sealed class DemoProject public string Severity = """["1 - Critical","2 - High","3 - Medium","4 - Low"]"""; public bool CustomerRequired; public bool ExtraState; + public bool TestCaseType; public FakeAdo Routes(FakeAdo ado) => ado .On("/contoso/_apis/projects/Demo", """ @@ -86,9 +87,12 @@ public FakeAdo Routes(FakeAdo ado) => ado {"name":"Task","referenceName":"Microsoft.VSTS.WorkItemTypes.Task","isDisabled":false, "states":[{"name":"To Do","category":"Proposed"},{"name":"Done","category":"Completed"}],"transitions":{}}, {"name":"Feature","referenceName":"Microsoft.VSTS.WorkItemTypes.Feature","isDisabled":false,"states":[],"transitions":{}}, - {"name":"Old Thing","referenceName":"Contoso.Old","isDisabled":true,"states":[],"transitions":{}} + {"name":"Old Thing","referenceName":"Contoso.Old","isDisabled":true,"states":[],"transitions":{}}{{{(TestCaseType ? ",{\"name\":\"Test Case\",\"referenceName\":\"Microsoft.VSTS.WorkItemTypes.TestCase\",\"isDisabled\":false,\"states\":[{\"name\":\"Design\",\"category\":\"Proposed\"}],\"transitions\":{}}" : "")}}} ]} """) + .On("/contoso/Demo/_apis/wit/workitemtypes/Test Case/fields", """ + {"value":[{"referenceName":"System.Title","name":"Title","alwaysRequired":true},{"referenceName":"Microsoft.VSTS.TCM.Steps","name":"Steps"}]} + """) .On("/contoso/Demo/_apis/wit/workitemtypes/Product Backlog Item/fields", () => $$$""" {"value":[ {"referenceName":"System.Title","name":"Title","alwaysRequired":true}, diff --git a/tests/AdoLens.Tests/SetupTests.cs b/tests/AdoLens.Tests/SetupTests.cs index a55bacc..3abed75 100644 --- a/tests/AdoLens.Tests/SetupTests.cs +++ b/tests/AdoLens.Tests/SetupTests.cs @@ -288,7 +288,8 @@ 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_file", "ado_health", "ado_link", "ado_my_day", - "ado_query", "ado_recipe", "ado_release_notes", "ado_search", "ado_settings", "ado_sprint", "ado_status", "ado_trace", "ado_undo", "ado_update", + "ado_query", "ado_recipe", "ado_release_notes", "ado_search", "ado_settings", "ado_sprint", "ado_status", "ado_test_create", "ado_tests", + "ado_trace", "ado_undo", "ado_update", "ado_upgrade", "ado_wiki", "ado_wiki_write", "ado_work_item"], names); } diff --git a/tests/AdoLens.Tests/TestCaseTests.cs b/tests/AdoLens.Tests/TestCaseTests.cs new file mode 100644 index 0000000..41bc69f --- /dev/null +++ b/tests/AdoLens.Tests/TestCaseTests.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; + +namespace AdoLens.Tests; + +/// <summary>Phase 5: Test Cases from criteria and the state of tests; team templates and process rules.</summary> +public class TestCaseTests : IDisposable +{ + readonly string dir = Path.Combine(Path.GetTempPath(), $"adolens-tests-{Guid.NewGuid():N}"); + readonly DemoProject demo = new() { TestCaseType = true }; + readonly DemoItems items = new(); + readonly List<string> suiteAdds = []; + readonly Lens lens; + + static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + + public TestCaseTests() + { + // #1 is tested by #20 (failed in plan R1) and #21 (never run). + ((JsonArray)items.Items[1]["relations"]!).Add(JsonNode.Parse("""{"rel":"Microsoft.VSTS.Common.TestedBy-Forward","url":"https://dev.azure.com/contoso/_apis/wit/workItems/20"}""")); + ((JsonArray)items.Items[1]["relations"]!).Add(JsonNode.Parse("""{"rel":"Microsoft.VSTS.Common.TestedBy-Forward","url":"https://dev.azure.com/contoso/_apis/wit/workItems/21"}""")); + items.Items[20] = (JsonObject)JsonNode.Parse(""" + {"id":20,"fields":{"System.Id":20,"System.TeamProject":"Demo","System.WorkItemType":"Test Case","System.State":"Ready","System.Title":"Sign in works", + "Microsoft.VSTS.TCM.Steps":"<steps id=\"0\" last=\"3\"><step id=\"2\" type=\"ValidateStep\"><parameterizedString isformatted=\"true\"><DIV><P>Open the portal</P></DIV></parameterizedString><parameterizedString isformatted=\"true\"><DIV><P>Sign-in page</P></DIV></parameterizedString><description/></step></steps>"}, + "relations":[]} + """)!.AsObject(); + items.Items[21] = (JsonObject)JsonNode.Parse(""" + {"id":21,"fields":{"System.Id":21,"System.TeamProject":"Demo","System.WorkItemType":"Test Case","System.State":"Design","System.Title":"Wrong password"},"relations":[]} + """)!.AsObject(); + + var ado = items.Routes(demo.Routes(new FakeAdo())) + .On("/contoso/Demo/_apis/test/points", """ + {"points":[{"id":1,"outcome":"Failed","testCase":{"id":"20"},"testPlan":{"name":"R1"},"suite":{"name":"Login"}, + "lastResultDetails":{"dateCompleted":"2026-10-10T10:00:00Z","runBy":{"displayName":"Anna Nowak"}}}]} + """) + .On("/contoso/Demo/_apis/testplan/plans", """{"value":[{"id":7,"name":"R1"}]}""") + .On("/contoso/Demo/_apis/testplan/Plans/7/suites", """{"value":[{"id":9,"name":"Login"}]}""") + .On("/contoso/Demo/_apis/testplan/Plans/7/Suites/9/TestCase", r => { suiteAdds.Add(r.Content!.ReadAsStringAsync().Result); return Json("""{"value":[]}"""); }) + .On("/contoso/Demo/Demo Team/_apis/wit/templates/t1", """{"id":"t1","name":"Customer bug","fields":{"Custom.Customer":"Beta","System.Tags":"customer"}}""") + .On("/contoso/Demo/Demo Team/_apis/wit/templates", """{"value":[{"id":"t1","name":"Customer bug","workItemTypeName":"Bug"}]}""") + .On("/contoso/_apis/work/processes/proc-1/workItemTypes/Microsoft.VSTS.WorkItemTypes.Bug/rules", """ + {"value":[ + {"name":"severity on done","conditions":[{"conditionType":"whenStateChangedTo","value":"Done"}],"actions":[{"actionType":"makeRequired","targetField":"Microsoft.VSTS.Common.Severity"}],"customizationType":"custom"}, + {"name":"system","conditions":[],"actions":[{"actionType":"makeReadOnly","targetField":"System.Id"}],"customizationType":"system"}]} + """); + var s = new Settings { DefaultConnection = "contoso" }; + s.Connections["contoso"] = new Connection { Organization = "contoso", Project = "Demo" }; + lens = new Lens(s, "0.0.0", dir, _ => null, _ => "pat", _ => ado.Client()); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + + [Fact] + public void Test_cases_are_read_from_markdown_and_steps_round_trip() + { + var cases = TestCases.Parse(""" + ## Sign in with a company account + 1. Open the portal => the sign-in page is shown + 2. Enter a valid account -> the dashboard opens + 3. Sign out + ## Wrong password + - Enter a wrong password | an error is shown + """); + Assert.Equal(["Sign in with a company account", "Wrong password"], cases.Select(c => c.Title)); + Assert.Equal(("Enter a valid account", "the dashboard opens"), cases[0].Steps[1]); + Assert.Equal(("Sign out", ""), cases[0].Steps[2]); + + var xml = TestCases.StepsXml(cases[0].Steps); + Assert.Contains("type=\"ActionStep\"", xml); // the step without an expected result + Assert.Equal(cases[0].Steps, TestCases.ReadSteps(xml)); + Assert.Throws<ArgumentException>(() => TestCases.Parse("1. a step without a case")); + Assert.Throws<ArgumentException>(() => TestCases.Parse("## Empty case")); + } + + [Fact] + public async Task Test_cases_are_created_linked_and_added_to_a_suite() + { + var text = await TestCases.CreateAsync(lens, null, 1, "## Sign in\n1. Open => Page\n2. Sign in => Dashboard", "R1", "Login"); + Assert.Contains("created 1 test case(s) for #1 Product Backlog Item [New] Login page, added to suite 9 of plan 7", text); + var body = items.Created.Single(); + Assert.Contains("Microsoft.VSTS.TCM.Steps", body); + Assert.Contains("TestedBy-Reverse", body); + Assert.Contains("Demo\\\\Sprint 6", body); // the story's iteration + Assert.Contains("\"id\":10", suiteAdds.Single()); + Assert.Contains("ado_test_create", Audit.List(lens, null, 5)); + } + + [Fact] + public async Task Test_status_shows_each_case_with_its_latest_outcome() + { + var text = (await TestCases.StatusAsync(lens, null, "1;4", null, null, "full")).Replace("\r", ""); + Assert.Contains("#1 Product Backlog Item [New] Login page: 2 test case(s) - failed 1, not planned 1", text); + Assert.Contains(" - #20 Sign in works: Failed 2026-10-10 Anna Nowak (R1 / Login)", text); + Assert.Contains(" 1. Open the portal => Sign-in page", text); + Assert.Contains(" - #21 Wrong password: not in any test plan", text); + Assert.Contains("without test cases (1): #4 Error on save", text); + } + + [Fact] + public async Task A_team_template_fills_a_new_item_and_rules_are_listed() + { + await WorkQueries.CreateAsync(lens, null, null, "Bug", new WriteRequest { Title = "Crash on save", Template = "Customer bug" }); + var body = items.Created.Single(); + Assert.Contains("\"/fields/Custom.Customer\",\"value\":\"Beta\"", body); + Assert.Contains("customer", body); + + var fields = await Queries.FieldsAsync(lens, null, null, "Bug", null, null, false, false); + Assert.Contains("team templates: Customer bug", fields); + Assert.Contains("- when state changed to = Done: make required Severity", fields); + Assert.DoesNotContain("make read only", fields); + } +} From 1c35d67375b3ccd5ed53997e26fed820b19df091 Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:47:13 +0200 Subject: [PATCH 2/3] Updates through the installer EXE when it installed the program --- CHANGELOG.md | 2 ++ release.ps1 | 2 +- src/AdoLens/Updater.cs | 45 +++++++++++++++++++++------ tests/AdoLens.Tests/SetupFlowTests.cs | 4 ++- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abbbcd7..53b2ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ Tests, templates, and two more ways to install. - `adolens login --pat-env <variable>` for unattended setups; without a project and with several in the organization it lists them and stops (exit code 4) instead of asking. - Releases publish the installer and the extension next to the ZIP, each with its SHA-256; CI builds all three. +- `ado_upgrade` of an installation made with the installer EXE updates with the new installer EXE (SHA-256 checked, + no questions), so the entry in Settings → Apps shows the new version. ## 0.4.0 — 2026-10-15 diff --git a/release.ps1 b/release.ps1 index cb46b5a..e254751 100644 --- a/release.ps1 +++ b/release.ps1 @@ -260,4 +260,4 @@ if (-not $Pre) { } Write-Host "`nReleased ADO Lens ${fullVersion}: $($release.url)" -ForegroundColor Green -Write-Host "Install: download the zip from the release, unzip, run install.ps1" +Write-Host "Install: irm https://raw.githubusercontent.com/willowdev-code/ado-lens/main/get.ps1 | iex (or the setup EXE / ZIP from the release)" diff --git a/src/AdoLens/Updater.cs b/src/AdoLens/Updater.cs index 8dbe5ec..982c85f 100644 --- a/src/AdoLens/Updater.cs +++ b/src/AdoLens/Updater.cs @@ -52,7 +52,11 @@ public static class Updater static readonly TimeSpan CheckEvery = TimeSpan.FromHours(24); public sealed record ReleaseInfo(ReleaseVersion Version, string Tag, string PageUrl, string? AssetName, string? AssetUrl, string? Sha256, - string Notes, bool Prerelease = false); + string Notes, bool Prerelease = false) + { + /// <summary>The installer EXE of the release (Inno Setup), when it has one.</summary> + public (string Name, string Url, string? Sha256)? Setup { get; init; } + } /// <summary>The running version, test-release suffix included; "+commit" build metadata dropped.</summary> public static ReleaseVersion Current { get; } = @@ -93,11 +97,18 @@ public sealed record ReleaseInfo(ReleaseVersion Version, string Tag, string Page .FirstOrDefault(a => a["name"]?.GetValue<string>() is { } name && name.StartsWith("ado-lens-", StringComparison.OrdinalIgnoreCase) && name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); - var digest = asset?["digest"]?.GetValue<string>(); + static string? Digest(JsonObject? a) => + a?["digest"]?.GetValue<string>() is { } d && d.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? d[7..].ToLowerInvariant() : null; + var setup = (n["assets"] as JsonArray)?.OfType<JsonObject>() + .FirstOrDefault(a => a["name"]?.GetValue<string>() is { } name + && name.StartsWith("ado-lens-setup-", StringComparison.OrdinalIgnoreCase) + && name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); return new ReleaseInfo(version, tag!, n["html_url"]?.GetValue<string>() ?? $"https://github.com/{Repository}/releases", - asset?["name"]?.GetValue<string>(), asset?["browser_download_url"]?.GetValue<string>(), - digest != null && digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? digest[7..].ToLowerInvariant() : null, - n["body"]?.GetValue<string>() ?? "", n["prerelease"]?.GetValue<bool>() ?? version.IsPrerelease); + asset?["name"]?.GetValue<string>(), asset?["browser_download_url"]?.GetValue<string>(), Digest(asset), + n["body"]?.GetValue<string>() ?? "", n["prerelease"]?.GetValue<bool>() ?? version.IsPrerelease) + { + Setup = setup?["name"]?.GetValue<string>() is { } sn && setup["browser_download_url"]?.GetValue<string>() is { } su ? (sn, su, Digest(setup)) : null, + }; } static string StateFile => Path.Combine(Settings.DataDir, "update.json"); @@ -211,12 +222,29 @@ public static string InstallDir() return ($"{head}{(notes.Count > 0 ? "\nchanges:\n" + string.Join('\n', notes.Select(l => " " + l.Trim())) : "")}" + $"\ninstall it with: adolens upgrade --install{(pre ? " --pre" : "")} (settings and PATs are kept)", false); } - if (latest.AssetUrl == null || latest.AssetName == null) return ($"release {latest.Tag} has no ado-lens-*.zip package - download it from {latest.PageUrl}", true); - if (latest.Sha256 == null) return ($"GitHub publishes no SHA-256 digest for {latest.AssetName} - not installing an unverified package", true); - var work = Path.Combine(Path.GetTempPath(), $"ado-lens-update-{latest.Version}"); if (Directory.Exists(work)) Directory.Delete(work, recursive: true); Directory.CreateDirectory(work); + var target = InstallDir(); + + // Installed with the setup EXE (its uninstaller is in the folder): update with the new setup EXE, which keeps + // the entry in Settings -> Apps current. It skips its questions when a connection exists. + if (File.Exists(Path.Combine(target, "unins000.exe")) && latest.Setup is { Sha256: not null } setup) + { + var exe = Path.Combine(work, setup.Name); + await using (var src = await http.GetStreamAsync(setup.Url, ct)) + await using (var dst = File.Create(exe)) + await src.CopyToAsync(dst, ct); + var hash = Sha256Of(exe); + if (!hash.Equals(setup.Sha256, StringComparison.OrdinalIgnoreCase)) + return ($"SHA-256 mismatch for {setup.Name}: expected {setup.Sha256}, got {hash} - not installing", true); + Process.Start(new ProcessStartInfo { FileName = exe, Arguments = "/SILENT /SUPPRESSMSGBOXES /NORESTART", UseShellExecute = true }); + return ($"{head}\nSHA-256 verified ({hash[..12]}…). The installer runs in its own window; " + + "Claude may stay open - new sessions use the new version, Claude Desktop needs a restart for running ones.", false); + } + + if (latest.AssetUrl == null || latest.AssetName == null) return ($"release {latest.Tag} has no ado-lens-*.zip package - download it from {latest.PageUrl}", true); + if (latest.Sha256 == null) return ($"GitHub publishes no SHA-256 digest for {latest.AssetName} - not installing an unverified package", true); var zip = Path.Combine(work, latest.AssetName); await using (var src = await http.GetStreamAsync(latest.AssetUrl, ct)) await using (var dst = File.Create(zip)) @@ -232,7 +260,6 @@ public static string InstallDir() if (script == null) return ("the package has no install.ps1", true); // The installer replaces this program's own files, so it runs in its own window after this process ends. - var target = InstallDir(); Process.Start(new ProcessStartInfo { FileName = "powershell.exe", diff --git a/tests/AdoLens.Tests/SetupFlowTests.cs b/tests/AdoLens.Tests/SetupFlowTests.cs index 05b478c..bf1132d 100644 --- a/tests/AdoLens.Tests/SetupFlowTests.cs +++ b/tests/AdoLens.Tests/SetupFlowTests.cs @@ -27,11 +27,13 @@ public void The_package_and_its_published_digest_are_found() var r = Updater.ParseRelease(""" {"tag_name":"v0.3.0","html_url":"https://github.com/x/releases/v0.3.0","body":"- new","prerelease":false, "assets":[{"name":"ado-lens-0.3.0.zip.sha256","browser_download_url":"https://x/s"}, - {"name":"ado-lens-0.3.0.zip","browser_download_url":"https://x/z","digest":"sha256:ABC123"}]} + {"name":"ado-lens-0.3.0.zip","browser_download_url":"https://x/z","digest":"sha256:ABC123"}, + {"name":"ado-lens-setup-0.3.0.exe","browser_download_url":"https://x/e","digest":"sha256:DEF456"}]} """)!; Assert.Equal("0.3.0", r.Version.ToString()); Assert.Equal("ado-lens-0.3.0.zip", r.AssetName); Assert.Equal("abc123", r.Sha256); + Assert.Equal(("ado-lens-setup-0.3.0.exe", "https://x/e", "def456"), r.Setup); } } From 47cfebc6745b8908b4f03aa55525e2d9a787a22e Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:47:32 +0200 Subject: [PATCH 3/3] ADO Lens 0.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests, templates, and two more ways to install. - `ado_tests` (tool set `test`): the Test Cases of work items or of a sprint's requirements and bugs, with the latest outcome per plan, suite and configuration; items without tests; steps with `detail=full`. - `ado_test_create`: Test Cases from markdown (`## title`, `1. action => expected`), linked as tested-by, in the item's area and iteration, optionally added to a suite; recorded for `ado_undo`. - `ado_create template=<name>`: a team's work item template fills the values not given; `ado_fields type=…` lists the team's templates and the custom rules of the type in an inherited process. - Installer with a wizard (`ado-lens-setup-X.Y.Z.exe`, Inno Setup, per user, no administrator rights): Azure DevOps link and PAT checked before installing (the PAT goes to Credential Manager over an environment variable, never on a command line), mode, skills for Claude Code and ZIPs for Claude Desktop, registration (asks to quit Claude Desktop when needed); updates skip the questions; uninstall from Settings → Apps. - Claude Desktop extension (`ado-lens-X.Y.Z.mcpb`): project link, PAT (kept by Claude Desktop in secure storage) and mode in the extension settings. - `adolens login --pat-env <variable>` for unattended setups; without a project and with several in the organization it lists them and stops (exit code 4) instead of asking. - Releases publish the installer and the extension next to the ZIP, each with its SHA-256; CI builds all three. - `ado_upgrade` of an installation made with the installer EXE updates with the new installer EXE (SHA-256 checked, no questions), so the entry in Settings → Apps shows the new version. --- CHANGELOG.md | 2 +- src/AdoLens/AdoLens.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b2ded..2910f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.5.0 — unreleased +## 0.5.0 — 2026-10-15 Tests, templates, and two more ways to install. diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index d7c0b5f..f579b8d 100644 --- a/src/AdoLens/AdoLens.csproj +++ b/src/AdoLens/AdoLens.csproj @@ -21,7 +21,7 @@ <AssemblyTitle>ADO Lens for Azure DevOps</AssemblyTitle> <Description>Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP</Description> <Copyright>Copyright © 2026 WillowDev</Copyright> - <Version>0.4.0</Version> + <Version>0.5.0</Version> </PropertyGroup> <ItemGroup>