diff --git a/CHANGELOG.md b/CHANGELOG.md index 684036b..850f40b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 0.3.0 — 2026-10-15 + +Easy to install and to keep current, for people who are not developers. + +- One-line install: `irm …/get.ps1 | iex` downloads the latest release, checks its SHA-256 and runs the installer + (no browser download, so no SmartScreen stop for the not yet signed program). +- Installer in six steps with defaults: connection, then who uses it (mode) and where code goes (environments found + from release stages and branches, names suggested, saved for the user or the team), skills, Claude registration. + Questions in Polish on a Polish Windows. `adolens setup` asks the same later. +- Team settings on the project wiki: a page "ADO Lens" with the environment table, read by everyone on the project; + `ado_settings scope=team` writes it with the page version. A local map overrides it. +- `ado_settings` without arguments shows the flow in use or the one detected, so Claude can ask once. +- Updates: the server checks GitHub once a day in the background and mentions a newer version once per session; + `ado_upgrade` / `adolens upgrade --install` downloads it, checks SHA-256 and runs its installer; `*.old` leftovers of + updates are removed at start. +- Skills `po-story` (writing requirements, bugs and tasks) and `po-sprint` (sprint status, review, release notes) next + to `po-verify`; installed into Claude Code and packed as ZIPs for Claude Desktop; removed on uninstall. + ## 0.2.0 — 2026-10-14 Checking work against what was asked, and where it is. diff --git a/README.md b/README.md index b24b7b6..981b739 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ not thousands. Changes between versions: [CHANGELOG.md](CHANGELOG.md). The road Azure DevOps is a trademark of Microsoft. > Status: **0.x — in development.** Connections, Claude registration, the local work item structure, work items, -> sprints, boards, backlogs, the wiki, tracing work to code and deployments, pull requests and pipelines work; the -> installer follows the plan. +> sprints, boards, backlogs, the wiki, tracing work to code and deployments, pull requests, pipelines, the one-line +> install with its setup and updates work. ## What it does differently @@ -31,7 +31,17 @@ Azure DevOps is a trademark of Microsoft. ## Installing -The install package is not part of the repository (`dist\` is excluded by `.gitignore`). There are two ways: +**The easy way — one line.** In a regular PowerShell window (not as administrator) paste: + +```powershell +irm https://raw.githubusercontent.com/willowdev-code/ado-lens/main/get.ps1 | iex +``` + +It downloads the latest release, checks its SHA-256 against the digest GitHub publishes and starts the installer. A file +downloaded this way is not marked as coming from the internet, so Windows SmartScreen does not stop the (not yet +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: **A. From a release package** — nothing needs to be installed on the target machine, not even .NET. @@ -51,10 +61,20 @@ cd C:\Dev\ado-lens powershell -ExecutionPolicy Bypass -File .\dist\ado-lens\install.ps1 ``` -The installer copies the program to `%LOCALAPPDATA%\Programs\ADO Lens` (no administrator rights), asks for the Azure -DevOps address (paste any link from the browser — the organization and project are taken from it), the PAT (hidden -input) and the default project (picked from a list), and registers the `ado-lens` server in Claude Desktop and Claude -Code. When the registration has to change while Claude Desktop runs, it asks you to quit Claude, waits and starts it +The installer, in six steps with a default for every question (Enter accepts it): + +1. checks whether the Claude registration has to change, +2. copies the program to `%LOCALAPPDATA%\Programs\ADO Lens` (no administrator rights), +3. asks for the Azure DevOps address (paste any link from the browser — organization and project are taken from it), + the PAT (hidden input) and the default project (picked from a list), +4. asks who uses it (Product Owner / developer / everything) and where code goes: it finds the release stages and + branches, suggests names (dev → DEV, staging → UAT, master → PROD) and saves them for you or — when you agree — for + the whole team on the project's wiki page "ADO Lens" (`adolens setup` asks the same later), +5. installs the skills: into `%USERPROFILE%\.claude\skills` for Claude Code, and as ZIPs in + `skills-for-claude-desktop` to upload in Claude Desktop (Settings → Capabilities → Skills), +6. registers the `ado-lens` server in Claude Desktop and Claude Code. + +Questions are in Polish on a Polish Windows, in English otherwise. When the registration has to change while Claude Desktop runs, it asks you to quit Claude, waits and starts it again. Running `install.ps1` from a newer package updates the program and keeps settings, PATs and metadata; an `adolens.exe` used by an open session is renamed to `*.old`, so that session keeps working. @@ -66,8 +86,13 @@ Useful parameters: .\install.ps1 -DesktopOnly | -CodeOnly | -NoRegister | -NoRestart ``` -Uninstall: `uninstall.ps1` in the installation folder (removes the Claude registrations of this installation, asks -about the stored PATs and the local data). +**Updating.** The server checks GitHub at most once a day, in the background. When a newer version exists, Claude +mentions it once per session; say "update ADO Lens" and `ado_upgrade` downloads it, checks its SHA-256 and runs its +installer in its own window. From a terminal: `adolens upgrade` shows what is new, `adolens upgrade --install` installs +it (`--pre` includes test releases). Settings, PATs and metadata are kept; Claude may stay open. + +Uninstall: `uninstall.ps1` in the installation folder (removes the Claude registrations and skills of this installation, +asks about the stored PATs and the local data). ### The PAT @@ -109,9 +134,16 @@ adolens config --connection fabrikam --env "Main-Deploy/Stage 1=UAT + SCM" `ado_trace` then lists the stages in that order with their labels ("UAT (sbx03): YES") and, for Git, also checks whether the work is merged into each mapped branch even when no release pipeline deploys it. -Nobody has to set this up in advance: when the map is missing, `ado_trace` ends with a short SETUP note listing the flow -it found (branches and release stages, with a first guess such as dev → DEV, staging → UAT, master → PROD). Claude asks -the user once how they call these environments and saves the answer with `ado_settings` — for good. +**One map for the whole team.** The map can live in Azure DevOps itself: a page "ADO Lens" in the project wiki with a +two-column table (branch or release stage | environment), in flow order. Everyone using ADO Lens on the project reads +it, so Product Owners configure nothing; a local map (`--env`) overrides it for one person. The installer, `adolens +setup` and `ado_settings scope=team` write it (with the page version, so a parallel edit is not lost), and people can +edit the table by hand. + +Nobody has to set this up in advance: when no map exists, `ado_trace` ends with a short SETUP note listing the flow it +found (release stages deployed in the last half year and long-lived branches, with a first guess such as dev → DEV, +staging → UAT, master → PROD). Claude asks the user once how they call these environments and saves the answer with +`ado_settings` — for good, for the user or (after they agree) for the team. Settings live in `%LOCALAPPDATA%\ado-lens\adolens.json` (no secrets) — they can be edited by hand too. @@ -122,7 +154,7 @@ never a permission. | Tool set | Tools | po | dev | full | |---|---|:-:|:-:|:-:| -| `core` | `ado_status`, `ado_settings`, `ado_fields` | ✓ | ✓ | ✓ | +| `core` | `ado_status`, `ado_settings`, `ado_upgrade`, `ado_fields` | ✓ | ✓ | ✓ | | `work` | `ado_work_item`, `ado_query`, `ado_create`, `ado_update`, `ado_comment`, `ado_link` | ✓ | ✓ | ✓ | | `pr` | `ado_pr`, `ado_pr_update` | – | ✓ | ✓ | | `pipelines` | `ado_pipeline`, `ado_pipeline_run` | – | ✓ | ✓ | @@ -139,7 +171,8 @@ What each MCP tool answers, when Claude should reach for it, and the CLI command | Tool | Answers | When | CLI | |---|---|---|---| | `ado_status` | version, connection, signed-in user, PAT source, metadata age | checking the setup | `adolens status` | -| `ado_settings` | the connection's settings; with `environments` saves for good what branches and release stages mean, in flow order (`dev=DEV (sbx01); staging=UAT (sbx03); master=PROD`) | `ado_trace` asks for it once (SETUP), the user answers in words | `adolens config --connection c --env dev=DEV` | +| `ado_settings` | the connection's settings and the environment flow in use (local, or the team's wiki page) or detected; with `environments` saves for good what branches and release stages mean, in flow order (`dev=DEV (sbx01); staging=UAT (sbx03); master=PROD`); `scope=team` writes the wiki page | `ado_trace` asks for it once (SETUP), the user answers in words | `adolens setup` | +| `ado_upgrade` | is there a newer ADO Lens; `install=true` downloads it (SHA-256 checked) and starts its installer | when the user asks to update | `adolens upgrade --install` | | `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"` | @@ -188,7 +221,7 @@ Estimates are characters / 4; Claude's tokenizer differs a little (Polish text r ``` adolens item|query|create|update|comment|link|fields|sprint|board|backlog|myday|wiki|wiki-write|trace|changes|pr|pipeline … -adolens login|logout|connections|config|status|register|unregister|refresh|stats|api|mcp|version +adolens setup|login|logout|connections|config|status|register|unregister|refresh|upgrade|stats|api|mcp|version ``` `adolens help` lists all options. `adolens api ` prints the raw JSON of a GET (for development). @@ -206,7 +239,8 @@ tests\AdoLens.Tests\ xUnit tests; Azure DevOps is simulated (FakeAdo), no build.ps1 compile; -Deploy replaces the installed binaries, -Test runs the tests first 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 +install.ps1 uninstall.ps1 installation; get.ps1 is the one-line download and install +skills\ skills for Claude (po-verify, po-story, po-sprint) docs\PLAN.md road map build\ dist\ output (keep nothing of your own there) ``` @@ -250,6 +284,7 @@ regular expression per line, `!word` for allowed words. Without a list the relea | `SetupTests.cs` | settings, connections and PAT order (Credential Manager first), command line, Claude configuration edits, Credential Manager, HTTP retries and errors, tool definition budget per mode | | `MetadataTests.cs` | fetching the structure (levels, inherited process name, bugs on the requirement backlog), change history, compact `ado_fields` answers | | `BoardTests.cs` | sprint progress, capacity left with days off, attention list, picking sprints, ended sprints, board columns and WIP, moving cards with the state mapping, backlog order and reordering, my day | +| `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 | | `WikiTests.cs` | page tree, page with version, replace only with the current version (412 never overwrites), append, create over an existing page, search snippets | @@ -269,7 +304,9 @@ issues or commits. | pull requests | `PullRequests.cs` | | builds and pipeline runs | `Pipelines.cs` | | changesets, commits, PRs, builds, deployments | `Code.cs`; tracing in `Trace.cs`; diffs and X++ per method in `Diff.cs` | -| skills for Claude | `skills\\SKILL.md` | +| skills for Claude | `skills\\SKILL.md` (installed by `install.ps1`) | +| updates | `Updater.cs`; one-line install `get.ps1` | +| team settings on the wiki, environment flow | `TeamSettings.cs`, `EnvironmentFlow.cs`; interactive setup in `Cli.cs` (`SetupAsync`), texts in `Ui.cs` | | work item structure (types, fields, states) | `MetadataService.cs` (fetch), `MetaStore.cs` (SQLite; raise `SchemaVersion` to refetch), `Metadata.cs` (change detection) | | rich text | `Html.cs` | | people | `Identities.cs` | diff --git a/README.pl.md b/README.pl.md index ca63b9c..84822e2 100644 --- a/README.pl.md +++ b/README.pl.md @@ -13,8 +13,8 @@ a nie tysiące. Zmiany między wersjami: [CHANGELOG.md](CHANGELOG.md). Plan rozw Azure DevOps jest znakiem towarowym Microsoftu. > Stan: **0.x — w budowie.** Działają połączenia, rejestracja w Claude, lokalna struktura work itemów, work itemy, -> sprinty, tablice, backlogi, wiki, śledzenie pracy do kodu i wdrożeń, pull requesty i pipeline'y; instalator — -> według planu. +> sprinty, tablice, backlogi, wiki, śledzenie pracy do kodu i wdrożeń, pull requesty, pipeline'y oraz instalacja +> jedną linijką z kreatorem i aktualizacjami. ## Czym się różni @@ -31,7 +31,17 @@ Azure DevOps jest znakiem towarowym Microsoftu. ## Instalacja -Paczki instalacyjnej nie ma w repozytorium (`dist\` jest pomijany przez `.gitignore`). Są dwie drogi: +**Najprościej — jedna linijka.** W zwykłym oknie PowerShella (nie jako administrator) wklej: + +```powershell +irm https://raw.githubusercontent.com/willowdev-code/ado-lens/main/get.ps1 | iex +``` + +Polecenie pobiera najnowsze wydanie, porównuje jego SHA-256 z sumą publikowaną przez GitHuba i uruchamia instalator. +Plik pobrany w ten sposób nie jest oznaczany jako „z internetu”, więc Windows SmartScreen nie zatrzymuje (jeszcze +niepodpisanego) programu. Na komputerze nie trzeba niczego więcej, nawet .NET. + +Paczki instalacyjnej nie ma w repozytorium (`dist\` jest pomijany przez `.gitignore`). Inne drogi: **A. Z gotowej paczki** — na maszynie docelowej nie trzeba niczego instalować, nawet .NET. @@ -51,9 +61,21 @@ cd C:\Dev\ado-lens powershell -ExecutionPolicy Bypass -File .\dist\ado-lens\install.ps1 ``` -Instalator kopiuje program do `%LOCALAPPDATA%\Programs\ADO Lens` (bez uprawnień administratora), pyta o adres Azure -DevOps (wklej dowolny link z przeglądarki — organizacja i projekt zostaną z niego wyciągnięte), PAT (wpisywany -niewidocznie) i domyślny projekt (wybierany z listy), a potem rejestruje serwer `ado-lens` w Claude Desktop i Claude Code. +Instalator działa w sześciu krokach, każde pytanie ma odpowiedź domyślną (Enter ją przyjmuje): + +1. sprawdza, czy rejestracja w Claude musi się zmienić, +2. kopiuje program do `%LOCALAPPDATA%\Programs\ADO Lens` (bez uprawnień administratora), +3. pyta o adres Azure DevOps (wklej dowolny link z przeglądarki — organizacja i projekt zostaną z niego wyciągnięte), + PAT (wpisywany niewidocznie) i domyślny projekt (wybierany z listy), +4. pyta, kto będzie używał narzędzia (Product Owner / programista / wszystko) i przez jakie środowiska przechodzi kod: + znajduje etapy release'ów i gałęzie, proponuje nazwy (dev → DEV, staging → UAT, master → PROD) i zapisuje je dla + ciebie albo — gdy się zgodzisz — dla całego zespołu na stronie wiki projektu „ADO Lens” (później to samo robi + `adolens setup`), +5. instaluje skille: do `%USERPROFILE%\.claude\skills` dla Claude Code oraz jako paczki ZIP w folderze + `skills-for-claude-desktop` do wgrania w Claude Desktop (Ustawienia → Capabilities → Skills), +6. rejestruje serwer `ado-lens` w Claude Desktop i Claude Code. + +Na polskim Windowsie pytania są po polsku, w innym przypadku po angielsku. Jeśli rejestracja musi się zmienić, a Claude Desktop działa, instalator prosi o zamknięcie Claude'a, czeka i uruchamia go ponownie. Uruchomienie `install.ps1` z nowszej paczki aktualizuje program i zostawia ustawienia, PAT-y i metadane; `adolens.exe` używany przez otwartą sesję dostaje nazwę `*.old`, więc ta sesja działa dalej. @@ -66,8 +88,13 @@ Przydatne parametry: .\install.ps1 -DesktopOnly | -CodeOnly | -NoRegister | -NoRestart ``` -Deinstalacja: `uninstall.ps1` w folderze instalacji (usuwa rejestracje tej instalacji w Claude, pyta o zapisane PAT-y -i dane lokalne). +**Aktualizacja.** Serwer sprawdza GitHuba najwyżej raz dziennie, w tle. Gdy jest nowsza wersja, Claude wspomni o niej raz +w sesji; wystarczy powiedzieć „zaktualizuj ADO Lens”, a `ado_upgrade` pobierze ją, sprawdzi SHA-256 i uruchomi jej +instalator w osobnym oknie. Z terminala: `adolens upgrade` pokazuje, co nowego, `adolens upgrade --install` instaluje +(`--pre` uwzględnia wydania testowe). Ustawienia, PAT-y i metadane zostają; Claude może być otwarty. + +Deinstalacja: `uninstall.ps1` w folderze instalacji (usuwa rejestracje i skille tej instalacji w Claude, pyta o zapisane +PAT-y i dane lokalne). ### PAT @@ -109,9 +136,16 @@ adolens config --connection fabrikam --env "Main-Deploy/Stage 1=UAT + SCM" Wtedy `ado_trace` wypisuje etapy w tej kolejności z ich etykietami („UAT (sbx03): YES”), a dla Gita sprawdza też, czy praca jest scalona do każdej zmapowanej gałęzi, nawet gdy żaden pipeline jej nie wdraża. -Nikt nie musi tego ustawiać zawczasu: gdy mapy brak, `ado_trace` kończy się krótką notą SETUP z wykrytym przepływem -(gałęzie i etapy release'ów, z pierwszym przypuszczeniem typu dev → DEV, staging → UAT, master → PROD). Claude raz pyta -użytkownika, jak nazywa te środowiska, i zapisuje odpowiedź przez `ado_settings` — na stałe. +**Jedna mapa dla całego zespołu.** Mapa może mieszkać w samym Azure DevOps: strona „ADO Lens” w wiki projektu z tabelą +w dwóch kolumnach (gałąź albo etap release'u | środowisko), w kolejności przepływu. Każdy, kto używa ADO Lens w projekcie, +ją czyta, więc Product Ownerzy niczego nie konfigurują; lokalna mapa (`--env`) nadpisuje ją dla jednej osoby. Zapisują +ją instalator, `adolens setup` i `ado_settings scope=team` (z wersją strony, żeby nie zgubić równoległej zmiany), +a tabelę można też poprawić ręcznie. + +Nikt nie musi tego ustawiać zawczasu: gdy mapy nie ma, `ado_trace` kończy się krótką notą SETUP z wykrytym przepływem +(etapy release'ów wdrażane w ostatnim półroczu i stałe gałęzie, z pierwszym przypuszczeniem typu dev → DEV, +staging → UAT, master → PROD). Claude raz pyta użytkownika, jak nazywa te środowiska, i zapisuje odpowiedź przez +`ado_settings` — na stałe, dla niego albo (po zgodzie) dla zespołu. Ustawienia siedzą w `%LOCALAPPDATA%\ado-lens\adolens.json` (bez sekretów) — można je też edytować ręcznie. @@ -122,7 +156,7 @@ Nigdy nie jest uprawnieniem. | Zestaw | Narzędzia | po | dev | full | |---|---|:-:|:-:|:-:| -| `core` | `ado_status`, `ado_settings`, `ado_fields` | ✓ | ✓ | ✓ | +| `core` | `ado_status`, `ado_settings`, `ado_upgrade`, `ado_fields` | ✓ | ✓ | ✓ | | `work` | `ado_work_item`, `ado_query`, `ado_create`, `ado_update`, `ado_comment`, `ado_link` | ✓ | ✓ | ✓ | | `pr` | `ado_pr`, `ado_pr_update` | – | ✓ | ✓ | | `pipelines` | `ado_pipeline`, `ado_pipeline_run` | – | ✓ | ✓ | @@ -139,7 +173,8 @@ Na co odpowiada każde narzędzie MCP, kiedy Claude powinien po nie sięgnąć i | Narzędzie | Odpowiada na | Kiedy | CLI | |---|---|---|---| | `ado_status` | wersja, połączenie, zalogowany użytkownik, źródło PAT, wiek metadanych | sprawdzenie konfiguracji | `adolens status` | -| `ado_settings` | ustawienia połączenia; z `environments` zapisuje na stałe, co znaczą gałęzie i etapy release'ów, w kolejności przepływu (`dev=DEV (sbx01); staging=UAT (sbx03); master=PROD`) | `ado_trace` pyta o to raz (SETUP), użytkownik odpowiada słowami | `adolens config --connection c --env dev=DEV` | +| `ado_settings` | ustawienia połączenia i używany przepływ środowisk (lokalny albo ze strony wiki zespołu) lub wykryty; z `environments` zapisuje na stałe, co znaczą gałęzie i etapy release'ów, w kolejności przepływu (`dev=DEV (sbx01); staging=UAT (sbx03); master=PROD`); `scope=team` zapisuje stronę wiki | `ado_trace` pyta o to raz (SETUP), użytkownik odpowiada słowami | `adolens setup` | +| `ado_upgrade` | czy jest nowsza wersja ADO Lens; `install=true` pobiera ją (z kontrolą SHA-256) i uruchamia jej instalator | gdy użytkownik prosi o aktualizację | `adolens upgrade --install` | | `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"` | @@ -188,7 +223,7 @@ Szacunek to znaki / 4; tokenizer Claude'a liczy trochę inaczej (polski tekst wy ``` adolens item|query|create|update|comment|link|fields|sprint|board|backlog|myday|wiki|wiki-write|trace|changes|pr|pipeline … -adolens login|logout|connections|config|status|register|unregister|refresh|stats|api|mcp|version +adolens setup|login|logout|connections|config|status|register|unregister|refresh|upgrade|stats|api|mcp|version ``` `adolens help` wypisuje wszystkie opcje. `adolens api <ścieżka>` drukuje surowy JSON z GET (do rozwoju). @@ -206,7 +241,8 @@ tests\AdoLens.Tests\ testy xUnit; Azure DevOps jest symulowane (FakeAdo), build.ps1 kompilacja; -Deploy podmienia binaria w instalacji, -Test najpierw uruchamia testy 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 +install.ps1 uninstall.ps1 instalacja; get.ps1 to pobranie i instalacja jedną linijką +skills\ skille dla Claude'a (po-verify, po-story, po-sprint) docs\PLAN.md plan rozwoju build\ dist\ wyniki (nie trzymaj tu niczego własnego) ``` @@ -250,6 +286,7 @@ i publikuje go razem z `.sha256` i opisem z `CHANGELOG.md` (podgląd: `.\release | `SetupTests.cs` | ustawienia, połączenia i kolejność PAT (najpierw Menedżer poświadczeń), linia poleceń, edycja konfiguracji Claude'a, Menedżer poświadczeń, ponawianie i błędy HTTP, budżet definicji narzędzi na tryb, faktyczna rejestracja narzędzi | | `MetadataTests.cs` | pobieranie struktury (poziomy, nazwa procesu dziedziczonego, bugi na backlogu wymagań), historia zmian, zwięzłe odpowiedzi `ado_fields` | | `BoardTests.cs` | postęp sprintu, pozostała pojemność z dniami wolnymi, lista do uwagi, wybór sprintu, zakończone sprinty, kolumny tablicy i WIP, przesuwanie kart z mapowaniem stanów, kolejność backlogu i jej zmiana, mój dzień | +| `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` | | `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 | @@ -269,7 +306,9 @@ organizacji do testów, zgłoszeń ani commitów. | pull requesty | `PullRequests.cs` | | buildy i uruchamianie pipeline'ów | `Pipelines.cs` | | changesety, commity, PR-y, buildy, wdrożenia | `Code.cs`; śledzenie w `Trace.cs`; diffy i X++ per metoda w `Diff.cs` | -| skille dla Claude'a | `skills\\SKILL.md` | +| skille dla Claude'a | `skills\\SKILL.md` (instaluje je `install.ps1`) | +| aktualizacje | `Updater.cs`; instalacja jedną linijką `get.ps1` | +| ustawienia zespołu na wiki, przepływ środowisk | `TeamSettings.cs`, `EnvironmentFlow.cs`; kreator w `Cli.cs` (`SetupAsync`), teksty w `Ui.cs` | | struktura work itemów (typy, pola, stany) | `MetadataService.cs` (pobieranie), `MetaStore.cs` (SQLite; podnieś `SchemaVersion`, żeby pobrać od nowa), `Metadata.cs` (wykrywanie zmian) | | tekst sformatowany | `Html.cs` | | osoby | `Identities.cs` | diff --git a/get.ps1 b/get.ps1 new file mode 100644 index 0000000..9b64ee3 --- /dev/null +++ b/get.ps1 @@ -0,0 +1,42 @@ +<# +.SYNOPSIS + Downloads the latest ADO Lens release, checks it and runs its installer - one line, no browser. + +.DESCRIPTION + Meant to be run straight from GitHub in a regular (non-administrator) PowerShell window: + + irm https://raw.githubusercontent.com/willowdev-code/ado-lens/main/get.ps1 | iex + + It reads the latest release from the GitHub API, downloads ado-lens-X.Y.Z.zip, compares its SHA-256 with the + digest GitHub publishes for it, unpacks it into a temporary folder and runs its install.ps1. Nothing is installed + when the checksum does not match. A file downloaded by PowerShell is not marked as coming from the internet, so + Windows SmartScreen does not stop the (not yet signed) program. +#> +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + +$repo = 'willowdev-code/ado-lens' +Write-Host 'ADO Lens for Azure DevOps - looking up the latest release...' -ForegroundColor Cyan +$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/latest" -Headers @{ 'User-Agent' = 'ado-lens-get' } +$asset = $release.assets | Where-Object { $_.name -like 'ado-lens-*.zip' } | Select-Object -First 1 +if (-not $asset) { throw "release $($release.tag_name) has no ado-lens-*.zip package" } +if (-not $asset.digest -or $asset.digest -notlike 'sha256:*') { throw "GitHub publishes no SHA-256 digest for $($asset.name) - not installing an unverified package" } +$expected = $asset.digest.Substring(7).ToLowerInvariant() + +$work = Join-Path ([IO.Path]::GetTempPath()) ("ado-lens-get-" + [Guid]::NewGuid().ToString('N').Substring(0, 8)) +New-Item -ItemType Directory -Path $work | Out-Null +$zip = Join-Path $work $asset.name +Write-Host "downloading $($asset.name) ($([math]::Round($asset.size / 1MB, 1)) MB)..." +Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $zip -UseBasicParsing -Headers @{ 'User-Agent' = 'ado-lens-get' } + +$actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() +if ($actual -ne $expected) { throw "SHA-256 mismatch for $($asset.name): expected $expected, got $actual - not installing" } +Write-Host "SHA-256 verified: $actual" + +Add-Type -AssemblyName System.IO.Compression.FileSystem +[IO.Compression.ZipFile]::ExtractToDirectory($zip, (Join-Path $work 'package')) +$installer = Get-ChildItem (Join-Path $work 'package') -Recurse -Filter install.ps1 | Select-Object -First 1 +if (-not $installer) { throw 'the package has no install.ps1' } + +& powershell -NoProfile -ExecutionPolicy Bypass -File $installer.FullName diff --git a/install.ps1 b/install.ps1 index 33dbb38..c64f904 100644 --- a/install.ps1 +++ b/install.ps1 @@ -53,7 +53,7 @@ $scope = @() if ($DesktopOnly) { $scope += '--desktop' } if ($CodeOnly) { $scope += '--code' } -Head '1/4 Claude registration' +Head '1/6 Claude registration' $needsRegister = $false if ($NoRegister) { Say 'skipped (-NoRegister)' @@ -72,7 +72,7 @@ if ($needsRegister -and (Get-ClaudeDesktop)) { Say 'Claude is closed' } -Head "2/4 Copying files to $InstallDir" +Head "2/6 Copying files to $InstallDir" $binDst = Join-Path $InstallDir 'bin' New-Item -ItemType Directory -Force -Path $binDst | Out-Null $target = [IO.Path]::GetFullPath($InstallDir).TrimEnd('\') @@ -98,10 +98,11 @@ else { foreach ($f in 'install.ps1', 'uninstall.ps1', 'README.md', 'README.pl.md', 'CHANGELOG.md', 'SECURITY.md', 'LICENSE') { if (Test-Path (Join-Path $src $f)) { Copy-Item (Join-Path $src $f) $InstallDir -Force } } + if (Test-Path (Join-Path $src 'skills')) { Copy-Item (Join-Path $src 'skills') $InstallDir -Recurse -Force } } Say "version: $(& $exe version)" -Head '3/4 Azure DevOps connection' +Head '3/6 Azure DevOps connection' & $exe status --quiet --offline $state = $LASTEXITCODE if ($state -eq 4 -or $Organization) { @@ -113,9 +114,45 @@ if ($state -eq 4 -or $Organization) { else { Say 'already configured' } -if ($Mode) { & $exe config --mode $Mode | Out-Null; Say "mode: $Mode" } -Head '4/4 Registering in Claude' +Head '4/6 Who uses it and where code goes' +# Interactive questions with defaults (Enter accepts); skipped in a non-interactive run. +$canAsk = [Environment]::UserInteractive -and $Host.Name -ne 'Default Host' -and + -not ([Environment]::GetCommandLineArgs() | Where-Object { $_ -like '-NonI*' }) +if ($canAsk) { + $setupArgs = @('setup') + if ($Mode) { $setupArgs += @('--mode', $Mode) } + & $exe @setupArgs +} +elseif ($Mode) { & $exe config --mode $Mode | Out-Null; Say "mode: $Mode" } +else { Say 'skipped (not interactive) - later: "adolens setup", or Claude asks when needed' } + +Head '5/6 Skills for Claude' +# Claude Code reads skills from %USERPROFILE%\.claude\skills; Claude Desktop takes each skill as a ZIP uploaded in +# Settings -> Capabilities -> Skills, so the ZIPs are prepared next to the program. +$skillSrc = Join-Path $InstallDir 'skills' +if (Test-Path $skillSrc) { + $codeSkills = Join-Path $env:USERPROFILE '.claude\skills' + $desktopZips = Join-Path $InstallDir 'skills-for-claude-desktop' + New-Item -ItemType Directory -Force -Path $desktopZips | Out-Null + Add-Type -AssemblyName System.IO.Compression.FileSystem + foreach ($skill in Get-ChildItem $skillSrc -Directory) { + if (Test-Path (Join-Path $env:USERPROFILE '.claude')) { + $dst = Join-Path $codeSkills $skill.Name + New-Item -ItemType Directory -Force -Path $dst | Out-Null + Copy-Item (Join-Path $skill.FullName '*') $dst -Recurse -Force + } + $zip = Join-Path $desktopZips "$($skill.Name).zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + [IO.Compression.ZipFile]::CreateFromDirectory($skill.FullName, $zip, [IO.Compression.CompressionLevel]::Optimal, $true) + } + $names = (Get-ChildItem $skillSrc -Directory | ForEach-Object Name) -join ', ' + if (Test-Path (Join-Path $env:USERPROFILE '.claude')) { Say "Claude Code: $names installed in $codeSkills" } + Say "Claude Desktop: upload the ZIPs from $desktopZips in Settings -> Capabilities -> Skills" +} +else { Say 'no skills in this package' } + +Head '6/6 Registering in Claude' if ($needsRegister) { & $exe register @scope --force if ($LASTEXITCODE -ne 0) { throw 'Registration failed.' } diff --git a/skills/po-sprint/SKILL.md b/skills/po-sprint/SKILL.md new file mode 100644 index 0000000..0c03d4f --- /dev/null +++ b/skills/po-sprint/SKILL.md @@ -0,0 +1,29 @@ +--- +name: po-sprint +description: Summarizes a sprint or a period for a Product Owner and stakeholders - progress, what is at risk, what was delivered and where it is deployed - and writes release notes. Use when someone asks "how is the sprint going", "prepare the sprint review", "what did we deliver", "release notes", "status for stakeholders", or "what is new today". Needs the ado-lens MCP server. +--- + +# Sprint summary and release notes + +The reader is a Product Owner or a stakeholder: plain business language, in the user's language; ids and technical +names only in brackets. + +## How is the sprint going + +1. `ado_sprint` (the team's current sprint; `sprint=next|previous|` when asked). If the sprint has ended, say so + plainly and ask whether they mean the next one or a date range. +2. Answer in this order: + - one sentence: on track or not, and why ("8 of 12 done, 2 days left, remaining work fits the capacity"), + - **needs attention** — the items from the attention list, each with what to do about it (who to ask, what to + decide), + - **in progress / not started** only when asked for details. +3. `ado_board summary` when they ask about the board or WIP limits; `ado_my_day` for "what is new for me". + +## Sprint review, delivered work, release notes + +1. Find the items: the sprint's done items (`ado_sprint detail=full`), or `ado_query` with a WIQL for a date range + (state category Completed, changed in the range), using field names from `ado_fields`. +2. For the most important items, `ado_trace id= detail=summary` tells where each one is (DEV / UAT / PROD). +3. Write the notes grouped by what users notice (new, changed, fixed), one line each, no internal jargon: + "Orders above the credit limit are now blocked at confirmation (#4821)". Add where it is available. +4. Offer to save them as a wiki page (`ado_wiki_write`, mode=create or append) — only after the user agrees. diff --git a/skills/po-story/SKILL.md b/skills/po-story/SKILL.md new file mode 100644 index 0000000..1bfd2ff --- /dev/null +++ b/skills/po-story/SKILL.md @@ -0,0 +1,34 @@ +--- +name: po-story +description: Writes a work item for a Product Owner - a requirement (user story, product backlog item, requirement), a bug or a task - short and complete, with testable acceptance criteria, using the project's real types and fields. Use when someone asks to "create a story", "write a requirement", "report a bug", "split this into tasks", or describes a need that should become a work item in Azure DevOps. Needs the ado-lens MCP server. +--- + +# Writing a work item + +The reader of the item is a developer and a tester; the writer is a Product Owner. Goal: after reading it, everyone +knows what to do and how to check it — nothing more. Write in the language the user writes in. + +## Steps + +1. Find out what is needed — ask only what is missing, at most 3 short questions at once: + - who needs it and why (the value), what exactly should happen, what is out of scope, + - for a bug: what happens, what should happen, steps, where (environment, company, sample data). +2. `ado_fields` (no type) — which type is the *requirement* level of this process (User Story, Product Backlog Item, + Requirement, or a company type) and which is the bug level. Then `ado_fields type=` — required fields and + their allowed values. Never guess field names or values. +3. Draft it and show the draft to the user before creating anything: + - **Requirement** — description: goal in 1–2 sentences, scope as bullets (and "out of scope" when it matters); + acceptance criteria in their own field: each one testable, Given/When/Then or one sentence; at most 7 — more means + the item should be split. About 120 words for the description. + - **Bug** — what happens / what should happen / numbered steps / where; about 100 words plus the steps. + - **Task** — one sentence: what to change (dev) or what and how to check (test). + - No filler, no repeating the title, concrete words ("credit limit check when the order is confirmed", not + "validation fixes"). Technical details belong in tasks, not in the requirement. +4. After the user agrees: `ado_create` (with `parent` for a task or a story under a feature). Fill required fields the + process asks for; if one is unknown, ask instead of inventing. +5. Optional, when asked: split into tasks for development and testing (`ado_create` type=Task, parent=), or link + related items (`ado_link`). + +## Answer + +One line with the created item and its link, then any notes ADO Lens returned (e.g. a description that is too long). diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index 8dab814..318960c 100644 --- a/src/AdoLens/AdoLens.csproj +++ b/src/AdoLens/AdoLens.csproj @@ -21,7 +21,7 @@ ADO Lens for Azure DevOps Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP Copyright © 2026 WillowDev - 0.2.0 + 0.3.0 diff --git a/src/AdoLens/Cli.cs b/src/AdoLens/Cli.cs index 212fac8..6f835b8 100644 --- a/src/AdoLens/Cli.cs +++ b/src/AdoLens/Cli.cs @@ -54,6 +54,8 @@ wiki [] --search --depth 2 --detail full --wiki api raw JSON of a GET request (development) stats MCP calls of the last days: answer sizes, times, empty answers --days 7 --top 10 --live: show every call as Claude makes it (tool, tokens, ms), until Ctrl+C + setup interactive: who uses it (mode) and where code goes (environments; for you or the team wiki) + upgrade is there a newer ADO Lens --install (download, check SHA-256, run its installer) --pre version print the version --tokens after any command prints the estimated size of the answer ("[~230 tokens]"); @@ -460,6 +462,107 @@ public async Task MyDayAsync() return 0; } + public async Task UpdateCommandAsync() + { + var (text, failed) = await Updater.RunAsync(cl.Flag("install"), cl.Flag("pre")); + (failed ? Console.Error : Console.Out).WriteLine(text); + return failed ? 1 : 0; + } + + /// + /// The interactive setup after the connection: who uses it (mode) and where code goes (environments), saved for this + /// user or for the whole team on the wiki. Every question has a default, Enter accepts it. + /// + public async Task SetupAsync() + { + var path = SettingsPath; + var settings = Settings.Load(path); + if (settings.Connections.Count == 0) + { + Console.WriteLine(Ui.T("No Azure DevOps connection yet - let's add one.", "Nie ma jeszcze połączenia z Azure DevOps - dodajmy je.")); + var login = await LoginAsync(); + if (login != 0) return login; + settings = Settings.Load(path); + } + var interactive = !Console.IsInputRedirected; + + // 1. Who uses it. + if (cl.Opt("mode") is { } m) settings.Mode = Settings.NormalizeMode(m); + else if (interactive) + { + Console.WriteLine(); + Console.WriteLine(Ui.T("Who will use ADO Lens here?", "Kto będzie tu używał ADO Lens?")); + Console.WriteLine(Ui.T(" 1. Product Owner - work items, sprints, board, wiki, checking delivered work", + " 1. Product Owner - work itemy, sprinty, tablica, wiki, sprawdzanie wykonanej pracy")); + Console.WriteLine(Ui.T(" 2. Developer - work items, wiki, code, pull requests, pipelines", + " 2. Programista - work itemy, wiki, kod, pull requesty, pipeline'y")); + Console.WriteLine(Ui.T(" 3. Everything", " 3. Wszystko")); + var current = settings.Mode switch { "dev" => "2", "full" => "3", _ => "1" }; + var answer = Prompt(Ui.T($"Choice [{current}]: ", $"Wybór [{current}]: ")); + settings.Mode = (string.IsNullOrWhiteSpace(answer) ? current : answer.Trim()) switch { "2" => "dev", "3" => "full", _ => "po" }; + } + settings.Save(path); + + // 2. Where code goes, per connection. + var lens = NewLens(); + foreach (var name in settings.Connections.Keys.ToList()) + { + Session session; + try { session = lens.For(name); } + catch (InvalidOperationException ex) { Console.WriteLine($"{name}: {ex.Message}"); continue; } + if (session.Config.Project is not { } project) continue; + Console.WriteLine(); + var (effective, source) = await EnvironmentFlow.EffectiveAsync(session, project, default); + if (effective.Count > 0) + { + Console.WriteLine(Ui.T($"{name} / {project}: code goes {string.Join(" -> ", effective.Values)} ({source}).", + $"{name} / {project}: kod trafia kolejno na {string.Join(" -> ", effective.Values)} ({source}).")); + continue; + } + var detected = await EnvironmentFlow.DetectAsync(session, project, default); + if (detected.Count == 0 || !interactive) + { + Console.WriteLine(Ui.T($"{name} / {project}: environments not set - Claude will ask about them when needed.", + $"{name} / {project}: środowiska nieustawione - Claude zapyta o nie, gdy będą potrzebne.")); + continue; + } + Console.WriteLine(Ui.T($"{name} / {project}: where does code go before it reaches production?", + $"{name} / {project}: przez jakie środowiska przechodzi kod, zanim trafi na produkcję?")); + Console.WriteLine(Ui.T("Give each a name people use (e.g. UAT (sbx03)); Enter keeps the suggestion, '-' skips it.", + "Nadaj każdemu nazwę, której używacie (np. UAT (sbx03)); Enter zostawia propozycję, '-' pomija.")); + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, guess) in detected) + { + var label = Prompt($" {key}{(guess is null ? "" : $" [{guess}]")}: "); + if (label == "-") continue; + var value = string.IsNullOrWhiteSpace(label) ? guess : label.Trim(); + if (value is not null) map[key] = value; + } + if (map.Count == 0) continue; + var forTeam = (Prompt(Ui.T("Save for the whole team on the project's wiki page \"ADO Lens\"? [y/N]: ", + "Zapisać dla całego zespołu na stronie wiki projektu \"ADO Lens\"? [t/N]: ")) ?? "") + .Trim().ToLowerInvariant() is "y" or "yes" or "t" or "tak"; + if (forTeam) + { + try + { + var url = await session.Team.SaveAsync(project, map, default); + Console.WriteLine(Ui.T($"saved on the wiki: {url}", $"zapisane na wiki: {url}")); + continue; + } + catch (AdoApiException ex) + { + Console.WriteLine(Ui.T($"could not write the wiki page ({ex.Message}) - saving for you only", + $"nie udało się zapisać strony wiki ({ex.Message}) - zapisuję tylko dla ciebie")); + } + } + settings.Connections[name].Environments = map; + settings.Save(path); + Console.WriteLine(Ui.T("saved in your settings", "zapisane w twoich ustawieniach")); + } + return 0; + } + public async Task TraceAsync() { if (Int(cl.Positional.FirstOrDefault()) is not { } id) { Console.Error.WriteLine("usage: adolens trace [--detail summary|full]"); return 2; } diff --git a/src/AdoLens/CommandLine.cs b/src/AdoLens/CommandLine.cs index 9273bb4..5600c1f 100644 --- a/src/AdoLens/CommandLine.cs +++ b/src/AdoLens/CommandLine.cs @@ -12,6 +12,8 @@ public sealed partial class CommandLine static readonly Dictionary Known = new(StringComparer.OrdinalIgnoreCase) { ["mcp"] = [], + ["upgrade"] = ["install", "pre"], + ["setup"] = ["mode="], ["login"] = ["connection=", "org=", "project=", "team=", "pat-stdin", "default"], ["logout"] = ["connection="], ["connections"] = [], diff --git a/src/AdoLens/EnvironmentFlow.cs b/src/AdoLens/EnvironmentFlow.cs new file mode 100644 index 0000000..1d48a9b --- /dev/null +++ b/src/AdoLens/EnvironmentFlow.cs @@ -0,0 +1,40 @@ +using System.Text.Json; + +namespace AdoLens; + +/// +/// Where code goes in a project, found without asking anyone: release stages deployed in the last half year and the +/// long-lived branches of its Git repositories, ordered DEV → UAT → PROD with a first guess of each name. +/// +public static class EnvironmentFlow +{ + /// The effective map of a project: the local one when set, else the team's page in the wiki. + public static async Task<(IReadOnlyDictionary Map, string Source)> EffectiveAsync(Session session, string project, CancellationToken ct) + { + if (session.Config.Environments.Count > 0) return (session.Config.Environments, "local settings"); + if (await session.Team.EnvironmentsAsync(project, ct) is { } team) return (team, $"team page \"{TeamSettings.PagePath}\" in the wiki of {project}"); + return (new Dictionary(), "none"); + } + + public static async Task> DetectAsync(Session session, string project, CancellationToken ct) + { + var keys = new List(); + foreach (var d in await session.Code.DeploymentsAsync(project, ct)) + { + if (d.When is not { } when || when < DateTime.UtcNow.AddDays(-183)) continue; + keys.Add(d.Branch?.StartsWith("$/", StringComparison.Ordinal) == true ? $"{d.Definition}/{d.Stage}" : Code.Short(d.Branch) ?? $"{d.Definition}/{d.Stage}"); + } + // Long-lived branches of the repositories that changed lately (feature branches are filtered by ProposeFlow). + try + { + var repos = await session.Client.GetListAsync($"{AdoClient.Scope(project)}_apis/git/repositories", ct); + foreach (var repo in repos.Where(r => !Json.Bool(r, "isDisabled") && r.TryGetProperty("size", out var s) && s.GetInt64() > 0).Take(30)) + { + var refs = await session.Client.GetListAsync($"_apis/git/repositories/{Json.Str(repo, "id")}/refs?filter=heads/&$top=200", ct); + keys.AddRange(refs.Select(r => Code.Short(Json.Str(r, "name")) ?? "").Where(n => n.Length > 0 && !n.Contains('/'))); + } + } + catch (AdoApiException) { } // no Git, or no Code (Read) scope + return TraceQueries.ProposeFlow(keys.Distinct(StringComparer.OrdinalIgnoreCase)); + } +} diff --git a/src/AdoLens/Lens.cs b/src/AdoLens/Lens.cs index 71cc96f..3fc6508 100644 --- a/src/AdoLens/Lens.cs +++ b/src/AdoLens/Lens.cs @@ -12,6 +12,8 @@ public sealed record Session(Config Config, AdoClient Client, MetadataService Me public Code Code { get; } = new(Client); + public TeamSettings Team { get; } = new(Client); + /// The project given, else the connection's default project. public string Project(string? project) => !string.IsNullOrWhiteSpace(project) ? project.Trim() diff --git a/src/AdoLens/McpTools.cs b/src/AdoLens/McpTools.cs index ead2439..627dec8 100644 --- a/src/AdoLens/McpTools.cs +++ b/src/AdoLens/McpTools.cs @@ -50,6 +50,12 @@ public static string Instructions(Settings settings) /// Set at server start from the settings: end every answer with "[~N tokens]". public static bool ShowTokens { get; set; } + /// An available update is mentioned once per session, after the first answer. + static int _updateNoticed; + + static string WithNotice(string answer) => + Updater.PendingNotice() is { } notice && Interlocked.Exchange(ref _updateNoticed, 1) == 0 ? answer + "\nnote: " + notice : answer; + internal static async Task Run(string tool, object args, Func> f) { var sw = Stopwatch.StartNew(); @@ -58,7 +64,8 @@ internal static async Task Run(string tool, object args, Func Status(Lens lens, => McpTools.Run("ado_status", new { connection }, () => Queries.StatusAsync(lens, connection, offline: false, ct)); [McpServerTool(Name = "ado_settings")] - [Description("Shows the connection's settings; with environments saves for good what branches and release stages mean, in flow order: \"dev=DEV (sbx01); staging=UAT (sbx03); master=PROD\" (a stage: \"Release/Stage=UAT + SCM\").")] - public static Task Settings(Lens lens, string? environments = null, string? connection = null, CancellationToken ct = default) - => McpTools.Run("ado_settings", new { environments, connection }, () => Task.FromResult(Queries.SettingsAnswer(lens, connection, environments))); + [Description("Shows the connection's settings and the environment flow (detected when not set); with environments saves for good what branches and release stages mean, in flow order: \"dev=DEV (sbx01); staging=UAT (sbx03); master=PROD\" (a stage: \"Release/Stage=UAT + SCM\"). scope=team saves it on the project's wiki page for everyone.")] + public static Task Settings(Lens lens, + string? environments = null, + [Description("local (default) | team.")] string? scope = null, + string? project = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_settings", new { environments, scope, project, connection }, () => Queries.SettingsAsync(lens, connection, project, environments, scope, ct)); + + [McpServerTool(Name = "ado_upgrade")] + [Description("Checks for a newer ADO Lens; install=true downloads it (SHA-256 checked) and starts its installer in its own window. Only when the user asks.")] + public static Task Upgrade(Lens lens, bool install = false, CancellationToken ct = default) + => McpTools.Run("ado_upgrade", new { install }, async () => + { + var (text, failed) = await Updater.RunAsync(install, pre: false, ct); + return failed ? throw new InvalidOperationException(text) : text; + }); [McpServerTool(Name = "ado_fields", ReadOnly = true, Idempotent = true)] [Description("Work item structure of the project: types by backlog level; with type: states, transitions, fields, required flags, allowed values; with field: one field across types; with changes: what changed in the structure.")] diff --git a/src/AdoLens/Program.cs b/src/AdoLens/Program.cs index a337c65..77f3ca0 100644 --- a/src/AdoLens/Program.cs +++ b/src/AdoLens/Program.cs @@ -72,6 +72,10 @@ return await cli.BacklogAsync(); case "myday": return await cli.MyDayAsync(); + case "upgrade": + return await cli.UpdateCommandAsync(); + case "setup": + return await cli.SetupAsync(); case "trace": return await cli.TraceAsync(); case "changes": @@ -103,6 +107,8 @@ async Task RunServerAsync() { var lens = Lens.Default(version); Usage.Enabled &= lens.Settings.UsageLog; + Updater.RemoveLeftovers(); + Updater.CheckInBackground(); McpTools.ShowTokens = lens.Settings.ShowTokens; var builder = Host.CreateApplicationBuilder([]); diff --git a/src/AdoLens/Queries.cs b/src/AdoLens/Queries.cs index f2ca198..c4d7859 100644 --- a/src/AdoLens/Queries.cs +++ b/src/AdoLens/Queries.cs @@ -33,6 +33,7 @@ public static async Task StatusAsync(Lens lens, string? connection, bool } sb.AppendLine($"PAT from {cfg.PatSource}"); if (cfg.Warning is { } w) sb.AppendLine("warning: " + w); + if (Updater.PendingNotice() is { } update) sb.AppendLine("update: " + update); var others = lens.Settings.Connections.Keys.Where(k => !k.Equals(cfg.Connection, StringComparison.OrdinalIgnoreCase)).ToList(); if (others.Count > 0) sb.AppendLine("other connections: " + string.Join(", ", others)); @@ -55,23 +56,65 @@ public static string SettingsAnswer(Lens lens, string? connection, string? envir throw new InvalidOperationException($"connection '{cfg.Connection}' comes from environment variables and cannot be changed here"); if (environments is not null) { - var map = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var part in environments.Split([';', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - var eq = part.IndexOf('='); - if (eq <= 0 || eq == part.Length - 1) throw new ArgumentException($"'{part}' is not key=label (e.g. staging=UAT (sbx03))"); - map[part[..eq].Trim()] = part[(eq + 1)..].Trim(); - } - conn.Environments = map; + conn.Environments = ParseEnvironments(environments); lens.SaveSettings(); } var sb = new StringBuilder(environments is null ? "" : "saved.\n"); sb.AppendLine($"connection {cfg.Connection}: https://dev.azure.com/{conn.Organization}, project {conn.Project ?? "-"}, team {conn.Team ?? "-"}, mode {lens.Settings.Mode}"); - sb.Append(conn.Environments.Count == 0 ? "environments: not set (ado_trace proposes them)" + sb.Append(conn.Environments.Count == 0 ? "local environments: not set" : "environments (in flow order): " + string.Join(" -> ", conn.Environments.Select(e => $"{e.Value} [{e.Key}]"))); return sb.ToString(); } + /// + /// The settings and the environment flow of a project: saved locally or (scope=team) on the project's wiki page for + /// everyone; without a map, the flow detected from releases and branches, so Claude can ask the user once. + /// + public static async Task SettingsAsync(Lens lens, string? connection, string? project, string? environments, string? scope, CancellationToken ct = default) + { + var team = (scope ?? "local").Trim().ToLowerInvariant() switch + { + "local" => false, + "team" => true, + _ => throw new ArgumentException("scope is local or team"), + }; + if (environments is not null && team) + { + var session = lens.For(connection); + var name = session.Project(project); + var map = ParseEnvironments(environments); + var url = await session.Team.SaveAsync(name, map, ct); + return $"saved for the whole team on the wiki page \"{TeamSettings.PagePath}\" of {name}: " + + string.Join(" -> ", map.Select(e => $"{e.Value} [{e.Key}]")) + $"\n{url}"; + } + var text = SettingsAnswer(lens, connection, environments); + if (environments is not null) return text; + + var s = lens.For(connection); + var p = s.Project(project); + var (effective, source) = await EnvironmentFlow.EffectiveAsync(s, p, ct); + if (effective.Count > 0) + return text + $"\nin use for {p} ({source}): " + string.Join(" -> ", effective.Select(e => $"{e.Value} [{e.Key}]")); + var detected = await EnvironmentFlow.DetectAsync(s, p, ct); + return text + (detected.Count == 0 + ? $"\nno release stages or long-lived branches found in {p}" + : $"\ndetected in {p}: {string.Join(", ", detected.Select(d => d.Guess is null ? d.Key : $"{d.Key} (looks like {d.Guess})"))}. " + + "Ask the user what each is called and in which order code moves, then save with environments=\"key=name; …\" " + + "(scope=team for everyone, after the user agrees)."); + } + + static Dictionary ParseEnvironments(string environments) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var part in environments.Split([';', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var eq = part.IndexOf('='); + if (eq <= 0 || eq == part.Length - 1) throw new ArgumentException($"'{part}' is not key=label (e.g. staging=UAT (sbx03))"); + map[part[..eq].Trim()] = part[(eq + 1)..].Trim(); + } + return map; + } + public static async Task FieldsAsync(Lens lens, string? connection, string? project, string? type, string? field, string? changes, bool all, bool refresh, CancellationToken ct = default) { diff --git a/src/AdoLens/TeamSettings.cs b/src/AdoLens/TeamSettings.cs new file mode 100644 index 0000000..ca3baa6 --- /dev/null +++ b/src/AdoLens/TeamSettings.cs @@ -0,0 +1,96 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; + +namespace AdoLens; + +/// +/// Settings shared by a team, kept in Azure DevOps itself: a page "ADO Lens" in the project wiki. Everyone using ADO Lens +/// on the project reads it, so a Product Owner configures nothing; a local map (adolens config --env) overrides it. +/// The page is plain markdown people can read and edit: +/// +/// | Branch or release stage | Environment | +/// |---|---| +/// | dev | DEV (sbx01) | +/// | staging | UAT (sbx03) | +/// | master | PROD | +/// +/// +public sealed class TeamSettings(AdoClient client) +{ + public const string PagePath = "/ADO Lens"; + static readonly TimeSpan Fresh = TimeSpan.FromHours(1); + + readonly ConcurrentDictionary? Map)> _cache = new(StringComparer.OrdinalIgnoreCase); + + static string PageUrl(string project, string wikiId, string extra = "") => + $"{AdoClient.Scope(project)}_apis/wiki/wikis/{Uri.EscapeDataString(wikiId)}/pages?path={Uri.EscapeDataString(PagePath)}{extra}"; + + async Task ProjectWikiAsync(string project, CancellationToken ct) + { + var wikis = await client.GetListAsync($"{AdoClient.Scope(project)}_apis/wiki/wikis", ct); + return wikis.Where(w => Json.Str(w, "type") == "projectWiki").Select(w => Json.Str(w, "id")).FirstOrDefault(); + } + + /// The team's environment map of a project in flow order, or null when the page or the wiki does not exist. + public async Task?> EnvironmentsAsync(string project, CancellationToken ct) + { + if (_cache.TryGetValue(project, out var hit) && DateTime.UtcNow - hit.Read < Fresh) return hit.Map; + Dictionary? map = null; + try + { + if (await ProjectWikiAsync(project, ct) is { } wiki) + map = Parse(Json.Str(await client.GetAsync(PageUrl(project, wiki, "&includeContent=true"), ct), "content")); + } + catch (AdoApiException) { } // no page, no wiki, or no permission: no team map + _cache[project] = (DateTime.UtcNow, map); + return map; + } + + /// Rows of the first two-column table: key | environment. Header and separator rows are skipped. + internal static Dictionary? Parse(string? markdown) + { + if (string.IsNullOrWhiteSpace(markdown)) return null; + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var inTable = false; + foreach (var raw in markdown.Replace("\r", "").Split('\n')) + { + var line = raw.Trim(); + if (!line.StartsWith('|')) { if (inTable && map.Count > 0) break; continue; } + var cells = line.Trim('|').Split('|').Select(c => c.Trim().Trim('`')).ToArray(); + if (cells.Length < 2 || cells.All(c => c.Replace("-", "").Replace(":", "").Length == 0)) { inTable = true; continue; } + if (!inTable) { inTable = true; continue; } // header row + if (cells[0].Length > 0 && cells[1].Length > 0) map[cells[0]] = cells[1]; + } + return map.Count > 0 ? map : null; + } + + public static string Render(IReadOnlyDictionary map) + { + var sb = new StringBuilder(); + sb.AppendLine("# ADO Lens"); + sb.AppendLine(); + sb.AppendLine("Where code goes, in order. ADO Lens (the Azure DevOps tool for Claude) reads this table to say where a change is."); + sb.AppendLine("A key is a branch (`dev`; for TFVC the last part of the branch path, e.g. `Main`), a release definition,"); + sb.AppendLine("or one release stage as `Definition/Stage`. Edit freely; keep the two columns."); + sb.AppendLine(); + sb.AppendLine("| Branch or release stage | Environment |"); + sb.AppendLine("|---|---|"); + foreach (var (key, label) in map) sb.AppendLine($"| {key} | {label} |"); + return sb.ToString(); + } + + /// Creates or replaces the page (with the version read just before, so a parallel edit is not lost). + public async Task SaveAsync(string project, IReadOnlyDictionary map, CancellationToken ct) + { + var wiki = await ProjectWikiAsync(project, ct) ?? throw new InvalidOperationException($"project {project} has no project wiki to keep team settings in"); + string? etag = null; + try { (_, etag) = await client.SendWithHeadersAsync(HttpMethod.Get, PageUrl(project, wiki), null, null, null, ct); } + catch (AdoApiException ex) when (ex.Status == System.Net.HttpStatusCode.NotFound) { } + var headers = etag is null ? null : new Dictionary { ["If-Match"] = etag }; + var (page, _) = await client.SendWithHeadersAsync(HttpMethod.Put, PageUrl(project, wiki, "&comment=" + Uri.EscapeDataString("Environment map for ADO Lens")), + new { content = Render(map) }, "application/json", headers, ct); + _cache[project] = (DateTime.UtcNow, new Dictionary(map, StringComparer.OrdinalIgnoreCase)); + return Json.Str(page, "remoteUrl") ?? PagePath; + } +} diff --git a/src/AdoLens/Trace.cs b/src/AdoLens/Trace.cs index 5e0fe07..7edc0c0 100644 --- a/src/AdoLens/Trace.cs +++ b/src/AdoLens/Trace.cs @@ -114,7 +114,7 @@ await Parallel.ForEachAsync(links.Where(l => l.Kind != "build/build" && l.Kind ! catch (AdoApiException ex) { lock (failed) failed.Add($"{l.Kind.Split('/')[^1]} {l.Value.Split('/')[^1]}: {Formatting.Collapse(ex.Message, 100)}"); } }); changes = Combine(changes.OrderBy(c => c.Change.Date).ToList()); - var env = session.Config.Environments; + var (env, envSource) = await EnvironmentFlow.EffectiveAsync(session, project, ct); // A PR from a long-lived branch (a release branch into master, staging into master…) carries many items at once: // it is shown in one line and counts as evidence of where the work is, not as work of its own. var prTargets = changes.Where(x => x.Change.Kind == "pr").Select(x => x.Change.TargetBranch).Where(t => t is not null).ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -290,7 +290,8 @@ static string Names(IEnumerable list) => sb.AppendLine("\nSETUP").AppendLine( $"environment names are not set for connection {session.Config.Connection}. Found: {string.Join(", ", proposal.Select(p => p.Guess is null ? p.Key : $"{p.Key} (looks like {p.Guess})"))}. " + "Ask the user what each is called and in which order code moves through them (e.g. DEV sbx01 -> UAT sbx03 -> PROD), " + - "save it once with ado_settings environments=\"" + string.Join("; ", proposal.Select(p => $"{p.Key}={p.Guess ?? ""}")) + "\", then answer with those names."); + "save it once with ado_settings environments=\"" + string.Join("; ", proposal.Select(p => $"{p.Key}={p.Guess ?? ""}")) + "\" " + + "(scope=team puts it on the wiki page for the whole team, after the user agrees), then answer with those names."); } if (changes.Count > 0 && where.Count > 0 && !anyIn) signals.Add("not deployed to any stage yet"); diff --git a/src/AdoLens/Ui.cs b/src/AdoLens/Ui.cs new file mode 100644 index 0000000..5766f27 --- /dev/null +++ b/src/AdoLens/Ui.cs @@ -0,0 +1,17 @@ +using System.Runtime.InteropServices; + +namespace AdoLens; + +/// +/// Texts of the interactive setup in the language of Windows: Polish when the user interface is Polish, else English. +/// (The program runs with invariant globalization, so the UI language is read from Windows directly.) +/// +public static class Ui +{ + [DllImport("kernel32.dll")] + static extern ushort GetUserDefaultUILanguage(); + + public static bool Polish { get; set; } = OperatingSystem.IsWindows() && (GetUserDefaultUILanguage() & 0x3FF) == 0x15; + + public static string T(string english, string polish) => Polish ? polish : english; +} diff --git a/src/AdoLens/Updater.cs b/src/AdoLens/Updater.cs new file mode 100644 index 0000000..8dbe5ec --- /dev/null +++ b/src/AdoLens/Updater.cs @@ -0,0 +1,246 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text.Json.Nodes; + +namespace AdoLens; + +/// +/// A release version: "0.2.0", or a test release "0.3.0-beta.2". A test release comes before its final version +/// (0.3.0-beta.2 < 0.3.0); its parts after the dash compare as numbers where they are numbers (beta.10 > beta.9). +/// +public sealed record ReleaseVersion(int Major, int Minor, int Patch, string? Pre = null) : IComparable +{ + public bool IsPrerelease => Pre != null; + + public int CompareTo(ReleaseVersion? other) + { + if (other is null) return 1; + var c = Major.CompareTo(other.Major); + if (c == 0) c = Minor.CompareTo(other.Minor); + if (c == 0) c = Patch.CompareTo(other.Patch); + if (c != 0) return c; + if (Pre == null || other.Pre == null) return Pre == null ? (other.Pre == null ? 0 : 1) : -1; + var a = Pre.Split('.'); + var b = other.Pre.Split('.'); + for (var i = 0; i < Math.Min(a.Length, b.Length); i++) + { + bool an = int.TryParse(a[i], out var ai), bn = int.TryParse(b[i], out var bi); + c = an && bn ? ai.CompareTo(bi) : an ? -1 : bn ? 1 : string.CompareOrdinal(a[i], b[i]); + if (c != 0) return c; + } + return a.Length.CompareTo(b.Length); + } + + public static bool operator >(ReleaseVersion? a, ReleaseVersion? b) => a is not null && a.CompareTo(b) > 0; + public static bool operator <(ReleaseVersion? a, ReleaseVersion? b) => b is not null && b.CompareTo(a) > 0; + public static bool operator >=(ReleaseVersion? a, ReleaseVersion? b) => !(a < b); + public static bool operator <=(ReleaseVersion? a, ReleaseVersion? b) => !(a > b); + + public override string ToString() => $"{Major}.{Minor}.{Patch}{(Pre != null ? "-" + Pre : "")}"; +} + +/// +/// Updates from GitHub releases: the package's SHA-256 is the digest GitHub publishes for the asset, and the installer +/// of the new package runs in its own window (it replaces this program's files). The MCP server checks at most once a +/// day in the background and tells Claude once per session; nothing is installed without being asked. +/// +public static class Updater +{ + public const string Repository = "willowdev-code/ado-lens"; + 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); + + /// The running version, test-release suffix included; "+commit" build metadata dropped. + public static ReleaseVersion Current { get; } = + ParseVersion(typeof(Lens).Assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) + .OfType().FirstOrDefault()?.InformationalVersion) + ?? new ReleaseVersion(0, 0, 0); + + /// "v0.2.10" / "0.2.10" → 0.2.10; "v0.3.0-beta.2" → 0.3.0-beta.2. + public static ReleaseVersion? ParseVersion(string? tag) + { + if (string.IsNullOrWhiteSpace(tag)) return null; + var t = tag.Trim().TrimStart('v', 'V'); + if (t.IndexOf('+') is var plus and >= 0) t = t[..plus]; + string? pre = null; + if (t.IndexOf('-') is var dash and > 0) + { + pre = t[(dash + 1)..]; + t = t[..dash]; + if (pre.Length == 0) pre = null; + } + return Version.TryParse(t, out var v) ? new ReleaseVersion(v.Major, v.Minor, Math.Max(0, v.Build), pre) : null; + } + + public static ReleaseInfo? ParseRelease(string json) => ParseRelease(JsonNode.Parse(json)); + + /// The newest release in the JSON of GET /repos/{repo}/releases, test releases included (drafts skipped). + public static ReleaseInfo? NewestRelease(string json) => + (JsonNode.Parse(json) as JsonArray)?.OfType() + .Where(r => r["draft"]?.GetValue() != true) + .Select(r => ParseRelease(r)).OfType() + .MaxBy(r => r.Version); + + static ReleaseInfo? ParseRelease(JsonNode? n) + { + var tag = n?["tag_name"]?.GetValue(); + if (ParseVersion(tag) is not { } version) return null; + var asset = (n!["assets"] as JsonArray)?.OfType() + .FirstOrDefault(a => a["name"]?.GetValue() is { } name + && name.StartsWith("ado-lens-", StringComparison.OrdinalIgnoreCase) + && name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); + var digest = asset?["digest"]?.GetValue(); + return new ReleaseInfo(version, tag!, n["html_url"]?.GetValue() ?? $"https://github.com/{Repository}/releases", + asset?["name"]?.GetValue(), asset?["browser_download_url"]?.GetValue(), + digest != null && digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? digest[7..].ToLowerInvariant() : null, + n["body"]?.GetValue() ?? "", n["prerelease"]?.GetValue() ?? version.IsPrerelease); + } + + static string StateFile => Path.Combine(Settings.DataDir, "update.json"); + + static (ReleaseVersion? Latest, DateTime? Checked) ReadState() + { + try + { + if (!File.Exists(StateFile)) return (null, null); + var n = JsonNode.Parse(File.ReadAllText(StateFile)); + var when = DateTime.TryParse(n?["checkedUtc"]?.GetValue(), null, System.Globalization.DateTimeStyles.RoundtripKind, out var d) ? d : (DateTime?)null; + return (ParseVersion(n?["latest"]?.GetValue()), when); + } + catch (Exception) { return (null, null); } + } + + static void SaveState(ReleaseInfo r) + { + try + { + Directory.CreateDirectory(Settings.DataDir); + File.WriteAllText(StateFile, new JsonObject { ["latest"] = r.Version.ToString(), ["checkedUtc"] = DateTime.UtcNow.ToString("o") }.ToJsonString()); + } + catch (Exception) { } + } + + /// "ADO Lens 0.3.0 is available (you have 0.2.0)…" from the last check, without touching the network. + public static string? PendingNotice() + { + var (latest, _) = ReadState(); + return latest != null && latest > Current + ? $"ADO Lens {latest} is available (you have {Current}): ado_upgrade installs it (say 'update ADO Lens'); settings and PATs are kept" + : null; + } + + /// Checks GitHub at most once a day, in the background; failures are silent (offline, proxy). + public static void CheckInBackground() + { + var (_, checkedUtc) = ReadState(); + if (checkedUtc is { } c && DateTime.UtcNow - c < CheckEvery) return; + _ = Task.Run(async () => + { + try + { + using var http = Client(); + http.Timeout = TimeSpan.FromSeconds(20); + if (ParseRelease(await http.GetStringAsync($"https://api.github.com/repos/{Repository}/releases/latest")) is { } r) SaveState(r); + } + catch (Exception) { } + }); + } + + static HttpClient Client() + { + var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("ado-lens", Current.ToString())); + http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + return http; + } + + public static string Sha256Of(string file) + { + using var fs = File.OpenRead(file); + return Convert.ToHexString(SHA256.HashData(fs)).ToLowerInvariant(); + } + + /// The installer renames a running adolens.exe to *.old; the next start removes those no longer in use. + public static void RemoveLeftovers() + { + try + { + foreach (var f in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.old")) + try { File.Delete(f); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } // still used by an older Claude session + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } + } + + /// The installation folder of the running program: the parent of its 'bin' folder. + public static string InstallDir() + { + var bin = new DirectoryInfo(AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar)); + return bin.Name.Equals("bin", StringComparison.OrdinalIgnoreCase) && bin.Parent != null ? bin.Parent.FullName : bin.FullName; + } + + /// Looks up the latest release; with downloads, verifies and starts its installer. + /// Text for the user, and whether it failed. + public static async Task<(string Text, bool Failed)> RunAsync(bool install, bool pre = false, CancellationToken ct = default) + { + using var http = Client(); + ReleaseInfo? latest; + try + { + // /releases/latest never returns a test release; the list holds both kinds. + latest = pre + ? NewestRelease(await http.GetStringAsync($"https://api.github.com/repos/{Repository}/releases?per_page=30", ct)) + : ParseRelease(await http.GetStringAsync($"https://api.github.com/repos/{Repository}/releases/latest", ct)); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + return ($"cannot reach GitHub: {ex.Message}", true); + } + if (latest == null) return ("the latest release has no readable version tag", true); + if (!latest.Prerelease) SaveState(latest); + + var head = $"installed: {Current}\nlatest: {latest.Version}{(latest.Prerelease ? " (test release)" : "")} {latest.PageUrl}"; + if (latest.Version <= Current) return ($"{head}\nyou have the latest version.", false); + if (!install) + { + var notes = latest.Notes.Replace("\r", "").Split('\n').Where(l => l.TrimStart().StartsWith("- ", StringComparison.Ordinal)).Take(12).ToList(); + 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 zip = Path.Combine(work, latest.AssetName); + await using (var src = await http.GetStreamAsync(latest.AssetUrl, ct)) + await using (var dst = File.Create(zip)) + await src.CopyToAsync(dst, ct); + + var actual = Sha256Of(zip); + if (!actual.Equals(latest.Sha256, StringComparison.OrdinalIgnoreCase)) + return ($"SHA-256 mismatch for {latest.AssetName}: expected {latest.Sha256}, got {actual} - not installing", true); + + var extracted = Path.Combine(work, "package"); + ZipFile.ExtractToDirectory(zip, extracted); + var script = Directory.EnumerateFiles(extracted, "install.ps1", SearchOption.AllDirectories).FirstOrDefault(); + 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", + Arguments = $"-NoProfile -ExecutionPolicy Bypass -NoExit -File \"{script}\" -InstallDir \"{target}\"", + UseShellExecute = true, + WorkingDirectory = Path.GetDirectoryName(script)!, + }); + return ($"{head}\nSHA-256 verified ({actual[..12]}…). The installer continues in its own window (target {target}); " + + "Claude may stay open - new sessions use the new version, Claude Desktop needs a restart for running ones.", false); + } +} diff --git a/tests/AdoLens.Tests/SetupFlowTests.cs b/tests/AdoLens.Tests/SetupFlowTests.cs new file mode 100644 index 0000000..05b478c --- /dev/null +++ b/tests/AdoLens.Tests/SetupFlowTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; + +namespace AdoLens.Tests; + +public class UpdaterTests +{ + [Theory] + [InlineData("v0.2.0", "0.2.0")] + [InlineData("0.3.0-beta.2", "0.3.0-beta.2")] + [InlineData("v1.0.0+abc", "1.0.0")] + public void Versions_are_read_from_tags(string tag, string expected) => + Assert.Equal(expected, Updater.ParseVersion(tag)!.ToString()); + + [Fact] + public void Test_releases_come_before_their_version() + { + Assert.True(Updater.ParseVersion("0.3.0-beta.2") < Updater.ParseVersion("0.3.0")); + Assert.True(Updater.ParseVersion("0.3.0-beta.10") > Updater.ParseVersion("0.3.0-beta.9")); + Assert.True(Updater.ParseVersion("0.10.0") > Updater.ParseVersion("0.9.5")); + } + + [Fact] + 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"}]} + """)!; + Assert.Equal("0.3.0", r.Version.ToString()); + Assert.Equal("ado-lens-0.3.0.zip", r.AssetName); + Assert.Equal("abc123", r.Sha256); + } +} + +public class TeamSettingsTests : IDisposable +{ + readonly string dir = Path.Combine(Path.GetTempPath(), $"adolens-team-{Guid.NewGuid():N}"); + readonly DemoProject demo = new(); + readonly List<(string? IfMatch, string Body)> puts = []; + string? page; + readonly Settings settings; + readonly Lens lens; + + static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK, string? etag = null) + { + var r = new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + if (etag is not null) r.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue($"\"{etag}\""); + return r; + } + + public TeamSettingsTests() + { + var ado = demo.Routes(new FakeAdo()) + .On("/contoso/Demo/_apis/wiki/wikis/w1/pages", r => + { + if (r.Method == HttpMethod.Put) + { + var body = (string)JsonNode.Parse(r.Content!.ReadAsStringAsync().Result)!["content"]!; + puts.Add((r.Headers.IfMatch.FirstOrDefault()?.ToString(), body)); + page = body; + return Json("""{"path":"/ADO Lens","remoteUrl":"https://dev.azure.com/contoso/Demo/_wiki/x"}""", HttpStatusCode.OK, "v2"); + } + return page is null ? Json("""{"message":"not found"}""", HttpStatusCode.NotFound) + : Json(new JsonObject { ["path"] = "/ADO Lens", ["content"] = page }.ToJsonString(), HttpStatusCode.OK, "v1"); + }) + .On("/contoso/Demo/_apis/wiki/wikis", """{"value":[{"id":"w1","name":"Demo.wiki","type":"projectWiki"}]}""") + .On("/contoso/Demo/_apis/release/deployments", """ + {"value":[{"releaseDefinition":{"name":"Shop"},"releaseEnvironment":{"name":"lcs"},"completedOn":"2099-01-01T00:00:00Z", + "release":{"artifacts":[{"definitionReference":{"branch":{"id":"refs/heads/staging"},"sourceVersion":{"id":"x"}}}]}}]} + """) + .On("/contoso/Demo/_apis/git/repositories", """{"value":[{"id":"r1","name":"portal","size":100}]}""") + .On("/contoso/_apis/git/repositories/r1/refs", """{"value":[{"name":"refs/heads/dev"},{"name":"refs/heads/master"},{"name":"refs/heads/feature/x"}]}"""); + settings = new Settings { DefaultConnection = "contoso" }; + settings.Connections["contoso"] = new Connection { Organization = "contoso", Project = "Demo" }; + lens = new Lens(settings, "0.0.0", dir, _ => null, _ => "pat", _ => ado.Client()); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + + [Fact] + public void The_wiki_table_round_trips_in_order() + { + var map = new Dictionary { ["dev"] = "DEV (sbx01)", ["staging"] = "UAT (sbx03)", ["Main-Deploy/Stage 1"] = "UAT + SCM" }; + var parsed = TeamSettings.Parse(TeamSettings.Render(map))!; + Assert.Equal(map.Keys, parsed.Keys); + Assert.Equal("UAT + SCM", parsed["main-deploy/stage 1"]); + Assert.Null(TeamSettings.Parse("# no table here")); + // People edit the page: extra spaces, backticks and text around the table are fine. + var edited = TeamSettings.Parse("Intro\n\n| Branch | Env |\n|:--|--:|\n| `dev` | DEV |\n\nMore text\n| x | y |")!; + Assert.Equal(["dev"], edited.Keys); + } + + [Fact] + public async Task Without_any_map_the_flow_is_detected_from_releases_and_branches() + { + var text = await Queries.SettingsAsync(lens, null, null, null, null); + Assert.Contains("detected in Demo: dev (looks like DEV), staging (looks like UAT), master (looks like PROD)", text); + Assert.DoesNotContain("feature", text); + } + + [Fact] + public async Task The_team_map_is_saved_on_the_wiki_and_used_by_everyone_unless_overridden_locally() + { + var saved = await Queries.SettingsAsync(lens, null, null, "dev=DEV (sbx01); staging=UAT (sbx03); master=PROD", "team"); + Assert.StartsWith("saved for the whole team on the wiki page \"/ADO Lens\" of Demo: DEV (sbx01) [dev] -> UAT (sbx03) [staging] -> PROD [master]", saved); + Assert.Null(puts.Single().IfMatch); // a new page + Assert.Contains("| staging | UAT (sbx03) |", page); + + // Someone else's session reads the page. + var other = new Lens(settings, "0.0.0", dir, _ => null, _ => "pat", _ => new FakeAdo().Client()); + var session = lens.For(null); + var (map, source) = await EnvironmentFlow.EffectiveAsync(session, "Demo", default); + Assert.Equal(["dev", "staging", "master"], map.Keys); + Assert.Contains("team page", source); + + // A second save replaces the page with the version just read. + await Queries.SettingsAsync(lens, null, null, "dev=DEV", "team"); + Assert.Equal("\"v1\"", puts[1].IfMatch); + + // A local map wins over the team's. + Queries.SettingsAnswer(lens, null, "staging=My UAT"); + var (local, localSource) = await EnvironmentFlow.EffectiveAsync(lens.For(null), "Demo", default); + Assert.Equal("My UAT", local["staging"]); + Assert.Equal("local settings", localSource); + _ = other; + } +} diff --git a/tests/AdoLens.Tests/SetupTests.cs b/tests/AdoLens.Tests/SetupTests.cs index 479ad99..c6145cf 100644 --- a/tests/AdoLens.Tests/SetupTests.cs +++ b/tests/AdoLens.Tests/SetupTests.cs @@ -288,7 +288,7 @@ public void The_server_really_registers_the_tools_of_the_mode() { var names = RegisteredTools(new Settings { Mode = "po" }).Select(t => t.ProtocolTool.Name).Order().ToList(); Assert.Equal(["ado_backlog", "ado_board", "ado_changes", "ado_comment", "ado_create", "ado_fields", "ado_link", "ado_my_day", "ado_query", "ado_settings", - "ado_sprint", "ado_status", "ado_trace", "ado_update", "ado_wiki", "ado_wiki_write", "ado_work_item"], names); + "ado_sprint", "ado_status", "ado_trace", "ado_update", "ado_upgrade", "ado_wiki", "ado_wiki_write", "ado_work_item"], names); } [Fact] diff --git a/uninstall.ps1 b/uninstall.ps1 index ba171f1..ee31674 100644 --- a/uninstall.ps1 +++ b/uninstall.ps1 @@ -45,6 +45,15 @@ Get-CimInstance Win32_Process -Filter "Name='adolens.exe'" | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } Start-Sleep -Milliseconds 500 +# Skills this installation put into Claude Code. +$skillSrc = Join-Path $InstallDir 'skills' +if (Test-Path $skillSrc) { + foreach ($skill in Get-ChildItem $skillSrc -Directory) { + $installed = Join-Path $env:USERPROFILE ".claude\skills\$($skill.Name)" + if (Test-Path (Join-Path $installed 'SKILL.md')) { Remove-Item $installed -Recurse -Force; Write-Host " removed skill $($skill.Name) from Claude Code" } + } +} + if ($RemoveData -and (Test-Path $data)) { Remove-Item $data -Recurse -Force; Write-Host " removed $data" } # This script lives in the folder it removes: delete everything else now, the folder itself after it exits. Get-ChildItem $InstallDir -Force | Where-Object { $_.FullName -ne $PSCommandPath } | Remove-Item -Recurse -Force