diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b2b57..85471a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 0.6.0 — 2026-10-15 + +Screenshots and documents for Claude, D365 labels, checks before testing, ready commands, a settings menu. + +- `ado_attachments`: the files of a work item and the screenshots pasted into its text or comments; images are shown + to Claude, Word, Excel and text files are read as text; `add` attaches a local file. +- `ado_label`: a D365 message pasted as seen (values in place of any %1 %2 …) or a label id → the label, its text in + every language (missing ones flagged) and where it is used (object and X++ method, field or control). +- `ado_review`: checks of a change or of every change of a work item before testing - work item linked, labels in + every language, X++ messages from labels, no TODO left. +- `ado_create_tree`: a tree of work items from an indented list, previewed, one undo for all of it. +- `ado_metrics`: velocity per sprint, cycle and lead time, what is in progress longest. +- `ado_changes file=a;b;c`: several diffs in one call. +- Outlook e-mails (.msg) attached to work items are read: subject, sender, recipients, date, the files attached to the + e-mail and its text (plain, or HTML turned into text). +- Required label languages per connection (`adolens config --connection --label-languages "en-US, pl"`, or the + settings menu): `ado_review` and `ado_label` report missing texts only in those languages. +- Prompts (ready commands in Claude Desktop "+" and Claude Code "/"): verify, explain, story, sprint, refine, test, wiki, + label, dev_start, dev_pr, dev_build, dev_why - each runs its skill, no skill upload needed. +- Skills `po-refine`, `po-test`, `po-wiki`, `dev-start`, `dev-pr`, `dev-build`, `dev-why`; `po-verify`, `po-explain` + and `po-sprint` use the new tools. Claude Code gets only the skills of the mode (`adolens skills` after a change). +- Settings menu (`adolens setup`, Start menu "ADO Lens - settings"): mode, environments (change, add, remove, for you + or the team), add or remove a connection, status, language. +- Installer: an environments page (stages and branches found, names suggested, saved for you or the team) and a + language choice (Polish or English) kept for the settings. +- `ado_trace` leaves out release stages outside the environment map that have not deployed for half a year. +- The settings file keeps "UAT + SCM" and Polish names readable. +- `adolens logout --connection --remove` removes the whole connection: the PAT, the settings entry and its + metadata cache (a new default is picked when one connection is left). +- `adolens update` without anything to change says it changes work items and points to `adolens upgrade --install` + for updating ADO Lens itself. + ## 0.5.1 — 2026-10-15 - An icon: the program, the installer, Settings → Apps, the Start menu and the Claude Desktop extension. diff --git a/README.md b/README.md index 9a8fb44..233b50e 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,13 @@ The install package is not part of the repository (`dist\` is excluded by `.giti **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 +it (Product Owner / developer / everything) and, when it finds release stages and branches, how your environments are +called (for you or the whole team); it installs the skills and registers ADO Lens in Claude. The language (Polish or +English) is chosen at the start and kept for the settings; 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). -After installing, the Start menu has an **ADO Lens** folder: *settings* (mode, environments, another connection — -`adolens setup` in a console), *status* (is everything working) and the skills for Claude Desktop; `adolens` works in +After installing, the Start menu has an **ADO Lens** folder: *settings* (a menu: mode, environments — change, add, +remove, for you or the team —, add or remove a connection, status, language), *status* (is everything working) and the skills for Claude Desktop; `adolens` works in any new terminal. Uninstall from *Settings → Apps → ADO Lens* (or the Start menu): it removes the program, its Claude registrations and skills, and asks whether to remove the stored PATs and settings too (by default they stay). @@ -173,12 +174,12 @@ never a permission. | Tool set | Tools | po | dev | full | |---|---|:-:|:-:|:-:| | `core` | `ado_status`, `ado_settings`, `ado_upgrade`, `ado_fields` | ✓ | ✓ | ✓ | -| `work` | `ado_work_item`, `ado_query`, `ado_create`, `ado_update`, `ado_comment`, `ado_link`, `ado_search`, `ado_undo` | ✓ | ✓ | ✓ | +| `work` | `ado_work_item`, `ado_query`, `ado_create`, `ado_update`, `ado_comment`, `ado_link`, `ado_search`, `ado_undo`, `ado_attachments`, `ado_create_tree` | ✓ | ✓ | ✓ | | `pr` | `ado_pr`, `ado_pr_update` | – | ✓ | ✓ | | `pipelines` | `ado_pipeline`, `ado_pipeline_run` | – | ✓ | ✓ | -| `boards` | `ado_sprint`, `ado_board`, `ado_backlog`, `ado_my_day`, `ado_health`, `ado_release_notes` | ✓ | – | ✓ | +| `boards` | `ado_sprint`, `ado_board`, `ado_backlog`, `ado_my_day`, `ado_health`, `ado_release_notes`, `ado_metrics` | ✓ | – | ✓ | | `wiki` | `ado_wiki`, `ado_wiki_write` | ✓ | ✓ | ✓ | -| `trace` | `ado_trace`, `ado_changes`, `ado_file` | ✓ | ✓ | ✓ | +| `trace` | `ado_trace`, `ado_changes`, `ado_file`, `ado_label`, `ado_review` | ✓ | ✓ | ✓ | | `recipes` | `ado_recipe` | ✓ | ✓ | ✓ | | `test` | `ado_tests`, `ado_test_create` | ✓ | – | ✓ | | `test`, `recipes` | planned | ✓ | (recipes) | ✓ | @@ -213,6 +214,11 @@ What each MCP tool answers, when Claude should reach for it, and the CLI command | `ado_wiki_write` | `create` a page, `append` / `prepend` to it, or `replace` it with the version from the last full read — a page changed by someone else in the meantime is never overwritten; optional change comment | release notes, keeping docs current | `adolens wiki-write "Team/Release notes" --file notes.md --mode append` | | `ado_tests` | the tests of work items (ids) or of a sprint's requirements and bugs: their Test Cases with the latest outcome per plan, suite and configuration (who, when), items without tests; `detail=full` adds the steps | "is it tested", sprint review | `adolens tests 123`, `adolens tests` (current sprint) | | `ado_test_create` | Test Cases for a work item from markdown (`## title`, steps `1. action => expected`), linked as tested-by, in the item's area and iteration; `plan` + `suite` (id or name) add them to a suite | turning acceptance criteria into tests | `adolens test-create 123 --file cases.md --plan R1 --suite Login` | +| `ado_attachments` | the files of a work item and the screenshots pasted into its text or comments; `file` shows an image to Claude or gives the text of a Word, Excel, Outlook e-mail (.msg) or text file; `add` attaches a local file | specifications and e-mails attached to items, "look at the screenshot" | `adolens attachments 123`, `adolens attachments 123 --file 2` | +| `ado_create_tree` | a tree of work items from an indented list (`- Feature: …`, children indented, `\| description`, `@person`), under an existing item if given; previewed first, one undo removes it all | breaking a feature into requirements and tasks | — | +| `ado_metrics` | done items and estimate per finished sprint (velocity), cycle and lead time (median, 85%), what is in progress longest | "are we getting faster", planning | `adolens metrics --sprints 6` | +| `ado_label` | a D365 message pasted as seen (values in place of %1 %2 … are fine) or `@File:Id`: the label, its text in every language (missing ones flagged) and the objects, X++ methods, fields and controls using it | "where does this error come from" | `adolens label "Item 1234: received 12, on hand 5"` | +| `ado_review` | checks of a change, or of every change of a work item and its tasks, before testing: work item linked, labels added or used exist in every required language (`config --label-languages`), X++ messages from labels, no TODO left | before moving to test, PR review | `adolens review 523`, `adolens review --item 123` | | `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` | @@ -221,6 +227,29 @@ What each MCP tool answers, when Claude should reach for it, and the CLI command | `ado_undo` | reverts the last change ADO Lens made: field values back (fields someone changed since are left and named), comment deleted, link removed or added back, a created item set to its removed state; always a preview first; `list=true` shows recent changes | "undo that", a mistaken mass change | `adolens undo --list`, `adolens undo` | | `ado_recipe` | saved repeatable changes: steps with a WIQL query (or ids) and what to do with each item - fields, state, iteration `next`, tags, a comment, a child item from a template (`{title}`, `{id}`); `{parameters}`; every run previewed, then confirmed | "close the sprint", "set up a new customer" | `adolens recipe run close-sprint --param sprint="Demo\Sprint 6"`, `--yes` for Task Scheduler | +### Ready commands (prompts) and skills + +**Prompts** work without installing anything more: in Claude Desktop under "+" → *ado-lens*, in Claude Code as +`/mcp__ado-lens__`. Each one runs the matching skill with your request: + +| Prompt | What it does | +|---|---| +| `verify` | was a work item done as asked, and where is it (DEV / UAT / PROD) | +| `explain` | how a feature works and why, in business language, with sources | +| `story` | writes a requirement, bug or task, creates it after you agree | +| `sprint` | sprint status, review, release notes | +| `refine` | backlog refinement: what is not ready, proposals, questions | +| `test` | test cases from the acceptance criteria, test status | +| `wiki` | a documentation page from work items and code | +| `label` | where a D365 message comes from and what it means | +| `dev_start`, `dev_pr`, `dev_build`, `dev_why` | developer: start a work item, PR description or review, a red build, why the code is like this | + +**Skills** (`skills\`) are the same instructions that Claude picks by itself when the request fits. Claude Code loads +every installed skill's description in each session, so the installer puts there only the skills of the mode +(`po-*` for a Product Owner, `dev-*` for a developer, all for everything; `adolens skills` after changing the mode does it +again). Claude Desktop gets ZIPs of all of them in `skills-for-claude-desktop` to upload under *Settings → Capabilities → +Skills*. + ### Writing rules built in Created content is checked against soft limits (informative, never blocking): a requirement's description up to about @@ -268,7 +297,7 @@ build.ps1 compile; -Deploy replaces the installed binaries, -T 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) +skills\ skills for Claude (po-verify, po-story, po-sprint, po-explain, po-refine, po-test, po-wiki, dev-start, dev-pr, dev-build, dev-why) 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 @@ -318,6 +347,7 @@ regular expression per line, `!word` for allowed words. Without a list the relea | `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` | +| `LabelTests.cs`, `AttachmentTests.cs`, `WorkTreeTests.cs`, `PromptTests.cs` | labels from pasted messages (any number of %n), label files, where a label is used, review of added X++ lines; Word, Excel and image attachments; work item trees from indented lists with preview and one undo; prompts with their skills, the skills of each mode in Claude Code | | `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 e00ef9e..b602d57 100644 --- a/README.pl.md +++ b/README.pl.md @@ -47,12 +47,14 @@ Paczki instalacyjnej nie ma w repozytorium (`dist\` jest pomijany przez `.gitign **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 +Owner / programista / wszystko) oraz — gdy znajdzie etapy release'ów i gałęzie — jak nazywacie środowiska (dla ciebie +albo całego zespołu); instaluje skille i rejestruje ADO Lens w Claudzie. Język (polski lub angielski) wybierasz na +starcie i zostaje też dla ustawień; 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). -Po instalacji w menu Start jest folder **ADO Lens**: *ustawienia* (tryb, środowiska, kolejne połączenie — `adolens setup` -w konsoli), *status* (czy wszystko działa) i skille dla Claude Desktop; `adolens` działa w każdym nowym terminalu. +Po instalacji w menu Start jest folder **ADO Lens**: *ustawienia* (menu: tryb, środowiska — zmiana, dodanie, usunięcie, +dla ciebie lub zespołu —, dodanie lub usunięcie połączenia, status, język), *status* (czy wszystko działa) i skille dla Claude Desktop; `adolens` działa w każdym nowym terminalu. Odinstalowanie: *Ustawienia → Aplikacje → ADO Lens* (albo z menu Start) — usuwa program, jego rejestracje w Claudzie i skille, i pyta, czy usunąć też zapisane PAT-y i ustawienia (domyślnie zostają). @@ -175,12 +177,12 @@ Nigdy nie jest uprawnieniem. | Zestaw | Narzędzia | po | dev | full | |---|---|:-:|:-:|:-:| | `core` | `ado_status`, `ado_settings`, `ado_upgrade`, `ado_fields` | ✓ | ✓ | ✓ | -| `work` | `ado_work_item`, `ado_query`, `ado_create`, `ado_update`, `ado_comment`, `ado_link`, `ado_search`, `ado_undo` | ✓ | ✓ | ✓ | +| `work` | `ado_work_item`, `ado_query`, `ado_create`, `ado_update`, `ado_comment`, `ado_link`, `ado_search`, `ado_undo`, `ado_attachments`, `ado_create_tree` | ✓ | ✓ | ✓ | | `pr` | `ado_pr`, `ado_pr_update` | – | ✓ | ✓ | | `pipelines` | `ado_pipeline`, `ado_pipeline_run` | – | ✓ | ✓ | -| `boards` | `ado_sprint`, `ado_board`, `ado_backlog`, `ado_my_day`, `ado_health`, `ado_release_notes` | ✓ | – | ✓ | +| `boards` | `ado_sprint`, `ado_board`, `ado_backlog`, `ado_my_day`, `ado_health`, `ado_release_notes`, `ado_metrics` | ✓ | – | ✓ | | `wiki` | `ado_wiki`, `ado_wiki_write` | ✓ | ✓ | ✓ | -| `trace` | `ado_trace`, `ado_changes`, `ado_file` | ✓ | ✓ | ✓ | +| `trace` | `ado_trace`, `ado_changes`, `ado_file`, `ado_label`, `ado_review` | ✓ | ✓ | ✓ | | `recipes` | `ado_recipe` | ✓ | ✓ | ✓ | | `test` | `ado_tests`, `ado_test_create` | ✓ | – | ✓ | | `test`, `recipes` | w planie | ✓ | (recipes) | ✓ | @@ -215,6 +217,11 @@ Na co odpowiada każde narzędzie MCP, kiedy Claude powinien po nie sięgnąć i | `ado_wiki_write` | `create` — nowa strona, `append` / `prepend` — dopisanie, `replace` — zastąpienie z wersją z ostatniego pełnego odczytu (strony zmienionej w międzyczasie przez kogoś innego nigdy nie nadpisze); opcjonalny komentarz zmiany | notatki wydania, aktualna dokumentacja | `adolens wiki-write "Team/Release notes" --file notes.md --mode append` | | `ado_tests` | testy work itemów (id) albo wymagań i błędów sprintu: ich Test Case'y z ostatnim wynikiem w każdym planie, zestawie i konfiguracji (kto, kiedy), elementy bez testów; `detail=full` dodaje kroki | „czy to przetestowane”, przegląd sprintu | `adolens tests 123`, `adolens tests` (bieżący sprint) | | `ado_test_create` | Test Case'y dla work itemu z markdownu (`## tytuł`, kroki `1. akcja => oczekiwany wynik`), podpięte jako tested-by, w obszarze i iteracji elementu; `plan` + `suite` (id albo nazwa) dodają je do zestawu | kryteria akceptacji zamienione w testy | `adolens test-create 123 --file przypadki.md --plan R1 --suite Logowanie` | +| `ado_attachments` | pliki work itemu i zrzuty ekranu wklejone w jego treść lub komentarze; `file` pokazuje obraz Claude'owi albo daje tekst pliku Word, Excel, maila Outlooka (.msg) lub tekstowego; `add` dołącza plik z dysku | specyfikacje i maile przy elementach, „zobacz zrzut” | `adolens attachments 123`, `adolens attachments 123 --file 2` | +| `ado_create_tree` | drzewo work itemów z listy z wcięciami (`- Feature: …`, dzieci wcięte, `\| opis`, `@osoba`), opcjonalnie pod istniejącym elementem; najpierw podgląd, jedno cofnięcie usuwa całość | rozbicie feature'a na wymagania i taski | — | +| `ado_metrics` | zrobione elementy i szacunek w zakończonych sprintach (prędkość), czas realizacji (mediana, 85%), co najdłużej w toku | „czy przyspieszamy”, planowanie | `adolens metrics --sprints 6` | +| `ado_label` | komunikat D365 wklejony tak, jak widać na ekranie (z wartościami zamiast %1 %2 …) albo `@Plik:Id`: etykieta, jej tekst w każdym języku (brakujące oznaczone) i obiekty, metody X++, pola i kontrolki, które jej używają | „skąd ten błąd” | `adolens label "Towar 1234: przyjęto 12, stan 5"` | +| `ado_review` | sprawdzenie zmiany albo wszystkich zmian work itemu i jego tasków przed testami: powiązany work item, etykiety we wszystkich wymaganych językach (`config --label-languages`), komunikaty X++ z etykiet, brak TODO | przed przekazaniem do testów, przegląd PR | `adolens review 523`, `adolens review --item 123` | | `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` | @@ -223,6 +230,28 @@ Na co odpowiada każde narzędzie MCP, kiedy Claude powinien po nie sięgnąć i | `ado_undo` | cofa ostatnią zmianę zrobioną przez ADO Lens: przywraca wartości pól (pola zmienione w międzyczasie przez innych zostają i są wymienione), usuwa komentarz, usuwa lub przywraca link, utworzony element przestawia w stan usunięty; zawsze najpierw podgląd; `list=true` pokazuje ostatnie zmiany | „cofnij to”, pomyłka przy zmianie wsadowej | `adolens undo --list`, `adolens undo` | | `ado_recipe` | zapisane powtarzalne zmiany: kroki z zapytaniem WIQL (albo id) i tym, co zrobić z każdym elementem - pola, stan, iteracja `next`, tagi, komentarz, element podrzędny z szablonu (`{title}`, `{id}`); `{parametry}`; każde uruchomienie najpierw podgląd, potem potwierdzenie | „zamknij sprint”, „nowy klient” | `adolens recipe run close-sprint --param sprint="Demo\Sprint 6"`, `--yes` dla Harmonogramu zadań | +### Gotowe polecenia (prompty) i skille + +**Prompty** działają bez instalowania czegokolwiek więcej: w Claude Desktop pod „+” → *ado-lens*, w Claude Code jako +`/mcp__ado-lens__`. Każdy uruchamia odpowiedni skill z twoją prośbą: + +| Prompt | Co robi | +|---|---| +| `verify` | czy work item zrobiono tak, jak chciano, i gdzie jest (DEV / UAT / PROD) | +| `explain` | jak działa funkcjonalność i dlaczego, językiem biznesu, ze źródłami | +| `story` | pisze wymaganie, błąd lub task, zakłada po twojej zgodzie | +| `sprint` | stan sprintu, przegląd, notatki wydania | +| `refine` | refinement backlogu: co niegotowe, propozycje, pytania | +| `test` | przypadki testowe z kryteriów akceptacji, stan testów | +| `wiki` | strona dokumentacji z work itemów i kodu | +| `label` | skąd pochodzi komunikat D365 i co oznacza | +| `dev_start`, `dev_pr`, `dev_build`, `dev_why` | programista: start pracy, opis lub przegląd PR, czerwony build, dlaczego kod tak wygląda | + +**Skille** (`skills\`) to te same instrukcje, po które Claude sięga sam, gdy prośba pasuje. Claude Code wczytuje opis +każdego zainstalowanego skilla w każdej sesji, dlatego instalator kopiuje tam tylko skille trybu (`po-*` dla Product +Ownera, `dev-*` dla programisty, wszystkie dla „wszystko”; po zmianie trybu robi to `adolens skills`). Claude Desktop +dostaje ZIP-y wszystkich w `skills-for-claude-desktop` do wgrania w *Settings → Capabilities → Skills*. + ### Wbudowane zasady pisania Tworzona treść jest sprawdzana pod kątem miękkich limitów (informacyjnie, nigdy nie blokuje): opis wymagania do ok. 120 @@ -270,7 +299,7 @@ build.ps1 kompilacja; -Deploy podmienia binaria w instalacji, 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) +skills\ skille dla Claude'a (po-verify, po-story, po-sprint, po-explain, po-refine, po-test, po-wiki, dev-start, dev-pr, dev-build, dev-why) 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 @@ -320,6 +349,7 @@ i publikuje go razem z `.sha256` i opisem z `CHANGELOG.md` (podgląd: `.\release | `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` | +| `LabelTests.cs`, `AttachmentTests.cs`, `WorkTreeTests.cs`, `PromptTests.cs` | etykiety z wklejonych komunikatów (dowolna liczba %n), pliki etykiet, gdzie etykieta jest użyta, przegląd dodanych linii X++; załączniki Word, Excel i obrazy; drzewa work itemów z list z wcięciami, z podglądem i jednym cofnięciem; prompty z ich skillami, skille trybu w Claude Code | | `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/install.ps1 b/install.ps1 index c64f904..8bdac42 100644 --- a/install.ps1 +++ b/install.ps1 @@ -120,7 +120,7 @@ Head '4/6 Who uses it and where code goes' $canAsk = [Environment]::UserInteractive -and $Host.Name -ne 'Default Host' -and -not ([Environment]::GetCommandLineArgs() | Where-Object { $_ -like '-NonI*' }) if ($canAsk) { - $setupArgs = @('setup') + $setupArgs = @('setup', '--wizard') if ($Mode) { $setupArgs += @('--mode', $Mode) } & $exe @setupArgs } @@ -132,23 +132,17 @@ Head '5/6 Skills for Claude' # 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" + # Claude Code loads every installed skill in each session: only the skills of the mode go there. + Say (& $exe skills | Out-String).Trim() + Say "Claude Desktop: upload the ZIPs you want from $desktopZips in Settings -> Capabilities -> Skills" } else { Say 'no skills in this package' } diff --git a/packaging/inno/ado-lens.iss b/packaging/inno/ado-lens.iss index e7039f8..24b5832 100644 --- a/packaging/inno/ado-lens.iss +++ b/packaging/inno/ado-lens.iss @@ -42,7 +42,8 @@ WizardStyle=modern UninstallDisplayName=ADO Lens UninstallDisplayIcon={app}\bin\adolens.exe LicenseFile={#Source}\LICENSE -ShowLanguageDialog=auto +; Users choose: Polish or English (the choice is kept for the settings console too). +ShowLanguageDialog=yes CloseApplications=no VersionInfoVersion={#Version} @@ -53,8 +54,6 @@ 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 [Icons] ; Start menu: settings and status open in a console that stays open (adolens commands work there too). @@ -74,13 +73,37 @@ Filename: "{app}\bin\adolens.exe"; Parameters: "unregister"; Flags: runhidden wa [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 +Filename: "{cm:ReadmeUrl}"; Description: "{cm:OpenReadme}"; Flags: postinstall shellexec skipifsilent unchecked [CustomMessages] +en.EnvTitle=Environments +en.EnvSub=Where code goes before it reaches production +en.EnvText=ADO Lens found these release stages and branches. Name each environment the way your team says it (for example UAT (sbx03)); leave a field empty to skip it. +en.EnvDetecting=Looking for release stages and branches... +en.EnvNone=No release stages or long-lived branches were found. Claude will ask about environments when it needs them. +en.EnvExisting=Environments are already set: %1 (%2). +en.EnvLater=Later: Start menu > ADO Lens - settings > Environments (change, add, remove). +en.EnvMine=Save for me only (this computer) +en.EnvTeam=Save for the whole team (wiki page "ADO Lens" of the project) +en.EnvSkip=Skip - Claude asks when needed +en.EnvFailed=Saving the environments did not work:%n%n%1 +en.ReadmeUrl=https://github.com/willowdev-code/ado-lens#readme en.IconSettings=ADO Lens - settings en.IconStatus=ADO Lens - status en.IconSkills=ADO Lens - skills for Claude Desktop en.RemoveData=Also remove the stored personal access tokens (Windows Credential Manager) and the settings and cache of ADO Lens?%n%nChoose No to keep them for a later installation. +pl.EnvTitle=Środowiska +pl.EnvSub=Gdzie trafia kod, zanim dojdzie na produkcję +pl.EnvText=ADO Lens znalazł te etapy release'ów i gałęzie. Nazwij każde środowisko tak, jak mówi o nim zespół (np. UAT (sbx03)); puste pole = pomiń. +pl.EnvDetecting=Szukam etapów release'ów i gałęzi... +pl.EnvNone=Nie znaleziono etapów release'ów ani gałęzi. Claude zapyta o środowiska, gdy będą potrzebne. +pl.EnvExisting=Środowiska są już ustawione: %1 (%2). +pl.EnvLater=Później: menu Start > ADO Lens - ustawienia > Środowiska (zmiana, dodanie, usunięcie). +pl.EnvMine=Zapisz tylko dla mnie (ten komputer) +pl.EnvTeam=Zapisz dla całego zespołu (strona wiki "ADO Lens" w projekcie) +pl.EnvSkip=Pomiń - Claude zapyta, gdy będzie trzeba +pl.EnvFailed=Nie udało się zapisać środowisk:%n%n%1 +pl.ReadmeUrl=https://github.com/willowdev-code/ado-lens/blob/main/README.pl.md pl.IconSettings=ADO Lens - ustawienia pl.IconStatus=ADO Lens - status pl.IconSkills=ADO Lens - skille dla Claude Desktop @@ -140,6 +163,13 @@ var PatPage: TInputQueryWizardPage; ModePage: TInputOptionWizardPage; HasConnection: Boolean; + EnvPage: TWizardPage; + EnvInfo, EnvLater: TNewStaticText; + EnvKeys: TArrayOfString; + EnvLabels: array of TNewStaticText; + EnvEdits: array of TNewEdit; + EnvMine, EnvTeam, EnvSkip: TNewRadioButton; + EnvDetected: Boolean; function SetEnvironmentVariable(lpName: String; lpValue: String): Boolean; external 'SetEnvironmentVariableW@kernel32.dll stdcall'; @@ -179,6 +209,74 @@ begin RegWriteExpandStringValue(HKCU, 'Environment', 'Path', Kept); end; +function HasClaudeCode: Boolean; +begin + Result := DirExists(ExpandConstant('{%USERPROFILE}\.claude')); +end; + +{ The mode saved in the settings (po when unknown). } +function SavedMode: String; +var + Text: AnsiString; + S: String; +begin + Result := 'po'; + if not LoadStringFromFile(ExpandConstant('{localappdata}\ado-lens\adolens.json'), Text) then Exit; + S := Lowercase(String(Text)); + if Pos('"mode": "dev"', S) > 0 then Result := 'dev' + else if Pos('"mode": "full"', S) > 0 then Result := 'full'; +end; + +{ Claude Code loads the description of every installed skill in each session, so only the skills of the mode go + there: po-* for a Product Owner, dev-* for a developer, all for everything. Claude Desktop gets ZIPs of all. } +procedure InstallCodeSkills(Mode: String); +var + F: TFindRec; + Src, Dst, Name: String; +begin + if not HasClaudeCode then Exit; + Src := ExpandConstant('{app}\skills'); + if not FindFirst(Src + '\*', F) then Exit; + try + repeat + Name := F.Name; + if (F.Attributes and FILE_ATTRIBUTE_DIRECTORY <> 0) and (Name <> '.') and (Name <> '..') then + begin + Dst := ExpandConstant('{%USERPROFILE}\.claude\skills\') + Name; + if (Mode = 'full') or ((Mode = 'dev') and (Copy(Name, 1, 4) = 'dev-')) or ((Mode = 'po') and (Copy(Name, 1, 3) = 'po-')) then + begin + ForceDirectories(Dst); + FileCopy(Src + '\' + Name + '\SKILL.md', Dst + '\SKILL.md', False); + end + else if FileExists(Dst + '\SKILL.md') then DelTree(Dst, True, True, True); + end; + until not FindNext(F); + finally + FindClose(F); + end; +end; + +{ Uninstall: the skills of this program in Claude Code. } +procedure RemoveCodeSkills; +var + F: TFindRec; + Src, Dst: String; +begin + Src := ExpandConstant('{app}\skills'); + if not FindFirst(Src + '\*', F) then Exit; + try + repeat + if (F.Attributes and FILE_ATTRIBUTE_DIRECTORY <> 0) and (F.Name <> '.') and (F.Name <> '..') then + begin + Dst := ExpandConstant('{%USERPROFILE}\.claude\skills\') + F.Name; + if FileExists(Dst + '\SKILL.md') then DelTree(Dst, True, True, True); + end; + until not FindNext(F); + finally + FindClose(F); + end; +end; + var RemoveUserData: Boolean; @@ -194,6 +292,7 @@ begin (MsgBox(CustomMessage('RemoveData'), mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES); if RemoveUserData then Exec(ExpandConstant('{app}\bin\adolens.exe'), 'logout --all', '', SW_HIDE, ewWaitUntilTerminated, Code); + RemoveCodeSkills; end else if CurUninstallStep = usPostUninstall then begin @@ -202,10 +301,6 @@ begin end; end; -function HasClaudeCode: Boolean; -begin - Result := DirExists(ExpandConstant('{%USERPROFILE}\.claude')); -end; function SettingsFile: String; begin @@ -278,11 +373,149 @@ begin ModePage.Add(CustomMessage('ModeDev')); ModePage.Add(CustomMessage('ModeFull')); ModePage.SelectedValueIndex := 0; + + EnvPage := CreateCustomPage(ModePage.ID, CustomMessage('EnvTitle'), CustomMessage('EnvSub')); + EnvInfo := TNewStaticText.Create(EnvPage); + EnvInfo.Parent := EnvPage.Surface; + EnvInfo.AutoSize := False; + EnvInfo.WordWrap := True; + EnvInfo.Width := EnvPage.SurfaceWidth; + EnvInfo.Height := ScaleY(40); + EnvInfo.Caption := CustomMessage('EnvDetecting'); + EnvMine := TNewRadioButton.Create(EnvPage); + EnvTeam := TNewRadioButton.Create(EnvPage); + EnvSkip := TNewRadioButton.Create(EnvPage); + EnvMine.Parent := EnvPage.Surface; + EnvTeam.Parent := EnvPage.Surface; + EnvSkip.Parent := EnvPage.Surface; + EnvMine.Caption := CustomMessage('EnvMine'); + EnvTeam.Caption := CustomMessage('EnvTeam'); + EnvSkip.Caption := CustomMessage('EnvSkip'); + EnvMine.Width := EnvPage.SurfaceWidth; + EnvTeam.Width := EnvPage.SurfaceWidth; + EnvSkip.Width := EnvPage.SurfaceWidth; + EnvMine.Checked := True; + EnvMine.Visible := False; + EnvTeam.Visible := False; + EnvSkip.Visible := False; + EnvLater := TNewStaticText.Create(EnvPage); + EnvLater.Parent := EnvPage.Surface; + EnvLater.AutoSize := False; + EnvLater.WordWrap := True; + EnvLater.Width := EnvPage.SurfaceWidth; + EnvLater.Height := ScaleY(30); + EnvLater.Caption := CustomMessage('EnvLater'); + EnvLater.Top := EnvPage.SurfaceHeight - EnvLater.Height; +end; + +{ Fills the environment page from "adolens setup --detect": the map already in use, or one row per stage or branch + found with a suggested name. } +procedure DetectEnvironments; +var + Exe, OutFile, Line, Key, Guess: String; + Lines: TArrayOfString; + I, P, N, Top: Integer; + Code: Integer; +begin + EnvDetected := True; + Exe := ExpandConstant('{tmp}\adolens.exe'); + OutFile := ExpandConstant('{tmp}\adolens-envs.txt'); + DeleteFile(OutFile); + Exec(Exe, 'setup --detect --out "' + OutFile + '"', '', SW_HIDE, ewWaitUntilTerminated, Code); + if not LoadStringsFromFile(OutFile, Lines) then SetArrayLength(Lines, 0); + N := 0; + SetArrayLength(EnvKeys, 0); + for I := 0 to GetArrayLength(Lines) - 1 do + begin + Line := Lines[I]; + if Copy(Line, 1, 9) = 'existing' + #9 then + begin + Delete(Line, 1, 9); + P := Pos(#9, Line); + EnvInfo.Caption := FmtMessage(CustomMessage('EnvExisting'), [Copy(Line, 1, P - 1), Copy(Line, P + 1, Length(Line))]); + Exit; + end; + if (Copy(Line, 1, 4) = 'row' + #9) and (N < 8) then + begin + Delete(Line, 1, 4); + P := Pos(#9, Line); + if P > 0 then + begin + Key := Copy(Line, 1, P - 1); + Guess := Copy(Line, P + 1, Length(Line)); + end + else + begin + Key := Line; + Guess := ''; + end; + N := N + 1; + SetArrayLength(EnvKeys, N); + SetArrayLength(EnvLabels, N); + SetArrayLength(EnvEdits, N); + EnvKeys[N - 1] := Key; + Top := EnvInfo.Top + EnvInfo.Height + ScaleY(4) + (N - 1) * ScaleY(26); + EnvLabels[N - 1] := TNewStaticText.Create(EnvPage); + EnvLabels[N - 1].Parent := EnvPage.Surface; + EnvLabels[N - 1].Caption := Key; + EnvLabels[N - 1].Top := Top + ScaleY(3); + EnvLabels[N - 1].Width := EnvPage.SurfaceWidth div 2 - ScaleX(8); + EnvEdits[N - 1] := TNewEdit.Create(EnvPage); + EnvEdits[N - 1].Parent := EnvPage.Surface; + EnvEdits[N - 1].Left := EnvPage.SurfaceWidth div 2; + EnvEdits[N - 1].Top := Top; + EnvEdits[N - 1].Width := EnvPage.SurfaceWidth div 2; + EnvEdits[N - 1].Text := Guess; + end; + end; + if N = 0 then + begin + EnvInfo.Caption := CustomMessage('EnvNone'); + Exit; + end; + EnvInfo.Caption := CustomMessage('EnvText'); + Top := EnvInfo.Top + EnvInfo.Height + ScaleY(10) + N * ScaleY(26); + EnvMine.Top := Top; + EnvTeam.Top := Top + ScaleY(20); + EnvSkip.Top := Top + ScaleY(40); + EnvMine.Visible := True; + EnvTeam.Visible := True; + EnvSkip.Visible := True; +end; + +{ Saves the names typed on the environment page; returns False to stay on the page when it failed. } +function SaveEnvironments: Boolean; +var + Env, Name, Scope, Output: String; + I, Code: Integer; +begin + Result := True; + if EnvSkip.Checked or (GetArrayLength(EnvKeys) = 0) then Exit; + Env := ''; + for I := 0 to GetArrayLength(EnvKeys) - 1 do + begin + Name := Trim(EnvEdits[I].Text); + StringChangeEx(Name, '"', '', True); + StringChangeEx(Name, ';', ',', True); + if Name <> '' then + begin + if Env <> '' then Env := Env + '; '; + Env := Env + EnvKeys[I] + '=' + Name; + end; + end; + if Env = '' then Exit; + if EnvTeam.Checked then Scope := 'team' else Scope := 'local'; + Code := RunAdoLens(ExpandConstant('{tmp}\adolens.exe'), 'setup --env "' + Env + '" --scope ' + Scope, Output); + if Code <> 0 then + begin + MsgBox(FmtMessage(CustomMessage('EnvFailed'), [Output]), mbError, MB_OK); + Result := False; + end; end; function ShouldSkipPage(PageID: Integer): Boolean; begin - Result := HasConnection and ((PageID = ConnPage.ID) or (PageID = PatPage.ID) or (PageID = ModePage.ID)); + Result := HasConnection and ((PageID = ConnPage.ID) or (PageID = PatPage.ID) or (PageID = ModePage.ID) or (PageID = EnvPage.ID)); end; { The token is checked before anything is installed: adolens.exe runs from the temporary folder, signs in and keeps @@ -293,7 +526,9 @@ var Code: Integer; begin Result := True; - if CurPageID = ConnPage.ID then + if CurPageID = EnvPage.ID then + Result := SaveEnvironments + else if CurPageID = ConnPage.ID then begin if Trim(ConnPage.Values[0]) = '' then begin @@ -351,6 +586,8 @@ var begin if CurStep <> ssPostInstall then Exit; Exe := ExpandConstant('{app}\bin\adolens.exe'); + { The language chosen in the installer is kept for the settings console. } + if not WizardSilent then RunAdoLens(Exe, 'config --language ' + ActiveLanguage, Output); if not HasConnection then begin case ModePage.SelectedValueIndex of @@ -360,7 +597,9 @@ begin Mode := 'po'; end; RunAdoLens(Exe, 'config --mode ' + Mode, Output); - end; + end + else Mode := SavedMode; + InstallCodeSkills(Mode); { 7: Claude Desktop is running and would write its old configuration back. } Code := RunAdoLens(Exe, 'register', Output); while (Code = 7) and not WizardSilent do @@ -373,6 +612,16 @@ end; procedure CurPageChanged(CurPageID: Integer); begin + if (CurPageID = EnvPage.ID) and not EnvDetected then + begin + WizardForm.NextButton.Enabled := False; + WizardForm.Repaint; + try + DetectEnvironments; + finally + WizardForm.NextButton.Enabled := True; + end; + end; if CurPageID = wpFinished then WizardForm.FinishedLabel.Caption := WizardForm.FinishedLabel.Caption + #13#10#13#10 + CustomMessage('Registered'); end; diff --git a/skills/dev-build/SKILL.md b/skills/dev-build/SKILL.md new file mode 100644 index 0000000..d5b137e --- /dev/null +++ b/skills/dev-build/SKILL.md @@ -0,0 +1,22 @@ +--- +name: dev-build +description: Diagnoses a failed build or release for a developer - the failing step, the real error lines, the code behind them, and a proposed fix. Use when someone says "the build is red", "why did build 900 fail", "the pipeline failed", or "what broke the build". Needs the ado-lens MCP server. +--- + +# A red build + +## Steps + +1. `ado_pipeline` (recent runs; `definition` / `branch` to narrow) → the failed run; `ado_pipeline build=` for the + failed stage, job and steps with the error lines of the log (`detail=full` for more). +2. Separate the cause from the noise: the first real error, not the follow-up failures. +3. Find the code: the object or file named in the error (`ado_file`, X++: `part=`), and what changed since the + last green build of the same pipeline (`ado_pipeline` lists them; `ado_changes` of the changes between). +4. Who and what: `ado_file history=true` for the last change of that object and its work item. + +## Answer + +1. One sentence: what broke and why. +2. The error lines (short), the place in the code, the change that caused it (id, author, work item). +3. The fix - concrete code or setting; for data or environment problems, who has to act. +4. Offer to re-run after the fix: `ado_pipeline_run definition=… branch=…` - only when asked. diff --git a/skills/dev-pr/SKILL.md b/skills/dev-pr/SKILL.md new file mode 100644 index 0000000..bb29b9e --- /dev/null +++ b/skills/dev-pr/SKILL.md @@ -0,0 +1,28 @@ +--- +name: dev-pr +description: Writes a pull request description from its work item, or reviews a pull request against the work item's acceptance criteria and the project's checks. Use when a developer asks to "write the PR description", "open a PR for #123", "review PR 482", or "is this PR ready". Needs the ado-lens MCP server. +--- + +# Pull requests + +## Writing a description (work item given) + +1. `ado_work_item ids=` for the goal and criteria; `ado_changes` of the branch's commits or the PR for what + changed. +2. Description, ≤ ~150 words: + - **What and why** - 1–2 sentences with `#`, + - **Changes** - bullets by area (not by file), + - **How to check** - numbered test steps with data, + - **Risks** - only if any. +3. Create it only after the user agrees: `ado_pr_update action=create repo=… source=… target=… title=… text=… + workItems=`. + +## Reviewing (PR given) + +1. `ado_pr id=` - status, reviewers, checks, open threads; `ado_changes change="PR "` - files and X++ methods. +2. `ado_review change="PR "` - work item linked, labels in every language, messages from labels, TODO. +3. Read the diffs that matter (`ado_changes … file=a;b;c`), compare with each acceptance criterion of the work item. +4. Findings, most important first: **blocking** (a criterion not met, a bug), **should fix**, **nit**. Each with + file and method and a one-line reason. + +Comment or vote only when asked: `ado_pr_update action=comment file=… line=…` / `action=vote vote=…`. diff --git a/skills/dev-start/SKILL.md b/skills/dev-start/SKILL.md new file mode 100644 index 0000000..45d86e4 --- /dev/null +++ b/skills/dev-start/SKILL.md @@ -0,0 +1,26 @@ +--- +name: dev-start +description: Starts a developer's work on a work item - what is asked (criteria from the item and its parent, attachments), where in the code it lands, a plan per criterion, open questions. Use when a developer says "start #123", "what do I have to do in this task", "plan this story", or "where do I change this". Needs the ado-lens MCP server. +--- + +# Starting work on a work item + +The reader is a developer: technical and short - objects, methods, paths, ids. + +## Steps + +1. `ado_work_item ids= include=comments` and its parent (for a task the criteria are there); specifications, + e-mails and screenshots: `ado_attachments id=` (and on the parent). +2. Where it lands: `ado_search scope=code` with the domain words, `ado_file` for the objects found (X++: methods; + `part=` to read one), `ado_file history=true` for who touched them last and why. With xpp-lens on the + machine, prefer it for callers, references and extensions. +3. Earlier work on the same feature: `ado_search scope=work` and its `ado_trace` - reuse what exists. + +## Answer + +1. **Goal** in one sentence. +2. **Plan** per acceptance criterion: what to change, where (object.method), new objects, labels in every language. +3. **Open questions** - anything the criteria leave undecided, as precise questions to the PO (offer `ado_comment`). +4. **Risks** - shared code touched, data migration, security, performance - only if any. + +Change the state or the assignment only when asked (`ado_update state=… assignedTo=…`). diff --git a/skills/dev-why/SKILL.md b/skills/dev-why/SKILL.md new file mode 100644 index 0000000..8f1c585 --- /dev/null +++ b/skills/dev-why/SKILL.md @@ -0,0 +1,23 @@ +--- +name: dev-why +description: Explains why a piece of code is the way it is - the history of a file or X++ object, the changes that shaped it, the work items and decisions behind them. Use when a developer asks "why is this like this", "who changed this and why", "what was this for", or "can I remove this". Needs the ado-lens MCP server. +--- + +# Why the code is like this + +## Steps + +1. `ado_file path= history=true` - changes newest first, with pull requests and work items. + With xpp-lens on the machine, use it for callers and references of the method in question. +2. Pick the changes that touched the part asked about: `ado_changes change= file=` shows the method-level + diff of each. +3. Read the work items behind those changes (`ado_work_item`, comments for decisions; `ado_attachments` for + specifications) - that is the "why". + +## Answer + +1. One or two sentences: why it is like this, with the work item ("added in #4821 to block orders over the credit + limit"). +2. A short timeline of the changes that matter: date, author, change, work item, what it did. +3. For "can I remove it": what still depends on it (callers, settings, data) and who to ask. +4. "Simple version" on request: two sentences for a PO, without code. diff --git a/skills/po-explain/SKILL.md b/skills/po-explain/SKILL.md index d22143a..faf952c 100644 --- a/skills/po-explain/SKILL.md +++ b/skills/po-explain/SKILL.md @@ -12,9 +12,11 @@ Technical names (objects, methods, files) only in brackets, or when asked for "d 1. `ado_search text=` — work items, wiki pages and code at once. Try a second wording (a synonym, the English or the local term) when the first finds little. + A message from the screen ("why do I see …"): `ado_label text=` finds where it comes from. 2. Read the strongest sources, stop when the question is answered: - wiki pages: `ado_wiki path=`, - - work items: `ado_work_item ids=` — the requirement and its acceptance criteria say what was intended, + - work items: `ado_work_item ids=` — the requirement and its acceptance criteria say what was intended; + specifications and screenshots attached to them: `ado_attachments`, - code: `ado_file path=` (X++ objects show their methods; `part=` one method) — the code says what really happens. 3. "Why is it like this": `ado_file path= history=true` — who changed it, when and for which work items; read the diff --git a/skills/po-refine/SKILL.md b/skills/po-refine/SKILL.md new file mode 100644 index 0000000..2fe8841 --- /dev/null +++ b/skills/po-refine/SKILL.md @@ -0,0 +1,29 @@ +--- +name: po-refine +description: Prepares backlog refinement for a Product Owner - what is not ready for planning (no acceptance criteria, no estimate, too big, unclear), concrete fixes and questions for the team, splits of large items. Use when someone asks to "prepare refinement", "clean up the backlog", "what is not ready for the sprint", "split this story", or "what should we ask the team". Needs the ado-lens MCP server. +--- + +# Backlog refinement + +The reader is a Product Owner: plain language, in the user's language; ids in brackets. + +## Steps + +1. `ado_health` (the team's open backlog) — items without acceptance criteria, description, estimate or parent, + stale ones, left in finished sprints, look-alike titles. +2. `ado_backlog top=20` — the top of the backlog is what planning takes next; focus there first. +3. For the top items that are not ready, read them (`ado_work_item ids=…`, files with `ado_attachments`) and decide: + - **missing criteria** → propose 2–5 testable criteria from the description, + - **too big** (more than ~7 criteria, several user goals, "and" in the title) → propose a split into smaller + requirements, each with its own goal; `ado_create_tree` can create them later, + - **unclear** → a precise question for the team or the requester (who decides, what exactly), + - **duplicates** → which one to keep. + +## Answer + +1. One sentence: how many of the top items are ready for planning. +2. A table: item | what is missing | proposal (criteria, split, question). +3. Questions for the refinement meeting, grouped by person or topic. + +Change nothing on your own. After the user agrees: `ado_update` for criteria, `ado_create_tree` for splits, +`ado_comment` with questions (`@[name]` to notify). diff --git a/skills/po-sprint/SKILL.md b/skills/po-sprint/SKILL.md index 0c03d4f..c5b082f 100644 --- a/skills/po-sprint/SKILL.md +++ b/skills/po-sprint/SKILL.md @@ -17,12 +17,13 @@ names only in brackets. - **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". +3. `ado_board summary` when they ask about the board or WIP limits; `ado_my_day` for "what is new for me"; + `ado_metrics` for "are we getting faster", velocity, cycle time and what is stuck longest. ## 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`. +1. `ado_release_notes` (the sprint, or `since`/`until` for a date range) gives the finished requirements and bugs + grouped by feature; rewrite the titles in user language. 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. diff --git a/skills/po-test/SKILL.md b/skills/po-test/SKILL.md new file mode 100644 index 0000000..6a1ab29 --- /dev/null +++ b/skills/po-test/SKILL.md @@ -0,0 +1,31 @@ +--- +name: po-test +description: Writes acceptance test cases from a work item's acceptance criteria and shows how its tests stand, for a Product Owner or tester. Use when someone asks to "write test cases", "prepare UAT scenarios", "how do I test this", "is this tested", or "what failed in testing". Needs the ado-lens MCP server. +--- + +# Acceptance tests + +Plain language, in the user's language; steps a business tester can follow on UAT. + +## Steps + +1. `ado_work_item ids=` — the acceptance criteria (from the parent for a task). No criteria: look at the + specification in `ado_attachments`, else ask the user what has to work. +2. `ado_tests ids=` — test cases that already exist and their last results; do not duplicate them. +3. One test case per criterion (more only for clearly different situations, e.g. limit exceeded / not exceeded): + - title: what is checked, as a short sentence, + - 3–8 steps, each `action => expected result`, with concrete example data (an item, a store, a quantity), + - where to run it (environment, company) when it matters. +4. Show the cases as markdown in exactly the form `ado_test_create` takes: + ``` + ## Order above the credit limit is blocked + 1. Open a sales order for customer US-001 => the order opens + 2. Add a line of 120 pcs => the total is above the limit + 3. Confirm the order => a message about the credit limit, the order stays unconfirmed + ``` + +## Afterwards + +Create them only after the user agrees: `ado_test_create id= cases=`, with `plan` and `suite` when the +user names the test plan. For "is it tested", answer from `ado_tests` (sprint: without ids): passed / failed / not +run per item, failures first, with who ran them and when. diff --git a/skills/po-verify/SKILL.md b/skills/po-verify/SKILL.md index b02f9d1..4c0349f 100644 --- a/skills/po-verify/SKILL.md +++ b/skills/po-verify/SKILL.md @@ -11,11 +11,14 @@ Technical names (objects, methods, files, ids of commits) only in brackets or wh ## Steps 1. `ado_trace id=` — what was asked (acceptance criteria), what code is linked, builds, where it is deployed, signals. - For a task, the criteria come from its parent; say so. + For a task, the criteria come from its parent; say so. No criteria in the item: look for the specification in + its files (`ado_attachments id=`, also on the parent) - a Word document or an e-mail - and screenshots. 2. For every criterion, look for evidence in the code — never assume: - `ado_changes change=` lists the changed objects with their methods, - `ado_changes … file=` shows what exactly changed (X++ per method, metadata such as fields and controls). Read only the files that can prove or disprove a criterion; stop when each one has an answer. + `ado_review id=` checks every change at once: work item linked, labels in every language, messages from + labels, TODO left - mention what it finds under "Watch out". 3. Judge each criterion: - ✅ **done** — the code clearly does it (name the place in brackets), - ⚠️ **to check by hand** — partly there, or only testable in the system (data, configuration, UI behaviour), diff --git a/skills/po-wiki/SKILL.md b/skills/po-wiki/SKILL.md new file mode 100644 index 0000000..2649e2e --- /dev/null +++ b/skills/po-wiki/SKILL.md @@ -0,0 +1,30 @@ +--- +name: po-wiki +description: Writes or updates a documentation page on the project wiki for a Product Owner - how a feature works, what a delivered change means for users, a process description - from work items, attachments and code. Use when someone asks to "document this", "write a wiki page", "update the documentation after this story", or "describe the process for new colleagues". Needs the ado-lens MCP server. +--- + +# A wiki page + +Write for the people who will read the page (users, testers, new colleagues): plain language, in the language the +wiki is written in (look at its other pages), short sections, examples. + +## Steps + +1. Facts first: + - the feature: `ado_search`, then the requirement and its criteria (`ado_work_item`), its specification and + screenshots (`ado_attachments`), what the code does (`ado_file`), + - a delivered change: `ado_trace id=` (what was asked, where it is) and `ado_release_notes`. +2. Where it goes: `ado_wiki` shows the page tree; find the section where similar pages live, or an existing page to + update (read it whole with `detail=full` first). +3. Draft the page: + - **What it is for** (2–3 sentences), + - **How it works** — numbered steps, with an example; a Mermaid diagram when there are decisions, + - **Settings** — where it is switched on or configured, + - **Good to know** — limits, exceptions, messages the user may see, + - **History** — the work items behind it (links). + Say what comes from the code and what from documents when they differ. + +## Afterwards + +Show the draft and the path. Save only after the user agrees: `ado_wiki_write mode=create` for a new page; for an +existing page `mode=append`, or `mode=replace` with the version from the last full read. diff --git a/src/AdoLens/AdoClient.cs b/src/AdoLens/AdoClient.cs index 53a8cba..5cd00d2 100644 --- a/src/AdoLens/AdoClient.cs +++ b/src/AdoLens/AdoClient.cs @@ -86,6 +86,41 @@ public async Task GetTextAsync(string url, CancellationToken ct = defaul } } + /// A file (work item attachment), at most . + public async Task GetBytesAsync(string url, long maxBytes, CancellationToken ct = default) + { + url = WithApiVersion(url); + if (Uri.TryCreate(url, UriKind.Absolute, out var abs) && !IsAzureDevOpsHost(abs)) + throw new AdoApiException($"refusing to send the PAT to {abs.Host}: only Azure DevOps hosts are allowed"); + for (var attempt = 1; ; attempt++) + { + using var req = new HttpRequestMessage(HttpMethod.Get, url); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/octet-stream")); + using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); + if (attempt < MaxAttempts && RetryDelay(res) is { } delay) + { + await Delay(delay, ct); + continue; + } + if (!res.IsSuccessStatusCode || res.Content.Headers.ContentType?.MediaType == "text/html") await EnsureSuccessAsync(res, ct); + if (res.Content.Headers.ContentLength > maxBytes) throw new AdoApiException($"the file is larger than {maxBytes / 1024 / 1024} MB"); + return await res.Content.ReadAsByteArrayAsync(ct); + } + } + + /// Uploads a file as a work item attachment; returns its URL. + public async Task UploadAttachmentAsync(string project, string fileName, byte[] content, CancellationToken ct = default) + { + var url = WithApiVersion($"{AdoClient.Scope(project)}_apis/wit/attachments?fileName={Uri.EscapeDataString(fileName)}"); + using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = new ByteArrayContent(content) }; + req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + using var res = await http.SendAsync(req, ct); + await EnsureSuccessAsync(res, ct); + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + return Json.Str(doc.RootElement, "url") ?? throw new AdoApiException("the attachment upload returned no URL"); + } + public Task PostAsync(string url, object body, CancellationToken ct = default) => SendAsync(HttpMethod.Post, url, body, "application/json", ct); diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index d08b4fa..63d19ee 100644 --- a/src/AdoLens/AdoLens.csproj +++ b/src/AdoLens/AdoLens.csproj @@ -23,7 +23,7 @@ ADO Lens for Azure DevOps Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP Copyright © 2026 WillowDev - 0.5.1 + 0.6.0 diff --git a/src/AdoLens/Attachments.cs b/src/AdoLens/Attachments.cs new file mode 100644 index 0000000..c705d01 --- /dev/null +++ b/src/AdoLens/Attachments.cs @@ -0,0 +1,207 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using System.Xml.Linq; + +namespace AdoLens; + +/// Files attached to work items: the list, their text (Word, Excel, plain text), and adding one. +public static class Attachments +{ + const long MaxDownload = 20 * 1024 * 1024; + const int MaxText = 12000; + const string Rel = "AttachedFile"; + + sealed record Attached(string Name, string Url, long? Size, string? Comment); + + const long MaxImage = 4 * 1024 * 1024; + + static readonly Dictionary ImageTypes = new(StringComparer.OrdinalIgnoreCase) + { + [".png"] = "image/png", [".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".gif"] = "image/gif", [".webp"] = "image/webp", + }; + + static List List(JsonElement wi) => + Json.Arr(wi, "relations").Where(r => Json.Str(r, "rel") == Rel).Select(r => + { + var a = Json.Obj(r, "attributes"); + return new Attached(Json.Str(a, "name") ?? "(no name)", Json.Str(r, "url") ?? "", + a.TryGetProperty("resourceSize", out var s) && s.TryGetInt64(out var n) ? n : null, Json.Str(a, "comment")); + }).ToList(); + + static readonly System.Text.RegularExpressions.Regex Img = + new(@"]*?\bsrc=""(?[^""]+/_apis/wit/attachments/[^""]+)""", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + /// Images pasted into the text fields and the comments (stored as attachments, but not listed with them). + static async Task> InlineImagesAsync(Session session, JsonElement wi, CancellationToken ct) + { + var found = new List(); + void From(string? html, string where) + { + if (string.IsNullOrEmpty(html)) return; + foreach (System.Text.RegularExpressions.Match m in Img.Matches(html)) + { + var url = System.Net.WebUtility.HtmlDecode(m.Groups["src"].Value); + if (found.Any(f => f.Url == url)) continue; + var name = System.Text.RegularExpressions.Regex.Match(url, @"fileName=(?[^&]+)") is { Success: true } n ? Uri.UnescapeDataString(n.Groups["n"].Value) : "image.png"; + found.Add(new Attached(name, url, null, $"pasted in {where}")); + } + } + var fields = Json.Obj(wi, "fields"); + if (fields.ValueKind == JsonValueKind.Object) + foreach (var f in fields.EnumerateObject().Where(f => f.Value.ValueKind == JsonValueKind.String && f.Value.GetString()!.Contains(" bytes is not { } b ? "" : b < 1024 ? $" ({b} B)" : b < 1024 * 1024 ? $" ({b / 1024} KB)" : $" ({b / 1024.0 / 1024:0.#} MB)"; + + /// + /// Without : the attachments of the item and the images pasted into it; with it (name or + /// number): the text of a document, or an image (returned as image bytes for the client to show to the model). + /// + public static async Task<(string Text, (byte[] Bytes, string Mime)? Image)> ReadAsync(Lens lens, string? connection, int id, string? file, string? detail, + CancellationToken ct = default) + { + var session = lens.For(connection); + var items = await session.Work.GetAsync([id], relations: true, ct: ct); + if (items.Count == 0) return ($"no work item #{id} visible with this PAT", null); + var list = List(items[0]).Concat(await InlineImagesAsync(session, items[0], ct)).ToList(); + var head = WorkItemFormat.Header(items[0]); + if (list.Count == 0) return ($"{head}: no attachments and no pasted images", null); + if (string.IsNullOrWhiteSpace(file)) + return ($"{head}: {list.Count} file(s)\n" + string.Join('\n', list.Select((a, i) => $"{i + 1}. {a.Name}{Size(a.Size)}{(a.Comment is { Length: > 0 } c ? $" - {c}" : "")}")) + + "\nfile= reads one: documents as text (Word, Excel, e-mail, text), images are shown.", null); + + var pick = int.TryParse(file, out var n) && n >= 1 && n <= list.Count ? list[n - 1] + : list.FirstOrDefault(a => a.Name.Equals(file.Trim(), StringComparison.OrdinalIgnoreCase)) + ?? list.FirstOrDefault(a => a.Name.Contains(file.Trim(), StringComparison.OrdinalIgnoreCase)); + if (pick is null) return ($"no file '{file}' on #{id}; files: {string.Join(", ", list.Select(a => a.Name))}", null); + var bytes = await session.Client.GetBytesAsync(pick.Url + (pick.Url.Contains('?') ? "&" : "?") + "download=true", MaxDownload, ct); + if (ImageTypes.TryGetValue(Path.GetExtension(pick.Name), out var mime) || ImageMime(bytes) is { } sniffed && (mime = sniffed) is not null) + { + if (bytes.Length > MaxImage) return ($"{pick.Name}{Size(bytes.Length)}: the image is too large to show (over {MaxImage / 1024 / 1024} MB)", null); + return ($"{pick.Name}{Size(bytes.Length)} from {head}{(pick.Comment is { Length: > 0 } where ? $" ({where})" : "")} - image attached", (bytes, mime)); + } + var text = Extract(pick.Name, bytes); + if (text is null) return ($"{pick.Name}{Size(bytes.Length)}: this kind of file cannot be read as text (open it from the work item)", null); + var full = detail == "full"; + return ($"{pick.Name}{Size(bytes.Length)} from {head}\n\n" + + (full || text.Length <= MaxText ? text : text[..MaxText] + $"\n\n… {text.Length - MaxText} more characters (detail=full)"), null); + } + + /// An image recognized by its first bytes (pasted images may have no usable file name). + internal static string? ImageMime(byte[] b) => + b.Length > 8 && b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47 ? "image/png" + : b.Length > 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF ? "image/jpeg" + : b.Length > 6 && b[0] == 'G' && b[1] == 'I' && b[2] == 'F' ? "image/gif" + : b.Length > 12 && b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F' && b[8] == 'W' && b[9] == 'E' && b[10] == 'B' && b[11] == 'P' ? "image/webp" + : null; + + /// The text of a file: Word and Excel (Office Open XML), or anything that is plain text. + internal static string? Extract(string name, byte[] bytes) + { + var ext = Path.GetExtension(name).ToLowerInvariant(); + try + { + switch (ext) + { + case ".docx" or ".docm": + return Word(bytes); + case ".xlsx" or ".xlsm": + return Excel(bytes); + case ".msg": + return OutlookMsg.Read(bytes); + case ".txt" or ".md" or ".csv" or ".json" or ".xml" or ".log" or ".sql" or ".cs" or ".xpp" or ".yml" or ".yaml" or ".html" or ".htm": + return Encoding.UTF8.GetString(bytes).TrimStart('\uFEFF'); + } + } + catch (Exception ex) when (ex is InvalidDataException or System.Xml.XmlException) { return null; } + // Unknown extension: text when it has no zero bytes in the start. + return bytes.Take(4096).Contains((byte)0) ? null : Encoding.UTF8.GetString(bytes); + } + + static XDocument? Part(ZipArchive zip, string path) => + zip.GetEntry(path) is { } e ? XDocument.Load(e.Open()) : null; + + /// Paragraphs as lines, headings as markdown, tables as "a | b" rows. + static string Word(byte[] bytes) + { + using var zip = new ZipArchive(new MemoryStream(bytes)); + var doc = Part(zip, "word/document.xml") ?? throw new InvalidDataException(); + XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + var sb = new StringBuilder(); + var body = doc.Root?.Element(w + "body"); + if (body is null) return ""; + foreach (var block in body.Elements()) + { + if (block.Name == w + "p") sb.AppendLine(Paragraph(block, w)); + else if (block.Name == w + "tbl") + { + foreach (var row in block.Elements(w + "tr")) + sb.AppendLine("| " + string.Join(" | ", row.Elements(w + "tc").Select(c => string.Join(" ", c.Elements(w + "p").Select(p => Paragraph(p, w))).Trim())) + " |"); + sb.AppendLine(); + } + } + // Collapse runs of empty lines. + return System.Text.RegularExpressions.Regex.Replace(sb.ToString(), @"\n{3,}", "\n\n").Trim(); + } + + static string Paragraph(XElement p, XNamespace w) + { + var text = string.Concat(p.Descendants().Select(e => e.Name == w + "t" ? e.Value : e.Name == w + "tab" ? "\t" : e.Name == w + "br" ? "\n" : "")); + var style = p.Element(w + "pPr")?.Element(w + "pStyle")?.Attribute(w + "val")?.Value ?? ""; + var level = style.StartsWith("Heading", StringComparison.OrdinalIgnoreCase) && int.TryParse(style[7..], out var l) ? l + : style.StartsWith("Nagwek", StringComparison.OrdinalIgnoreCase) && int.TryParse(style[6..], out var l2) ? l2 : 0; + if (level > 0 && text.Length > 0) return new string('#', Math.Min(level, 6)) + " " + text; + if (p.Element(w + "pPr")?.Element(w + "numPr") is not null && text.Length > 0) return "- " + text; + return text; + } + + /// Each sheet as rows of "a | b | c" (shared strings resolved). + static string Excel(byte[] bytes) + { + using var zip = new ZipArchive(new MemoryStream(bytes)); + XNamespace x = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + var shared = Part(zip, "xl/sharedStrings.xml")?.Root?.Elements(x + "si").Select(si => string.Concat(si.Descendants(x + "t").Select(t => t.Value))).ToList() ?? []; + var sb = new StringBuilder(); + foreach (var entry in zip.Entries.Where(e => e.FullName.StartsWith("xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase)).OrderBy(e => e.FullName.Length).ThenBy(e => e.FullName)) + { + sb.AppendLine($"## {Path.GetFileNameWithoutExtension(entry.FullName)}"); + var sheet = XDocument.Load(entry.Open()); + foreach (var row in sheet.Descendants(x + "row").Take(500)) + { + var cells = row.Elements(x + "c").Select(c => + { + var v = c.Element(x + "v")?.Value ?? string.Concat(c.Descendants(x + "t").Select(t => t.Value)); + return (string?)c.Attribute("t") == "s" && int.TryParse(v, out var i) && i < shared.Count ? shared[i] : v; + }).ToList(); + if (cells.Any(c => c.Length > 0)) sb.AppendLine("| " + string.Join(" | ", cells) + " |"); + } + sb.AppendLine(); + } + return sb.ToString().Trim(); + } + + /// Attaches a local file to a work item. + public static async Task AddAsync(Lens lens, string? connection, int id, string path, string? comment, CancellationToken ct = default) + { + if (!File.Exists(path)) throw new ArgumentException($"no file {path}"); + var info = new FileInfo(path); + if (info.Length > 60L * 1024 * 1024) throw new ArgumentException("the file is larger than 60 MB"); + var session = lens.For(connection); + var items = await session.Work.GetAsync([id], relations: false, ["System.Id", "System.TeamProject", "System.WorkItemType", "System.State", "System.Title"], ct); + if (items.Count == 0) return $"no work item #{id} visible with this PAT"; + var project = WorkItems.Field(items[0], "System.TeamProject")!; + var url = await session.Client.UploadAttachmentAsync(project, info.Name, await File.ReadAllBytesAsync(path, ct), ct); + await session.Work.PatchAsync(id, [new { op = "add", path = "/relations/-", value = new { rel = Rel, url, attributes = new { comment = comment ?? "" } } }], ct); + return $"attached {info.Name}{Size(info.Length)} to {WorkItemFormat.Header(items[0])}"; + } +} diff --git a/src/AdoLens/Cli.cs b/src/AdoLens/Cli.cs index 8a819ab..bd8b27e 100644 --- a/src/AdoLens/Cli.cs +++ b/src/AdoLens/Cli.cs @@ -23,11 +23,14 @@ public static void PrintUsage() => Console.WriteLine(""" --org --project --team --connection --pat-stdin (read the PAT from standard input) --default (make it the default connection) logout remove the stored PAT of a connection --connection | --all + --remove: also the connection itself and its metadata cache connections list the connections config show or change settings --connection --project --team (defaults of a connection) --default-connection --mode po|dev|full --usage-log true|false --show-tokens true|false --add-toolset --remove-toolset --confirm-above + --language pl|en|auto (settings console and installer texts) + --connection --label-languages "en-US, pl"|all (languages review and label require) status configuration, connection check, metadata and Claude registrations --connection --offline --quiet register add / update the 'ado-lens' MCP server in Claude Desktop and Claude Code @@ -56,6 +59,11 @@ wiki [] --search --depth 2 --detail full --wiki search work items, wiki and code in one search --scope work;wiki;code --top 10 file a file ($/… TFVC, Git path with --repo, or a file name) --version --part --history (who changed it, when, for which work items) --detail full + skills puts the skills of the mode into Claude Code (po-*, dev-*, all for full), removes the others + review checks before testing: work item linked, labels in every language, X++ messages from labels, + no TODO; --item : every change of a work item and its tasks + attachments files of a work item; --file reads one (Word, Excel, text); --add attaches + label "" a D365 message as seen (values for %1 are fine) or @File:Id: label, languages, where used health backlog hygiene of the team --team release-notes [current|previous|] or --since 14d [--until yyyy-MM-dd] markdown for the wiki undo revert the last change made by ADO Lens (preview, then --confirm ) --list @@ -64,7 +72,8 @@ recipe list | show | save --file | delete 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) + setup settings menu: mode, environments (change or add, for you or the team wiki), add or remove a + connection, status; --wizard: the first-run questions in a row (used by the installer) upgrade is there a newer ADO Lens --install (download, check SHA-256, run its installer) --pre version print the version @@ -196,9 +205,31 @@ public int Logout() Console.WriteLine(CredentialStore.Delete(name) ? $"removed {CredentialStore.TargetFor(name)} from Windows Credential Manager" : $"no stored PAT for connection '{name}'"); + if (cl.Flag("remove")) RemoveConnection(name, patRemoved: true); return 0; } + /// The whole connection: PAT, settings entry and its metadata cache. + void RemoveConnection(string name, bool patRemoved = false) + { + if (!patRemoved && CredentialStore.Delete(name)) Console.WriteLine($"removed {CredentialStore.TargetFor(name)} from Windows Credential Manager"); + var settings = Settings.Load(SettingsPath); + var key = settings.Connections.Keys.FirstOrDefault(k => k.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (key is null) { Console.WriteLine($"no connection '{name}' in the settings"); return; } + settings.Connections.Remove(key); + if (string.Equals(settings.DefaultConnection, key, StringComparison.OrdinalIgnoreCase)) + settings.DefaultConnection = settings.Connections.Count == 1 ? settings.Connections.Keys.First() : null; + settings.Save(SettingsPath); + Console.WriteLine($"removed connection '{key}' from {SettingsPath}" + + (settings.DefaultConnection is { } d ? $"; default connection: {d}" : settings.Connections.Count > 1 ? "; set a default with 'adolens config --default-connection '" : "")); + var meta = Path.Combine(Settings.DataDir, "meta"); + foreach (var f in Directory.Exists(meta) ? Directory.GetFiles(meta, key + ".db*") : []) + { + try { File.Delete(f); } + catch (IOException) { Console.WriteLine($"note: {f} is in use (an open Claude session) - delete it later"); } + } + } + public int Connections() { var settings = Settings.Load(SettingsPath); @@ -208,7 +239,8 @@ public int Connections() var pat = CredentialStore.Read(name) is not null ? "PAT stored" : "no stored PAT"; Console.WriteLine($"{(name.Equals(settings.DefaultConnection, StringComparison.OrdinalIgnoreCase) ? "*" : " ")} {name,-16} " + $"https://dev.azure.com/{c.Organization} project {c.Project ?? "-"} team {c.Team ?? "-"} ({pat})" + - (c.Environments.Count > 0 ? $"\n {"",-16} environments: {string.Join(", ", c.Environments.Select(e => $"{e.Key} = {e.Value}"))}" : "")); + (c.Environments.Count > 0 ? $"\n {"",-16} environments: {string.Join(", ", c.Environments.Select(e => $"{e.Key} = {e.Value}"))}" : "") + + (c.LabelLanguages.Count > 0 ? $"\n {"",-16} label languages: {string.Join(", ", c.LabelLanguages)}" : "")); } return 0; } @@ -233,16 +265,22 @@ public int Config() changed = true; } while (cl.Opt("remove-env") is { } r) { conn.Environments.Remove(r.Trim()); changed = true; } + if (cl.Opt("label-languages") is { } ll) + { + conn.LabelLanguages = SplitLanguages(ll); + changed = true; + } } - else if (cl.Peek("project") is not null || cl.Peek("team") is not null || cl.Peek("env") is not null || cl.Peek("remove-env") is not null) - throw new ArgumentException("--project / --team / --env need --connection "); + else if (cl.Peek("project") is not null || cl.Peek("team") is not null || cl.Peek("env") is not null || cl.Peek("remove-env") is not null + || cl.Peek("label-languages") is not null) + throw new ArgumentException("--project / --team / --env / --label-languages need --connection "); if (cl.Opt("default-connection") is { } d) { if (!settings.Connections.ContainsKey(d)) throw new ArgumentException($"no connection named '{d}'"); settings.DefaultConnection = settings.Connections.Keys.First(k => k.Equals(d, StringComparison.OrdinalIgnoreCase)); changed = true; } - if (cl.Opt("mode") is { } m) { settings.Mode = Settings.NormalizeMode(m); changed = true; } + if (cl.Opt("mode") is { } m) { settings.Mode = Settings.NormalizeMode(m); changed = true; Console.WriteLine(Skills.Sync(settings.Mode)); } if (cl.Opt("usage-log") is { } u) { settings.UsageLog = bool.TryParse(u, out var b) ? b : throw new ArgumentException("--usage-log takes true or false"); @@ -259,6 +297,17 @@ public int Config() settings.ExtraToolsets.Remove(Toolsets.Normalize(rem)); changed = true; } + if (cl.Opt("language") is { } lang) + { + settings.Language = lang.Trim().ToLowerInvariant() switch + { + "pl" or "polski" or "polish" => "pl", + "en" or "english" or "angielski" => "en", + "" or "auto" => null, + _ => throw new ArgumentException("--language takes pl, en or auto (the language of Windows)"), + }; + changed = true; + } if (cl.Opt("show-tokens") is { } st) { settings.ShowTokens = bool.TryParse(st, out var b) ? b : throw new ArgumentException("--show-tokens takes true or false"); @@ -443,6 +492,14 @@ public async Task UpdateAsync() Tags = cl.Opt("tags"), Fields = FieldOptions(), }; var ids = cl.Opt("ids") ?? (cl.Positional.Count > 0 ? string.Join(';', cl.Positional) : null); + var wiql = cl.Peek("wiql"); + if (ids is null && wiql is null && r.IsEmpty && cl.Peek("comment") is null) + { + // "update" alone is easily taken for updating the program. + Console.Error.WriteLine("update changes work items: adolens update --state | --field \"Name=Value\" | --comment …"); + Console.Error.WriteLine("to update ADO Lens itself: adolens upgrade --install"); + return 2; + } Print(await WorkQueries.UpdateAsync(lens, cl.Opt("connection"), ids, cl.Opt("wiql"), r, cl.Opt("comment"), cl.Opt("confirm"), lens.Settings.ConfirmBulkAbove)); return 0; } @@ -502,8 +559,43 @@ public async Task UpdateCommandAsync() /// 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. /// + [System.Runtime.InteropServices.DllImport("kernel32.dll")] + static extern uint GetACP(); + + /// + /// For the installer wizard, on the default (or given) connection and its project: + /// --detect [--out file]: "existing<TAB><flow><TAB><source>" when a map is in use, else "row<TAB>key<TAB>guess" per + /// stage or branch found (written in the system ANSI code page, which the wizard reads); + /// --env "key=name; …" [--scope local|team]: saves the map without questions. + /// + async Task SetupUnattendedAsync() + { + var lens = NewLens(); + var session = lens.For(cl.Opt("connection")); + var project = session.Project(null); + if (cl.Flag("detect")) + { + var lines = new List(); + var (effective, source) = await EnvironmentFlow.EffectiveAsync(session, project, default); + if (effective.Count > 0) lines.Add($"existing\t{string.Join(" -> ", effective.Values)}\t{source}"); + else lines.AddRange((await EnvironmentFlow.DetectAsync(session, project, default)).Select(d => $"row\t{d.Key}\t{d.Guess}")); + if (cl.Opt("out") is { } file) + { + System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); + File.WriteAllLines(file, lines, System.Text.Encoding.GetEncoding((int)GetACP())); + } + else lines.ForEach(Console.WriteLine); + return 0; + } + var env = cl.Opt("env")!; + var scope = cl.Opt("scope") ?? "local"; + Print(await Queries.SettingsAsync(lens, session.Config.Connection, project, env, scope)); + return 0; + } + public async Task SetupAsync() { + if (cl.Flag("detect") || cl.Peek("env") is not null) return await SetupUnattendedAsync(); var path = SettingsPath; var settings = Settings.Load(path); if (settings.Connections.Count == 0) @@ -514,6 +606,8 @@ public async Task SetupAsync() settings = Settings.Load(path); } var interactive = !Console.IsInputRedirected; + // Run again later (Start menu "ADO Lens - settings"): a menu to change one thing at a time. + if (interactive && !cl.Flag("wizard") && cl.Peek("mode") is null) return await SetupMenuAsync(); // 1. Who uses it. if (cl.Opt("mode") is { } m) settings.Mode = Settings.NormalizeMode(m); @@ -531,6 +625,7 @@ public async Task SetupAsync() settings.Mode = (string.IsNullOrWhiteSpace(answer) ? current : answer.Trim()) switch { "2" => "dev", "3" => "full", _ => "po" }; } settings.Save(path); + Console.WriteLine(Skills.Sync(settings.Mode)); // 2. Where code goes, per connection. var lens = NewLens(); @@ -541,55 +636,173 @@ public async Task SetupAsync() 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) + await EnvironmentsAsync(name, session, project, interactive, change: false); + } + return 0; + } + + static bool Yes(string? answer) => (answer ?? "").Trim().ToLowerInvariant() is "y" or "yes" or "t" or "tak"; + + /// + /// Asks for the environment names of a connection's project. With an existing map is + /// edited (its names offered, new stages and branches added at the end); otherwise an existing map is only shown. + /// + async Task EnvironmentsAsync(string name, Session session, string project, bool interactive, bool change) + { + 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}).")); + if (effective.Count > 0 && !change) return; + var detected = await EnvironmentFlow.DetectAsync(session, project, default); + // Rows: the current map in its order, then what was found and is not in it yet. + var rows = effective.Select(e => (e.Key, Guess: (string?)e.Value)) + .Concat(detected.Where(d => !effective.ContainsKey(d.Key))).ToList(); + if (rows.Count == 0 || !interactive) + { + Console.WriteLine(Ui.T($"{name} / {project}: no release stages or long-lived branches found - Claude will ask when needed.", + $"{name} / {project}: nie znaleziono etapów release'ów ani gałęzi - Claude zapyta, gdy będzie trzeba.")); + return; + } + 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("Name each as people say it (e.g. UAT (sbx03)); Enter keeps [the name], '-' leaves it out.", + "Nadaj każdemu nazwę, której używacie (np. UAT (sbx03)); Enter zostawia [nazwę], '-' pomija.")); + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, guess) in rows) + { + 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; + } + // Something the search did not find (a release stage that has not run yet, a branch in another project). + while (Prompt(Ui.T(" another one (branch or Release/Stage=name, Enter = done): ", " kolejne (gałąź albo Release/Etap=nazwa, Enter = koniec): ")) is { Length: > 0 } extra) + { + var eq = extra.IndexOf('='); + if (eq > 0) map[extra[..eq].Trim()] = extra[(eq + 1)..].Trim(); + else Console.WriteLine(Ui.T(" write it as key=name, e.g. release=PROD", " zapisz jako klucz=nazwa, np. release=PROD")); + } + if (map.Count == 0) return; + var teamNow = source.StartsWith("team", StringComparison.Ordinal); + var forTeam = Yes(Prompt(Ui.T($"Save for the whole team on the project's wiki page \"ADO Lens\"? [{(teamNow ? "Y/n" : "y/N")}]: ", + $"Zapisać dla całego zespołu na stronie wiki projektu \"ADO Lens\"? [{(teamNow ? "T/n" : "t/N")}]: ")) is { Length: > 0 } a ? a : teamNow ? "y" : "n"); + if (forTeam) + { + try { - 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; + var url = await session.Team.SaveAsync(project, map, default); + Console.WriteLine(Ui.T($"saved on the wiki: {url}", $"zapisane na wiki: {url}")); + // A local map would hide the team's one: drop it. + if (session.Config.Environments.Count > 0) + { + var s = Settings.Load(SettingsPath); + s.Connections[name].Environments = new Dictionary(StringComparer.OrdinalIgnoreCase); + s.Save(SettingsPath); + Console.WriteLine(Ui.T("your own map was removed, the team's is used now", "twoja własna mapa została usunięta, teraz obowiązuje zespołowa")); + } + return; } - 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) + catch (AdoApiException ex) { - 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; + 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")); } - 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) + } + var settings = Settings.Load(SettingsPath); + settings.Connections[name].Environments = map; + settings.Save(SettingsPath); + Console.WriteLine(Ui.T("saved in your settings", "zapisane w twoich ustawieniach")); + } + + /// The settings menu: one thing at a time, until Enter. + async Task SetupMenuAsync() + { + while (true) + { + var settings = Settings.Load(SettingsPath); + Console.WriteLine(); + Console.WriteLine(Ui.T($"ADO Lens {version} - settings", $"ADO Lens {version} - ustawienia")); + Console.WriteLine(Ui.T($" mode: {settings.Mode}; connections: {string.Join(", ", settings.Connections.Keys.Select(k => k == settings.DefaultConnection ? k + " (default)" : k))}", + $" tryb: {settings.Mode}; połączenia: {string.Join(", ", settings.Connections.Keys.Select(k => k == settings.DefaultConnection ? k + " (domyślne)" : k))}")); + Console.WriteLine(Ui.T(" 1. Who uses it (mode)", " 1. Kto używa (tryb)")); + Console.WriteLine(Ui.T(" 2. Environments (where code goes) - change or add", " 2. Środowiska (gdzie trafia kod) - zmiana lub dodanie")); + Console.WriteLine(Ui.T(" 3. Add a connection (another organization or company)", " 3. Dodaj połączenie (inna organizacja lub firma)")); + Console.WriteLine(Ui.T(" 4. Remove a connection", " 4. Usuń połączenie")); + Console.WriteLine(Ui.T(" 5. Status (is everything working)", " 5. Status (czy wszystko działa)")); + Console.WriteLine(Ui.T(" 6. Language: Polish / English", " 6. Język: polski / angielski")); + Console.WriteLine(Ui.T(" 7. Required label languages (checks of D365 changes)", " 7. Wymagane języki etykiet (kontrola zmian D365)")); + var choice = Prompt(Ui.T("Choice (Enter = done): ", "Wybór (Enter = koniec): ")); + switch (choice?.Trim()) { - 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")); - } + case null or "": return 0; + case "1": + var current = settings.Mode switch { "dev" => "2", "full" => "3", _ => "1" }; + Console.WriteLine(Ui.T(" 1. Product Owner 2. Developer 3. Everything", " 1. Product Owner 2. Programista 3. Wszystko")); + var m = Prompt(Ui.T($" mode [{current}]: ", $" tryb [{current}]: ")); + settings.Mode = (string.IsNullOrWhiteSpace(m) ? current : m.Trim()) switch { "2" => "dev", "3" => "full", _ => "po" }; + settings.Save(SettingsPath); + Console.WriteLine(Ui.T($" mode: {settings.Mode} - new Claude sessions use it", $" tryb: {settings.Mode} - obowiązuje w nowych sesjach Claude'a")); + Console.WriteLine(" " + Skills.Sync(settings.Mode)); + break; + case "2": + var name = PickConnection(settings); + if (name is null) break; + Session session; + try { session = NewLens().For(name); } + catch (InvalidOperationException ex) { Console.WriteLine(" " + ex.Message); break; } + if (session.Config.Project is not { } project) { Console.WriteLine(Ui.T(" this connection has no default project", " to połączenie nie ma domyślnego projektu")); break; } + await EnvironmentsAsync(name, session, project, interactive: true, change: true); + break; + case "3": + await LoginAsync(); + break; + case "4": + var remove = PickConnection(settings); + if (remove is not null && Yes(Prompt(Ui.T($" remove '{remove}' with its PAT? [y/N]: ", $" usunąć '{remove}' razem z PAT-em? [t/N]: ")))) + RemoveConnection(remove); + break; + case "5": + await StatusAsync(); + break; + case "6": + var l = Prompt(" 1. polski 2. English 3. auto (Windows): "); + settings.Language = l?.Trim() switch { "1" => "pl", "2" => "en", "3" => null, _ => settings.Language }; + settings.Save(SettingsPath); + Ui.Polish = settings.Language == "pl" || settings.Language is null && Ui.Polish; + break; + case "7": + var cn = PickConnection(settings); + if (cn is null) break; + var now = settings.Connections[cn].LabelLanguages; + Console.WriteLine(Ui.T($" now: {(now.Count == 0 ? "all languages of the label files" : string.Join(", ", now))}", + $" teraz: {(now.Count == 0 ? "wszystkie języki plików etykiet" : string.Join(", ", now))}")); + var langs = Prompt(Ui.T(" languages every label must have, e.g. en-US, pl ('all' = every one, Enter = keep): ", + " języki, które musi mieć każda etykieta, np. en-US, pl ('all' = wszystkie, Enter = bez zmian): ")); + if (!string.IsNullOrWhiteSpace(langs)) + { + settings.Connections[cn].LabelLanguages = SplitLanguages(langs); + settings.Save(SettingsPath); + Console.WriteLine(Ui.T(" saved", " zapisane")); + } + break; } - settings.Connections[name].Environments = map; - settings.Save(path); - Console.WriteLine(Ui.T("saved in your settings", "zapisane w twoich ustawieniach")); } - return 0; + } + + /// "en-US, pl" → [en-US, pl]; "all" or empty → [] (every language required). + static List SplitLanguages(string text) => + text.Trim().Equals("all", StringComparison.OrdinalIgnoreCase) ? [] + : text.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + string? PickConnection(Settings settings) + { + var names = settings.Connections.Keys.ToList(); + if (names.Count == 0) { Console.WriteLine(Ui.T(" no connections yet - choose 3", " brak połączeń - wybierz 3")); return null; } + if (names.Count == 1) return names[0]; + for (var i = 0; i < names.Count; i++) Console.WriteLine($" {i + 1}. {names[i]}"); + var a = Prompt(Ui.T(" connection [1]: ", " połączenie [1]: ")); + return int.TryParse(string.IsNullOrWhiteSpace(a) ? "1" : a, out var n) && n >= 1 && n <= names.Count ? names[n - 1] : null; } public async Task TraceAsync() @@ -648,6 +861,39 @@ public async Task SearchAsync() return 0; } + public async Task AttachmentsAsync() + { + if (Int(cl.Positional.FirstOrDefault()) is not { } id) { Console.Error.WriteLine("usage: adolens attachments [--file ] [--add --comment ]"); return 2; } + var lens = NewLens(); + if (cl.Opt("add") is { } add) { Print(await Attachments.AddAsync(lens, cl.Opt("connection"), id, add, cl.Opt("comment"))); return 0; } + var (text, image) = await Attachments.ReadAsync(lens, cl.Opt("connection"), id, cl.Opt("file"), cl.Opt("detail")); + Print(text); + if (image is { } i) + { + // In a terminal an image is saved and opened instead. + var path = Path.Combine(Path.GetTempPath(), $"adolens-{id}-{Guid.NewGuid():N}"[..24] + (i.Mime == "image/jpeg" ? ".jpg" : "." + i.Mime[6..])); + await File.WriteAllBytesAsync(path, i.Bytes); + Console.WriteLine($"saved: {path}"); + } + return 0; + } + + public async Task ReviewAsync() + { + var arg = cl.Positional.Count > 0 ? string.Join(' ', cl.Positional) : null; + var item = Int(cl.Opt("item")); + if (arg is null && item is null) { Console.Error.WriteLine("usage: adolens review <523 | \"PR 482\" | sha --repo > or adolens review --item "); return 2; } + Print(await Review.ReviewAsync(NewLens(), cl.Opt("connection"), item is null ? arg : null, item, cl.Opt("repo"))); + return 0; + } + + public async Task LabelAsync() + { + if (cl.Positional.Count == 0) { Console.Error.WriteLine("usage: adolens label \"\" | @File:Id [--branch Main]"); return 2; } + Print(await Labels.FindAsync(NewLens(), cl.Opt("connection"), cl.Opt("project"), string.Join(' ', cl.Positional), cl.Opt("branch"))); + return 0; + } + public async Task FileAsync() { if (cl.Positional.Count == 0) { Console.Error.WriteLine("usage: adolens file [--repo ] [--version ] [--part ] [--history]"); return 2; } @@ -672,6 +918,12 @@ public async Task TestCreateAsync() return 0; } + public async Task MetricsAsync() + { + Print(await BoardQueries.MetricsAsync(NewLens(), cl.Opt("connection"), cl.Opt("project"), cl.Opt("team"), Int(cl.Opt("sprints")) ?? 6)); + 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 1c015f7..98e981b 100644 --- a/src/AdoLens/CommandLine.cs +++ b/src/AdoLens/CommandLine.cs @@ -13,17 +13,17 @@ public sealed partial class CommandLine { ["mcp"] = [], ["upgrade"] = ["install", "pre"], - ["setup"] = ["mode="], + ["setup"] = ["mode=", "connection=", "detect", "out=", "env=", "scope=", "wizard"], ["login"] = ["connection=", "org=", "project=", "team=", "pat-stdin", "pat-env=", "default"], ["tests"] = ["connection=", "team=", "detail="], ["test-create"] = ["connection=", "file=", "plan=", "suite="], - ["logout"] = ["connection=", "all"], + ["logout"] = ["connection=", "all", "remove"], ["connections"] = [], ["trace"] = ["connection=", "detail="], ["pr"] = ["connection=", "project=", "repo=", "list=", "detail="], ["pipeline"] = ["connection=", "project=", "definition=", "branch=", "top=", "detail="], ["changes"] = ["connection=", "repo=", "file=", "detail="], - ["config"] = ["env=", "remove-env=", "connection=", "default-connection=", "project=", "team=", "mode=", "usage-log=", "add-toolset=", "remove-toolset=", "confirm-above=", "show-tokens="], + ["config"] = ["env=", "remove-env=", "connection=", "default-connection=", "project=", "team=", "mode=", "usage-log=", "add-toolset=", "remove-toolset=", "confirm-above=", "show-tokens=", "language=", "label-languages="], ["item"] = ["connection=", "include=", "detail="], ["query"] = ["connection=", "project=", "wiql=", "query=", "top="], ["create"] = ["connection=", "project=", "type=", "title=", "description=", "criteria=", "assign=", "parent=", "iteration=", "area=", "tags=", "state=", "field=", "template="], @@ -43,9 +43,14 @@ public sealed partial class CommandLine ["fields"] = ["connection=", "project=", "type=", "field=", "changes=", "all", "refresh"], ["stats"] = ["days=", "top=", "live"], ["api"] = ["connection="], - ["search"] = ["connection=", "project=", "scope=", "top="], + ["skills"] = [], + ["review"] =["connection=", "repo=", "item="], + ["attachments"] =["connection=", "file=", "add=", "comment=", "detail="], + ["label"] =["connection=", "project=", "branch="], + ["search"] =["connection=", "project=", "scope=", "top="], ["file"] = ["connection=", "project=", "repo=", "version=", "part=", "history", "top=", "detail="], - ["health"] = ["connection=", "project=", "team="], + ["metrics"] = ["connection=", "project=", "team=", "sprints="], + ["health"] =["connection=", "project=", "team="], ["release-notes"] = ["connection=", "project=", "team=", "since=", "until="], ["undo"] = ["connection=", "confirm=", "list"], ["recipe"] = ["connection=", "file=", "param=", "confirm=", "yes"], diff --git a/src/AdoLens/Knowledge.cs b/src/AdoLens/Knowledge.cs index 9976602..895ab29 100644 --- a/src/AdoLens/Knowledge.cs +++ b/src/AdoLens/Knowledge.cs @@ -109,9 +109,9 @@ static async Task WikiAsync(AdoClient client, string project, string tex return sb.ToString().TrimEnd(); } - sealed record CodeHit(string Repo, string Path, string? Branch, int Matches, bool Tfvc); + internal sealed record CodeHit(string Repo, string Path, string? Branch, int Matches, bool Tfvc); - static async Task<(List Hits, int Total)> CodeHitsAsync(AdoClient client, string project, string text, int top, string? repo, CancellationToken ct) + internal static async Task<(List Hits, int Total)> CodeHitsAsync(AdoClient client, string project, string text, int top, string? repo, CancellationToken ct) { var more = repo is null ? null : new Dictionary { ["Repository"] = [repo] }; var root = await client.PostAsync(client.ServiceUrl("almsearch") + $"{Uri.EscapeDataString(project)}/_apis/search/codesearchresults", SearchBody(text, top, project, more), ct); @@ -212,7 +212,7 @@ public static async Task FileAsync(Lens lens, string? connection, string return Show(where, file.Path, text, part, detail == "full"); } - static async Task Content(AdoClient client, string url, CancellationToken ct) + internal static async Task Content(AdoClient client, string url, CancellationToken ct) { try { return Json.Str(await client.GetAsync(url, ct), "content"); } catch (AdoApiException ex) when (ex.Status is HttpStatusCode.NotFound or HttpStatusCode.BadRequest) { return null; } diff --git a/src/AdoLens/Labels.cs b/src/AdoLens/Labels.cs new file mode 100644 index 0000000..f8c2d6a --- /dev/null +++ b/src/AdoLens/Labels.cs @@ -0,0 +1,216 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace AdoLens; + +/// +/// Dynamics 365 F&O labels: from the text a user sees (or a label id) to the label, its translations and where the +/// code uses it - the object and the X++ method, or the field or control whose label it is. +/// +public static partial class Labels +{ + const int MaxLabels = 5; + const int MaxUses = 15; + + [GeneratedRegex(@"^@?(?:(?[A-Za-z0-9_]+):)?(?[A-Za-z_][A-Za-z0-9_]*)$")] + private static partial Regex LabelId(); + + [GeneratedRegex(@"%\d+")] + private static partial Regex Placeholder(); + + [GeneratedRegex(@"[^\p{L}\p{N}]+")] + private static partial Regex NonWord(); + + /// A label file: "…/AxLabelFile/LabelResources/pl/Contoso.pl.label.txt" → (Contoso, pl, the branch it is on). + sealed record LabelFile(string Path, string? RepoId, string FileId, string Language, string Root); + + static LabelFile? AsLabelFile(string path, string? repoId) + { + var p = path.Replace('\\', '/'); + var i = p.IndexOf("/AxLabelFile/LabelResources/", StringComparison.OrdinalIgnoreCase); + if (i < 0 || !p.EndsWith(".label.txt", StringComparison.OrdinalIgnoreCase)) return null; + var name = p[(p.LastIndexOf('/') + 1)..^".label.txt".Length]; // Contoso.pl + var dot = name.LastIndexOf('.'); + if (dot <= 0) return null; + // The branch: what comes before /Metadata/ (TFVC "$/P/Trunk/Main"), else the repository root. + var meta = p.IndexOf("/Metadata/", StringComparison.OrdinalIgnoreCase); + return new LabelFile(p, repoId, name[..dot], name[(dot + 1)..], meta > 0 ? p[..meta] : "/"); + } + + /// "Id=Text" lines of a label file (comments start with " ;"). + internal static Dictionary Parse(string content) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var raw in content.Replace("\r", "").Split('\n')) + { + if (raw.Length == 0 || raw[0] == ' ' || raw[0] == ';') continue; + var eq = raw.IndexOf('='); + if (eq > 0) map[raw[..eq].Trim().TrimStart('\uFEFF')] = raw[(eq + 1)..]; + } + return map; + } + + /// + /// Whether a label text is what the user saw: the user's text inside it, or the label with its %1 %2 filled in + /// ("No on-hand for item %1" matches "No on-hand for item 12345"). + /// + internal static bool Matches(string label, string seen) + { + var l = label.Trim(); + var s = seen.Trim(); + if (l.Contains(s, StringComparison.OrdinalIgnoreCase)) return true; + if (!Placeholder().IsMatch(l)) return false; + var pattern = "^" + string.Join(".*?", Placeholder().Split(l).Select(Regex.Escape)) + "$"; + return Regex.IsMatch(s, pattern, RegexOptions.IgnoreCase | RegexOptions.Singleline, TimeSpan.FromMilliseconds(200)); + } + + /// The words of a seen message worth searching for: no numbers or ids, the longest few. + internal static string SearchWords(string seen) => + string.Join(' ', NonWord().Split(seen).Where(w => w.Length > 2 && !w.Any(char.IsDigit)).Distinct(StringComparer.OrdinalIgnoreCase) + .OrderByDescending(w => w.Length).Take(5)); + + public static async Task FindAsync(Lens lens, string? connection, string? project, string text, string? branch, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("give the text of the message (as seen on screen) or a label id like @Model:Id"); + var session = lens.For(connection); + var name = session.Project(project); + var client = session.Client; + var asId = LabelId().Match(text.Trim()) is { Success: true } m && (text.Contains(':') || text.TrimStart().StartsWith('@') || !text.Contains(' ')) ? m : null; + var query = asId is not null ? asId.Groups["id"].Value : SearchWords(text); + if (query.Length == 0) throw new ArgumentException("the text has no words to search for"); + + // Only label files. A pasted message has its %1 %2 filled in with values that are not in the label (an item + // name, a number): numbers are left out of the words, and with no hit the search is repeated with fewer words. + var hits = new List(); + var words = query.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); + try + { + for (var n = words.Count; n >= 1 && hits.Count == 0; n = n > 3 ? 3 : n - 1) + (hits, _) = await Knowledge.CodeHitsAsync(client, name, string.Join(' ', words.Take(n)) + " file:*.label.txt", 200, null, ct); + } + catch (AdoApiException ex) { return $"labels can only be found with Code Search ({ex.Message})"; } + + var repoIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + async Task RepoId(Knowledge.CodeHit h) + { + if (h.Tfvc) return null; + if (!repoIds.TryGetValue(h.Repo, out var id)) repoIds[h.Repo] = id = (await session.Code.RepoAsync(h.Repo, name, ct)).Id; + return id; + } + var files = new List(); + foreach (var h in hits) + if (AsLabelFile(h.Path, await RepoId(h)) is { } lf) files.Add(lf); + if (files.Count == 0) return $"no label file in {name} contains '{text}' (only labels of your own models are in the repository; Microsoft labels like @SYS… are not)"; + + // One branch: the one asked for, else Main / main / master, else the first. + var roots = files.Select(f => f.Root).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var root = (branch is null ? null : roots.FirstOrDefault(r => r.EndsWith("/" + branch.Trim('/'), StringComparison.OrdinalIgnoreCase))) + ?? roots.FirstOrDefault(r => r.EndsWith("/Main", StringComparison.OrdinalIgnoreCase)) + ?? roots.FirstOrDefault(r => r.EndsWith("/main", StringComparison.Ordinal) || r.EndsWith("/master", StringComparison.OrdinalIgnoreCase)) + ?? roots[0]; + var onRoot = files.Where(f => f.Root.Equals(root, StringComparison.OrdinalIgnoreCase)).ToList(); + + // The labels: lines of the matching files whose id (or text) is what was asked. + var found = new List<(LabelFile File, string Id)>(); + foreach (var f in onRoot) + { + var content = await ReadAsync(client, f, ct); + if (content is null) continue; + foreach (var (id, value) in Parse(content)) + if (asId is not null ? id.Equals(asId.Groups["id"].Value, StringComparison.OrdinalIgnoreCase) && (!asId.Groups["file"].Success || asId.Groups["file"].Value.Equals(f.FileId, StringComparison.OrdinalIgnoreCase)) + : Matches(value, text)) + if (!found.Any(x => x.File.FileId == f.FileId && x.Id.Equals(id, StringComparison.OrdinalIgnoreCase))) found.Add((f, id)); + } + if (found.Count == 0) return $"no label on {root} has the text '{text}'" + (roots.Count > 1 ? $" (branches with label files: {string.Join(", ", roots)})" : ""); + + var sb = new StringBuilder(); + if (found.Count > MaxLabels) sb.AppendLine($"{found.Count} labels match; the first {MaxLabels} - give more of the text to narrow it down"); + foreach (var (file, id) in found.Take(MaxLabels)) + { + var label = $"@{file.FileId}:{id}"; + sb.AppendLine($"{label} ({root})"); + // Every language of the label file: the text, or that it is missing. + var folder = file.Path[..file.Path.IndexOf("/LabelResources/", StringComparison.OrdinalIgnoreCase)] + "/LabelResources"; + foreach (var lf in await LanguagesAsync(client, folder, file, ct)) + { + var content = await ReadAsync(client, lf, ct); + var value = content is null ? null : Parse(content).GetValueOrDefault(id); + // A language the connection does not require is left out when the label has no text in it. + if (value is null && !session.Config.LabelLanguageRequired(lf.Language)) continue; + sb.AppendLine($" {lf.Language}: {(value is null ? "MISSING" : Formatting.Collapse(value, 200))}"); + } + sb.AppendLine(" used in:"); + sb.Append(await UsesAsync(session, name, root, label, ct)); + } + return sb.ToString().TrimEnd(); + } + + static Task ReadAsync(AdoClient client, LabelFile f, CancellationToken ct) => + Knowledge.Content(client, f.RepoId is null + ? $"_apis/tfvc/items?path={Uri.EscapeDataString(f.Path)}&includeContent=true" + : $"_apis/git/repositories/{f.RepoId}/items?path={Uri.EscapeDataString(f.Path)}&includeContent=true", ct); + + /// The label files of the same label file id in every language folder. + static async Task> LanguagesAsync(AdoClient client, string folder, LabelFile file, CancellationToken ct) + { + List items; + try + { + items = await client.GetListAsync(file.RepoId is null + ? $"_apis/tfvc/items?scopePath={Uri.EscapeDataString(folder)}&recursionLevel=Full" + : $"_apis/git/repositories/{file.RepoId}/items?scopePath={Uri.EscapeDataString(folder)}&recursionLevel=Full", ct); + } + catch (AdoApiException) { return [file]; } + var list = items.Select(i => Json.Str(i, "path")).Where(p => p is not null).Select(p => AsLabelFile(p!, file.RepoId)) + .Where(l => l is not null && l.FileId.Equals(file.FileId, StringComparison.OrdinalIgnoreCase)).Select(l => l!) + .OrderBy(l => l.Language, StringComparer.OrdinalIgnoreCase).ToList(); + return list.Count == 0 ? [file] : list; + } + + /// Objects using the label on the branch: X++ methods that show it, fields and controls it names. + static async Task UsesAsync(Session session, string project, string root, string label, CancellationToken ct) + { + List hits; + try { (hits, _) = await Knowledge.CodeHitsAsync(session.Client, project, label.TrimStart('@').Replace(':', ' '), 100, null, ct); } + catch (AdoApiException ex) { return $" ({ex.Message})\n"; } + var uses = hits.Where(h => !h.Path.Contains("/AxLabelFile/", StringComparison.OrdinalIgnoreCase) + && (root == "/" || h.Path.StartsWith(root + "/", StringComparison.OrdinalIgnoreCase))).Take(MaxUses).ToList(); + if (uses.Count == 0) return " - nowhere on this branch (unused, or used only through code that builds the id)\n"; + var lines = await Task.WhenAll(uses.Select(async h => + { + var where = h.Tfvc ? h.Path : $"{h.Repo}: {h.Path}"; + if (!AotDiff.IsAot(h.Path)) return $" - {where}"; + var repoId = h.Tfvc ? null : (await session.Code.RepoAsync(h.Repo, project, ct)).Id; + var xml = await Knowledge.Content(session.Client, repoId is null + ? $"_apis/tfvc/items?path={Uri.EscapeDataString(h.Path)}&includeContent=true" + : $"_apis/git/repositories/{repoId}/items?path={Uri.EscapeDataString(h.Path)}&includeContent=true", ct); + return $" - {AotDiff.ObjectName(h.Path)}{Places(xml, label)}"; + })); + return string.Join('\n', lines) + "\n"; + } + + /// ": method a, method b; label of field X" - where in an AOT object the label appears. + internal static string Places(string? xml, string label) + { + if (xml is null) return ""; + XDocument doc; + try { doc = XDocument.Parse(xml); } + catch (System.Xml.XmlException) { return ""; } + var methods = AotDiff.Methods(doc).Where(m => m.Value.Contains(label, StringComparison.OrdinalIgnoreCase)).Select(m => m.Key).ToList(); + // Metadata: an element whose value is the label (Label, HelpText, Caption…) inside a named field or control. + var props = doc.Descendants().Where(e => !e.HasElements && e.Value.Trim().Equals(label, StringComparison.OrdinalIgnoreCase) + && e.Name.LocalName is not ("Source" or "Declaration")) + .Select(e => + { + var owner = e.Ancestors().FirstOrDefault(a => a.Parent is not null && a.Elements().Any(x => x.Name.LocalName == "Name")); + var ownerName = owner?.Elements().First(x => x.Name.LocalName == "Name").Value; + return ownerName is null ? e.Name.LocalName : $"{e.Name.LocalName} of {ownerName}"; + }).Distinct().ToList(); + var parts = new List(); + if (methods.Count > 0) parts.Add(string.Join(", ", methods.Take(5))); + if (props.Count > 0) parts.Add(string.Join(", ", props.Take(5))); + return parts.Count == 0 ? "" : ": " + string.Join("; ", parts); + } +} diff --git a/src/AdoLens/McpTools.cs b/src/AdoLens/McpTools.cs index e9421b5..4a8c3db 100644 --- a/src/AdoLens/McpTools.cs +++ b/src/AdoLens/McpTools.cs @@ -190,6 +190,16 @@ public static Task Backlog(Lens lens, public static Task Health(Lens lens, string? team = null, string? project = null, string? connection = null, CancellationToken ct = default) => McpTools.Run("ado_health", new { team, project, connection }, () => BoardQueries.HealthAsync(lens, connection, project, team, null, ct)); + [McpServerTool(Name = "ado_metrics", ReadOnly = true, Idempotent = true)] + [Description("Team flow: done items and estimate per finished sprint (velocity), cycle and lead time (median, 85%), what is in progress longest.")] + public static Task Metrics(Lens lens, + [Description("Finished sprints to compare (default 6).")] int sprints = 6, + string? team = null, + string? project = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_metrics", new { sprints, team, project, connection }, () => BoardQueries.MetricsAsync(lens, connection, project, team, sprints, null, ct)); + [McpServerTool(Name = "ado_release_notes", ReadOnly = true, Idempotent = true)] [Description("Release notes in markdown: requirements and bugs finished in a sprint (default current) or between dates, grouped by feature.")] public static Task ReleaseNotes(Lens lens, @@ -230,13 +240,32 @@ public static Task Trace(Lens lens, int id, public static Task Changes(Lens lens, [Description("523, C523, PR 482, or a commit sha.")] string change, [Description("Repository name, for commits.")] string? repo = null, - [Description("File name or end of its path.")] string? file = null, + [Description("File name or end of its path; several: a;b;c.")] string? file = null, [Description("summary | normal | full.")] string? detail = null, string? connection = null, CancellationToken ct = default) => McpTools.Run("ado_changes", new { change, repo, file, detail, connection }, () => TraceQueries.ChangesAsync(lens, connection, change, repo, file, detail, ct)); + [McpServerTool(Name = "ado_review", ReadOnly = true, Idempotent = true)] + [Description("Checks a change (523, PR 482, commit + repo) or all changes of a work item (id) before testing: work item linked, labels in every language, X++ messages from labels, no TODO.")] + public static Task ReviewTool(Lens lens, + string? change = null, + int? id = null, + string? repo = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_review", new { change, id, repo, connection }, () => Review.ReviewAsync(lens, connection, change, id, repo, ct)); + + [McpServerTool(Name = "ado_label", ReadOnly = true, Idempotent = true)] + [Description("A D365 message or label: paste the text as seen (values in place of %1 are fine) or @File:Id - the label, its text in every language (MISSING flagged), and the objects and X++ methods, fields or controls using it.")] + public static Task Label(Lens lens, string text, + [Description("Branch, e.g. Main (default: Main or master).")] string? branch = null, + string? project = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_label", new { text, branch, project, connection }, () => Labels.FindAsync(lens, connection, project, text, branch, ct)); + [McpServerTool(Name = "ado_file", ReadOnly = true, Idempotent = true)] [Description("A file from the repository (TFVC $/… path, Git path with repo, or just a file name), X++ objects as their methods (part=method shows one); history=true: who changed it, when, for which work items.")] public static Task ReadFile(Lens lens, string path, @@ -456,6 +485,42 @@ public static Task Link(Lens lens, int id, => McpTools.Run("ado_link", new { id, targets, type, remove, connection }, () => WorkQueries.LinkAsync(lens, connection, id, targets, type, remove, ct)); + [McpServerTool(Name = "ado_create_tree")] + [Description("Creates a tree of work items from an indented list, one per line '- Type: Title | description @person', children indented under their parent; preview first, then confirm=.")] + public static Task CreateTree(Lens lens, string items, + [Description("Existing work item the top items go under.")] int? parent = null, + [Description("Iteration path, 'current' or 'next' for all.")] string? iteration = null, + string? area = null, + string? confirm = null, + string? project = null, + string? connection = null, + CancellationToken ct = default) + => McpTools.Run("ado_create_tree", new { parent, iteration, area, confirm, project, connection }, + () => WorkTree.CreateAsync(lens, connection, project, parent, items, iteration, area, confirm, ct)); + + [McpServerTool(Name = "ado_attachments")] + [Description("Files of a work item and screenshots pasted into its text or comments: the list; file= shows an image or the text of a document (Word, Excel, Outlook e-mail, text); add= attaches a file.")] + public static async Task> AttachmentsTool(Lens lens, int id, + string? file = null, + [Description("Local file to attach.")] string? add = null, + string? comment = null, + [Description("full: the whole text.")] string? detail = null, + string? connection = null, + CancellationToken ct = default) + { + (byte[] Bytes, string Mime)? image = null; + var text = await McpTools.Run("ado_attachments", new { id, file, add, connection }, async () => + { + if (add is not null) return await Attachments.AddAsync(lens, connection, id, add, comment, ct); + var (t, img) = await Attachments.ReadAsync(lens, connection, id, file, detail, ct); + image = img; + return t; + }); + var blocks = new List { new ModelContextProtocol.Protocol.TextContentBlock { Text = text } }; + if (image is { } i) blocks.Add(ModelContextProtocol.Protocol.ImageContentBlock.FromBytes(i.Bytes, i.Mime)); + return blocks; + } + [McpServerTool(Name = "ado_search", ReadOnly = true, Idempotent = true)] [Description("One search over work items, wiki pages and code of the project, grouped - the start for \"how does X work\".")] public static Task Search(Lens lens, string text, diff --git a/src/AdoLens/Metrics.cs b/src/AdoLens/Metrics.cs new file mode 100644 index 0000000..b6af344 --- /dev/null +++ b/src/AdoLens/Metrics.cs @@ -0,0 +1,104 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace AdoLens; + +/// Team flow for a Product Owner: velocity and throughput per sprint, cycle time, what is stuck longest. +public static partial class BoardQueries +{ + const string ActivatedRef = "Microsoft.VSTS.Common.ActivatedDate"; + const string ClosedRef = "Microsoft.VSTS.Common.ClosedDate"; + const string StateChangeRef = "Microsoft.VSTS.Common.StateChangeDate"; + + static DateTime? DateField(JsonElement wi, string reference) => + DateTime.TryParse(WorkItems.Field(wi, reference), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var d) ? d : null; + + static double Percentile(List sorted, double p) + { + if (sorted.Count == 0) return 0; + var i = (int)Math.Ceiling(p * sorted.Count) - 1; + return sorted[Math.Clamp(i, 0, sorted.Count - 1)]; + } + + public static async Task MetricsAsync(Lens lens, string? connection, string? project, string? team, int sprints, DateTime? today = null, CancellationToken ct = default) + { + var session = lens.For(connection); + var name = session.Project(project); + var boards = new Boards(session); + var t = await boards.TeamAsync(name, team, ct); + var day = (today ?? DateTime.Now).Date; + var s = (await session.Meta.GetAsync(name, ct)).Snapshot; + var estimate = Estimates.FirstOrDefault(e => s.Fields.ContainsKey(e)); + sprints = Math.Clamp(sprints, 1, 12); + + var sb = new StringBuilder($"flow of {t} ({name})\n"); + + // Per finished sprint: what was done in it (requirements and bugs), and their estimate. + var past = (await boards.IterationsAsync(name, t, ct)).Where(i => i.Finish is { } f && f < day).OrderByDescending(i => i.Finish).Take(sprints).Reverse().ToList(); + if (past.Count > 0) + { + sb.AppendLine(estimate is null ? "sprint | done items" : $"sprint | done items | done {s.Fields[estimate].Name.ToLowerInvariant()}"); + var counts = new List(); + var points = new List(); + foreach (var it in past) + { + var (ids, parent) = await boards.SprintItemsAsync(name, t, it, ct); + var top = ids.Where(i => !parent.ContainsKey(i)).ToList(); + var fields = new List { "System.Id", "System.WorkItemType", "System.State" }; + if (estimate is not null) fields.Add(estimate); + var items = top.Count == 0 ? [] : await session.Work.GetAsync(top, relations: false, fields, ct); + var done = items.Where(w => Category(s, w) == "Completed" && Level(s, w) is Levels.Requirement or Levels.Bug).ToList(); + counts.Add(done.Count); + var sum = estimate is null ? 0 : done.Sum(w => Number(w, estimate) ?? 0); + points.Add(sum); + sb.AppendLine($"{it.Name} | {done.Count}{(estimate is null ? "" : $" | {Num(sum)}")}"); + } + sb.Append($"average: {Num(Math.Round(counts.Average(), 1))} items"); + if (estimate is not null) sb.Append($", {Num(Math.Round(points.Average(), 1))} {s.Fields[estimate].Name.ToLowerInvariant()}"); + sb.AppendLine(" per sprint"); + } + else sb.AppendLine("no finished sprints of the team"); + + // Cycle time: from start of work to done, for what was finished in the period of those sprints. + var since = past.FirstOrDefault()?.Start ?? day.AddDays(-90); + var types = BacklogTypes(s); + var doneStates = types.SelectMany(x => x.States).Where(x => x.Category == "Completed").Select(x => x.Name).ToList(); + var area = await AreaAsync(boards, name, t, ct); + if (types.Count > 0 && doneStates.Count > 0) + { + var (doneIds, _) = await session.Work.WiqlAsync(name, + $"SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] IN {InList(types.Select(x => x.Name))}" + + $" AND [System.State] IN {InList(doneStates)} AND [{StateChangeRef}] >= '{since:yyyy-MM-dd}'" + area, 1000, ct); + var finished = doneIds.Count == 0 ? [] : await session.Work.GetAsync(doneIds, relations: false, + ["System.Id", "System.WorkItemType", "System.Title", "System.CreatedDate", ActivatedRef, ClosedRef, StateChangeRef], ct); + var cycle = finished.Select(w => (Start: DateField(w, ActivatedRef), End: DateField(w, ClosedRef) ?? DateField(w, StateChangeRef))) + .Where(x => x.Start is not null && x.End is not null && x.End >= x.Start).Select(x => (x.End!.Value - x.Start!.Value).TotalDays).Order().ToList(); + var lead = finished.Select(w => (Start: DateField(w, "System.CreatedDate"), End: DateField(w, ClosedRef) ?? DateField(w, StateChangeRef))) + .Where(x => x.Start is not null && x.End is not null && x.End >= x.Start).Select(x => (x.End!.Value - x.Start!.Value).TotalDays).Order().ToList(); + if (cycle.Count > 0) + sb.AppendLine($"cycle time (work started -> done), {cycle.Count} items since {since:yyyy-MM-dd}: median {Num(Math.Round(Percentile(cycle, 0.5), 1))} days, 85% within {Num(Math.Round(Percentile(cycle, 0.85), 1))} days"); + if (lead.Count > 0) + sb.AppendLine($"lead time (created -> done): median {Num(Math.Round(Percentile(lead, 0.5), 1))} days, 85% within {Num(Math.Round(Percentile(lead, 0.85), 1))} days"); + } + + // In progress for longest. + var inProgress = types.SelectMany(x => x.States).Where(x => x.Category is "InProgress" or "Resolved").Select(x => x.Name).ToList(); + if (inProgress.Count > 0) + { + var (openIds, _) = await session.Work.WiqlAsync(name, + $"SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] IN {InList(types.Select(x => x.Name))}" + + $" AND [System.State] IN {InList(inProgress)}" + area + $" ORDER BY [{ActivatedRef}] ASC", 1000, ct); + var open = openIds.Count == 0 ? [] : await session.Work.GetAsync(openIds.Take(200).ToList(), relations: false, + ["System.Id", "System.WorkItemType", "System.State", "System.Title", "System.AssignedTo", ActivatedRef, "System.CreatedDate"], ct); + var aged = open.Select(w => (W: w, Since: DateField(w, ActivatedRef) ?? DateField(w, "System.CreatedDate"))) + .Where(x => x.Since is not null).OrderBy(x => x.Since).Take(5).ToList(); + if (aged.Count > 0) + { + sb.AppendLine($"in progress longest ({openIds.Count}{(openIds.Count >= 1000 ? "+" : "")} in progress):"); + foreach (var (w, start) in aged) sb.AppendLine($"- {WorkItemFormat.Header(w)}{Who(w)}: {(day - start!.Value.ToLocalTime().Date).Days} days"); + } + } + return sb.ToString().TrimEnd(); + } +} diff --git a/src/AdoLens/OutlookMsg.cs b/src/AdoLens/OutlookMsg.cs new file mode 100644 index 0000000..aa804e1 --- /dev/null +++ b/src/AdoLens/OutlookMsg.cs @@ -0,0 +1,169 @@ +using System.Text; + +namespace AdoLens; + +/// +/// An Outlook e-mail (.msg): a Compound File (CFB) whose top-level streams hold the message properties. Read without +/// any library: subject, sender, recipients, date, the plain-text body (or the HTML one), and the names of the files +/// attached to it. +/// +public static class OutlookMsg +{ + const uint Free = 0xFFFFFFFF, EndOfChain = 0xFFFFFFFE; + + sealed record Entry(string Name, int Type, uint Start, long Size, uint Left, uint Right, uint Child); + + /// The compound file: its directory and how to read a stream. + sealed class Cfb + { + readonly byte[] b; + readonly int sectorSize, miniSectorSize; + readonly uint cutoff; + readonly List fat = [], miniFat = []; + public readonly List Entries = []; + byte[] miniStream = []; + + public Cfb(byte[] bytes) + { + b = bytes; + if (b.Length < 512 || BitConverter.ToUInt64(b, 0) != 0xE11AB1A1E011CFD0) throw new InvalidDataException("not a compound file"); + sectorSize = 1 << BitConverter.ToUInt16(b, 0x1E); + miniSectorSize = 1 << BitConverter.ToUInt16(b, 0x20); + cutoff = BitConverter.ToUInt32(b, 0x38); + // The FAT sectors: 109 in the header, the rest in a chain of DIFAT sectors. + var fatSectors = new List(); + for (var i = 0; i < 109; i++) fatSectors.Add(BitConverter.ToUInt32(b, 0x4C + i * 4)); + var difat = BitConverter.ToUInt32(b, 0x44); + for (var guard = 0; difat != EndOfChain && difat != Free && guard < 10000; guard++) + { + var off = Offset(difat); + for (var i = 0; i < sectorSize / 4 - 1; i++) fatSectors.Add(BitConverter.ToUInt32(b, off + i * 4)); + difat = BitConverter.ToUInt32(b, off + sectorSize - 4); + } + foreach (var s in fatSectors.Where(s => s != Free && s != EndOfChain)) + { + var off = Offset(s); + for (var i = 0; i < sectorSize / 4 && off + i * 4 + 4 <= b.Length; i++) fat.Add(BitConverter.ToUInt32(b, off + i * 4)); + } + var dir = Chain(BitConverter.ToUInt32(b, 0x30)); + for (var i = 0; i + 128 <= dir.Length; i += 128) + { + var nameLength = BitConverter.ToUInt16(dir, i + 64); + var name = nameLength >= 2 ? Encoding.Unicode.GetString(dir, i, Math.Min(64, (int)nameLength) - 2) : ""; + Entries.Add(new Entry(name, dir[i + 66], BitConverter.ToUInt32(dir, i + 116), (long)BitConverter.ToUInt64(dir, i + 120) & 0xFFFFFFFF, + BitConverter.ToUInt32(dir, i + 68), BitConverter.ToUInt32(dir, i + 72), BitConverter.ToUInt32(dir, i + 76))); + } + if (Entries.Count > 0) + { + miniStream = Chain(Entries[0].Start); + var mf = Chain(BitConverter.ToUInt32(b, 0x3C)); + for (var i = 0; i + 4 <= mf.Length; i += 4) miniFat.Add(BitConverter.ToUInt32(mf, i)); + } + } + + int Offset(uint sector) => (int)((sector + 1) * (uint)sectorSize); + + byte[] Chain(uint start) + { + if (start == EndOfChain || start == Free) return []; + using var ms = new MemoryStream(); + for (var (s, guard) = (start, 0); s != EndOfChain && s < fat.Count && guard < 1_000_000; s = fat[(int)s], guard++) + { + var off = Offset(s); + if (off >= b.Length) break; + ms.Write(b, off, Math.Min(sectorSize, b.Length - off)); + } + return ms.ToArray(); + } + + public byte[] Read(Entry e) + { + if (e.Size < cutoff) + { + using var ms = new MemoryStream(); + for (var (s, guard) = (e.Start, 0); s != EndOfChain && s < miniFat.Count && guard < 1_000_000; s = miniFat[(int)s], guard++) + { + var off = (int)s * miniSectorSize; + if (off >= miniStream.Length) break; + ms.Write(miniStream, off, Math.Min(miniSectorSize, miniStream.Length - off)); + } + return ms.ToArray()[..(int)Math.Min(e.Size, ms.Length)]; + } + var all = Chain(e.Start); + return all[..(int)Math.Min(e.Size, all.Length)]; + } + + /// The entries directly inside a storage: the red-black tree under its child. + public List Children(Entry storage) + { + var list = new List(); + var stack = new Stack(); + if (storage.Child != Free) stack.Push(storage.Child); + while (stack.Count > 0 && list.Count < 10000) + { + var i = stack.Pop(); + if (i >= Entries.Count) continue; + var e = Entries[(int)i]; + list.Add(e); + if (e.Left != Free) stack.Push(e.Left); + if (e.Right != Free) stack.Push(e.Right); + } + return list; + } + } + + /// The e-mail as text, or null when the file is not an Outlook message. + public static string? Read(byte[] bytes) + { + Cfb cfb; + try { cfb = new Cfb(bytes); } + catch (Exception ex) when (ex is InvalidDataException or ArgumentException or IndexOutOfRangeException) { return null; } + if (cfb.Entries.Count == 0) return null; + var top = cfb.Children(cfb.Entries[0]); + string? Text(IEnumerable scope, int id) + { + var hex = id.ToString("X4"); + var unicode = scope.FirstOrDefault(e => e.Name.Equals($"__substg1.0_{hex}001F", StringComparison.OrdinalIgnoreCase)); + if (unicode is not null) return Encoding.Unicode.GetString(cfb.Read(unicode)).TrimEnd('\0'); + var ansi = scope.FirstOrDefault(e => e.Name.Equals($"__substg1.0_{hex}001E", StringComparison.OrdinalIgnoreCase)); + return ansi is null ? null : Encoding.Latin1.GetString(cfb.Read(ansi)).TrimEnd('\0'); + } + var sb = new StringBuilder(); + void Line(string label, string? value) { if (!string.IsNullOrWhiteSpace(value)) sb.AppendLine($"{label}: {value.Trim()}"); } + Line("Subject", Text(top, 0x0037)); + var from = Text(top, 0x0C1A); + var fromMail = Text(top, 0x5D01) ?? Text(top, 0x0C1F); + Line("From", from is null ? fromMail : fromMail is null || !fromMail.Contains('@') ? from : $"{from} <{fromMail}>"); + Line("To", Text(top, 0x0E04)); + Line("Cc", Text(top, 0x0E03)); + if (SentAt(cfb, top) is { } sent) Line("Sent", sent.ToString("yyyy-MM-dd HH:mm")); + var attachments = top.Where(e => e.Type == 1 && e.Name.StartsWith("__attach_version1.0_", StringComparison.OrdinalIgnoreCase)) + .Select(a => cfb.Children(a)).Select(c => Text(c, 0x3707) ?? Text(c, 0x3704)).Where(n => n is not null).ToList(); + if (attachments.Count > 0) Line("Attachments", string.Join(", ", attachments)); + sb.AppendLine(); + var body = Text(top, 0x1000); + if (string.IsNullOrWhiteSpace(body) && top.FirstOrDefault(e => e.Name.Equals("__substg1.0_10130102", StringComparison.OrdinalIgnoreCase)) is { } html) + body = Html.ToMarkdown(Encoding.UTF8.GetString(cfb.Read(html))); + sb.Append(string.IsNullOrWhiteSpace(body) ? "(no text body - the message may be in RTF only)" : body.Replace("\r\n", "\n").Trim()); + return sb.ToString(); + } + + /// PR_CLIENT_SUBMIT_TIME (0x0039) from the top-level property stream. + static DateTime? SentAt(Cfb cfb, List top) + { + var props = top.FirstOrDefault(e => e.Name == "__properties_version1.0"); + if (props is null) return null; + var data = cfb.Read(props); + // The top level has a 32-byte header, then 16-byte entries: type (2), id (2), flags (4), value (8). + for (var i = 32; i + 16 <= data.Length; i += 16) + { + if (BitConverter.ToUInt16(data, i) == 0x0040 && BitConverter.ToUInt16(data, i + 2) is 0x0039 or 0x0E06) + { + var ft = BitConverter.ToInt64(data, i + 8); + try { return DateTime.FromFileTimeUtc(ft).ToLocalTime(); } + catch (ArgumentOutOfRangeException) { return null; } + } + } + return null; + } +} diff --git a/src/AdoLens/Program.cs b/src/AdoLens/Program.cs index 74c94f6..c296f9a 100644 --- a/src/AdoLens/Program.cs +++ b/src/AdoLens/Program.cs @@ -90,12 +90,23 @@ return await cli.WikiWriteAsync(); case "search": return await cli.SearchAsync(); + case "skills": + Console.WriteLine(Skills.Sync(Settings.Load(Settings.DefaultPath).Mode)); + return 0; + case "review": + return await cli.ReviewAsync(); + case "attachments": + return await cli.AttachmentsAsync(); + case "label": + return await cli.LabelAsync(); case "file": return await cli.FileAsync(); case "tests": return await cli.TestsAsync(); case "test-create": return await cli.TestCreateAsync(); + case "metrics": + return await cli.MetricsAsync(); case "health": return await cli.HealthAsync(); case "release-notes": @@ -150,7 +161,8 @@ async Task RunServerAsync() o.ServerInstructions = McpTools.Instructions(lens.Settings); }) .WithStdioServerTransport() - .WithActiveTools(lens.Settings); + .WithActiveTools(lens.Settings) + .WithPrompts([typeof(Prompts)]); await builder.Build().RunAsync(); return 0; } diff --git a/src/AdoLens/Prompts.cs b/src/AdoLens/Prompts.cs new file mode 100644 index 0000000..a3a5fe3 --- /dev/null +++ b/src/AdoLens/Prompts.cs @@ -0,0 +1,111 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +namespace AdoLens; + +/// +/// MCP prompts: ready commands in Claude Desktop ("+" → the server) and Claude Code ("/mcp__ado-lens__…"). Each one is +/// the instructions of the matching skill (read from the skills folder installed next to the program) plus the request, +/// so they work without uploading the skills to Claude Desktop. Prompts are not sent to the model until chosen, so they +/// cost no tokens per session. +/// +[McpServerPromptType] +public static class Prompts +{ + /// The installed skills: …\ADO Lens\skills (the program is in …\ADO Lens\bin); in a source tree, skills\ above. + internal static string? SkillsDir() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + for (var i = 0; i < 7 && dir is not null; i++, dir = dir.Parent) + if (Directory.Exists(Path.Combine(dir.FullName, "skills"))) return Path.Combine(dir.FullName, "skills"); + return null; + } + + /// A skill's instructions without its front matter, or when it is not installed. + internal static string Skill(string name, string fallback) + { + if (SkillsDir() is { } dir && Path.Combine(dir, name, "SKILL.md") is var path && File.Exists(path)) + { + var text = File.ReadAllText(path); + if (text.StartsWith("---", StringComparison.Ordinal) && text.IndexOf("\n---", 3, StringComparison.Ordinal) is var end and > 0) + text = text[(end + 4)..]; + return text.Trim(); + } + return fallback; + } + + static string Ask(string skill, string fallback, string request) => + $"{Skill(skill, fallback)}\n\n---\nRequest: {request.Trim()}\nAnswer in the language of the request (Polish request → Polish answer)."; + + [McpServerPrompt(Name = "verify", Title = "Check a work item: done as asked, and where is it")] + [Description("Was a work item done as asked and where is it (DEV / UAT / PROD) - criterion by criterion.")] + public static string Verify([Description("Work item id, e.g. 37027.")] string id) => + Ask("po-verify", "Use ado_trace on the work item, read the changes with ado_changes, judge each acceptance criterion done / to check / missing, say where it is deployed.", + $"Was work item #{id.TrimStart('#')} done as asked, and where is it?"); + + [McpServerPrompt(Name = "explain", Title = "Explain how a feature works")] + [Description("How a feature works and why, in business language, with sources.")] + public static string Explain([Description("The feature, in your words.")] string topic) => + Ask("po-explain", "Use ado_search, read the strongest sources (wiki, work items, code with ado_file), explain in plain language with sources; separate code from documentation.", + $"How does this work: {topic}"); + + [McpServerPrompt(Name = "story", Title = "Write a work item")] + [Description("A requirement, bug or task written short and complete, with acceptance criteria, then created after you agree.")] + public static string Story([Description("What is needed, in your words.")] string need, + [Description("Parent work item id (optional).")] string? parent = null) => + Ask("po-story", "Write the work item with the project's real types and fields (ado_fields): goal, scope, testable acceptance criteria; show it before ado_create.", + $"Write a work item for: {need}{(string.IsNullOrWhiteSpace(parent) ? "" : $" (parent #{parent.TrimStart('#')})")}"); + + [McpServerPrompt(Name = "sprint", Title = "Sprint status or review")] + [Description("How the sprint is going, what is at risk, what was delivered and where it is.")] + public static string Sprint([Description("current (default), previous, next or a sprint name.")] string? sprint = null) => + Ask("po-sprint", "Use ado_sprint, then ado_release_notes for what was finished; summarize progress, risks and deliveries for stakeholders.", + $"Summarize the {(string.IsNullOrWhiteSpace(sprint) ? "current" : sprint)} sprint."); + + [McpServerPrompt(Name = "refine", Title = "Backlog refinement")] + [Description("What in the backlog needs work before planning: missing criteria, estimates, too big items, questions for the team.")] + public static string Refine([Description("Team (optional).")] string? team = null) => + Ask("po-refine", "Use ado_health and ado_backlog; propose concrete fixes and questions, split items that are too big; change nothing without the user's yes.", + $"Prepare backlog refinement{(string.IsNullOrWhiteSpace(team) ? "" : $" for team {team}")}."); + + [McpServerPrompt(Name = "test", Title = "Test cases from acceptance criteria")] + [Description("Test cases written from a work item's acceptance criteria, and how its tests stand.")] + public static string Test([Description("Work item id.")] string id) => + Ask("po-test", "Read the item (ado_work_item) and its tests (ado_tests); propose one test case per criterion, show them, create with ado_test_create after the user agrees.", + $"Test cases for work item #{id.TrimStart('#')}."); + + [McpServerPrompt(Name = "wiki", Title = "Write a documentation page")] + [Description("A wiki page about a feature or a delivered change, from work items and code.")] + public static string Wiki([Description("What the page is about.")] string topic) => + Ask("po-wiki", "Gather the facts (ado_search, ado_work_item, ado_file), draft the page in markdown, ask where to put it, save with ado_wiki_write after the user agrees.", + $"Write a wiki page about: {topic}"); + + [McpServerPrompt(Name = "label", Title = "Where does this message come from")] + [Description("A D365 message pasted as seen on screen: its label, translations, and where and in which process it appears.")] + public static string Label([Description("The message text as seen (values in it are fine).")] string text) => + $"Use ado_label with this text, then read the method that shows it (ado_file part=) and explain in plain language when the user sees this message and what to do about it.\n\nMessage: {text.Trim()}\nAnswer in the language of the message."; + + [McpServerPrompt(Name = "dev_start", Title = "Start work on a work item")] + [Description("Developer: what to do, where in the code, a plan from the acceptance criteria.")] + public static string DevStart([Description("Work item id.")] string id) => + Ask("dev-start", "Read the item and its parent (ado_work_item), find the code involved (ado_search, ado_file), propose a plan per criterion; set the state only when asked.", + $"Start work on #{id.TrimStart('#')}."); + + [McpServerPrompt(Name = "dev_pr", Title = "Prepare or review a pull request")] + [Description("Developer: a PR description from the work item, or a review of a PR against its work item.")] + public static string DevPr([Description("Work item id or 'PR '.")] string target) => + Ask("dev-pr", "For a work item: write the PR description (what and why, changes by area, how to check). For a PR: ado_pr, ado_changes, ado_review, compare with the work item's criteria.", + $"Pull request for {target}."); + + [McpServerPrompt(Name = "dev_build", Title = "Why is the build red")] + [Description("Developer: the failing build's errors and a proposed fix.")] + public static string DevBuild([Description("Build id (optional: the latest failed).")] string? build = null) => + Ask("dev-build", "Use ado_pipeline (with build for details), read the error lines, find the code (ado_file), propose the fix.", + string.IsNullOrWhiteSpace(build) ? "Why did the latest build fail?" : $"Why did build {build} fail?"); + + [McpServerPrompt(Name = "dev_why", Title = "Why is this code like this")] + [Description("Developer: the history of a file or object - who changed it, when, for which work items, and why.")] + public static string DevWhy([Description("File path or name, e.g. SalesTable.xml.")] string file) => + Ask("dev-why", "Use ado_file history=true, read the work items behind the important changes and the diffs (ado_changes), explain the decisions.", + $"Why is {file} the way it is?"); +} diff --git a/src/AdoLens/Review.cs b/src/AdoLens/Review.cs new file mode 100644 index 0000000..d5faccc --- /dev/null +++ b/src/AdoLens/Review.cs @@ -0,0 +1,208 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace AdoLens; + +/// +/// Checks of a code change before it goes to testing: a work item is linked, every label it adds or uses exists in +/// every language, messages in X++ come from labels, no TODO is left behind. +/// +public static partial class Review +{ + [GeneratedRegex(@"@(?[A-Za-z][A-Za-z0-9_]*):(?[A-Za-z_][A-Za-z0-9_]*)")] + private static partial Regex LabelRef(); + + // A message shown to the user with a literal text instead of a label. + [GeneratedRegex(@"\b(?info|warning|error|checkFailed|throw\s+error|Box::\w+)\s*\(\s*(?:strFmt\s*\(\s*)?""(?[^""@][^""]{2,})""", RegexOptions.IgnoreCase)] + private static partial Regex Hardcoded(); + + [GeneratedRegex(@"\b(TODO|FIXME|HACK)\b")] + private static partial Regex Todo(); + + [GeneratedRegex(@"vstfs:///VersionControl/Changeset/(?\d+)|vstfs:///Git/PullRequestId/[^%]+%2[Ff][^%]+%2[Ff](?\d+)", RegexOptions.IgnoreCase)] + private static partial Regex ArtifactLink(); + + /// A change (changeset, PR, commit) or every change linked to a work item and its tasks. + public static async Task ReviewAsync(Lens lens, string? connection, string? change, int? id, string? repo, CancellationToken ct = default) + { + var session = lens.For(connection); + var refs = new List(); + var sb = new StringBuilder(); + if (change is not null) refs.Add(change); + else if (id is { } wid) + { + var items = await session.Work.GetAsync([wid], relations: true, ct: ct); + if (items.Count == 0) return $"no work item #{wid} visible with this PAT"; + var children = Json.Arr(items[0], "relations").Where(r => Json.Str(r, "rel") == "System.LinkTypes.Hierarchy-Forward") + .Select(r => Audit.TargetOf(Json.Str(r, "url")) ?? 0).Where(i => i > 0).ToList(); + var all = children.Count == 0 ? items : items.Concat(await session.Work.GetAsync(children, relations: true, ct: ct)).ToList(); + foreach (var r in all.SelectMany(w => Json.Arr(w, "relations")).Where(r => Json.Str(r, "rel") == "ArtifactLink")) + if (ArtifactLink().Match(Json.Str(r, "url") ?? "") is { Success: true } m) + refs.Add(m.Groups["cs"].Success ? m.Groups["cs"].Value : "PR " + m.Groups["pr"].Value); + refs = refs.Distinct().ToList(); + if (refs.Count == 0) return $"{WorkItemFormat.Header(items[0])}: no changesets or pull requests linked to it or its tasks"; + sb.AppendLine($"{WorkItemFormat.Header(items[0])}: {refs.Count} change(s)"); + } + else throw new ArgumentException("give a change (523, PR 482, commit sha with repo) or a work item id"); + + foreach (var r in refs.Take(8)) + { + sb.AppendLine(await ReviewOneAsync(session, r, repo, ct)); + } + if (refs.Count > 8) sb.AppendLine($"… {refs.Count - 8} more changes not checked"); + return sb.ToString().TrimEnd(); + } + + static async Task ReviewOneAsync(Session session, string change, string? repo, CancellationToken ct) + { + var (c, versions) = await TraceQueries.ResolveAsync(session, change, repo, ct); + var client = session.Client; + var sb = new StringBuilder($"{c.Label}: {c.Files.Count} file(s)\n"); + var issues = 0; + + // 1. Work item. + List linked = []; + try + { + linked = c.Kind switch + { + "changeset" => (await client.GetListAsync($"_apis/tfvc/changesets/{c.Id}/workItems", ct)).Select(w => w.TryGetProperty("id", out var i) ? i.GetInt32() : 0).ToList(), + "pr" => (await client.GetListAsync($"_apis/git/repositories/{c.RepoId}/pullRequests/{c.Id}/workitems", ct)).Select(w => int.TryParse(Json.Str(w, "id"), out var i) ? i : 0).ToList(), + _ => WorkQueries.ParseIds(Regex.Match(c.Message ?? "", @"#\d+").Value), + }; + } + catch (AdoApiException) { } + linked = linked.Where(i => i > 0).Distinct().ToList(); + if (linked.Count == 0) { sb.AppendLine("- CHECK no work item linked"); issues++; } + + // 2. Labels: added in label files or used in code, and present in every language of their label file. + var labelFiles = c.Files.Where(f => f.Path.Contains("/AxLabelFile/LabelResources/", StringComparison.OrdinalIgnoreCase) && !f.ChangeType.Contains("delete")).ToList(); + var wanted = new Dictionary>(StringComparer.OrdinalIgnoreCase); // label file id -> label ids + var folders = new Dictionary(StringComparer.OrdinalIgnoreCase); // label file id -> LabelResources folder + foreach (var f in labelFiles) + { + var (before, after) = await versions(f); + var name = f.Path[(f.Path.LastIndexOf('/') + 1)..]; + var fileId = name.Split('.')[0]; + folders[fileId] = f.Path[..(f.Path.IndexOf("/LabelResources/", StringComparison.OrdinalIgnoreCase) + "/LabelResources".Length)]; + var old = before is null ? [] : Labels.Parse(before); + foreach (var (lid, text) in after is null ? [] : Labels.Parse(after)) + if (!old.TryGetValue(lid, out var was) || was != text) Add(wanted, fileId, lid); + } + var hardcoded = new List(); + var todos = new List(); + foreach (var f in c.Files.Where(f => AotDiff.IsAot(f.Path) && !f.ChangeType.Contains("delete")).Take(40)) + { + var (before, after) = await versions(f); + if (after is null) continue; + var addedLines = AddedLines(before, after); + foreach (var (method, line) in addedLines) + { + foreach (Match m in LabelRef().Matches(line)) Add(wanted, m.Groups["file"].Value, m.Groups["id"].Value); + if (Hardcoded().Match(line) is { Success: true } h) hardcoded.Add($"{AotDiff.ObjectName(f.Path)}.{method}: {h.Groups["call"].Value}(\"{Formatting.Collapse(h.Groups["text"].Value, 50)}\")"); + if (Todo().IsMatch(line)) todos.Add($"{AotDiff.ObjectName(f.Path)}.{method}: {Formatting.Collapse(line.Trim(), 70)}"); + } + // Labels named in the metadata (Label, HelpText of fields and controls) of the new version. + foreach (Match m in LabelRef().Matches(StripSource(after) ?? "")) + if (before is null || !(StripSource(before) ?? "").Contains(m.Value, StringComparison.Ordinal)) Add(wanted, m.Groups["file"].Value, m.Groups["id"].Value); + } + + var missing = new List(); + foreach (var (fileId, ids) in wanted) + { + if (fileId.Equals("SYS", StringComparison.OrdinalIgnoreCase) || ids.Count == 0) continue; + var folder = folders.GetValueOrDefault(fileId) ?? await FindFolderAsync(session, c, fileId, ct); + if (folder is null) continue; // a label file of another model (Microsoft's): not checked + var languages = await LanguageFilesAsync(session, c, folder, fileId, ct); + foreach (var (lang, path) in languages) + { + if (!session.Config.LabelLanguageRequired(lang)) continue; + var text = await ReadAtAsync(session, c, path, ct); + var map = text is null ? [] : Labels.Parse(text); + var absent = ids.Where(i => !map.ContainsKey(i)).ToList(); + if (absent.Count > 0) missing.Add($"{lang}: {string.Join(", ", absent.Take(8).Select(i => $"@{fileId}:{i}"))}{(absent.Count > 8 ? $" (+{absent.Count - 8})" : "")}"); + } + } + if (missing.Count > 0) { sb.AppendLine("- CHECK labels missing in a language:"); foreach (var m in missing) sb.AppendLine(" " + m); issues++; } + if (hardcoded.Count > 0) { sb.AppendLine("- CHECK messages written in code instead of labels (not translated):"); foreach (var h in hardcoded.Take(10)) sb.AppendLine(" " + h); issues++; } + if (todos.Count > 0) { sb.AppendLine("- CHECK TODO left:"); foreach (var t in todos.Take(10)) sb.AppendLine(" " + t); issues++; } + if (issues == 0) sb.AppendLine($"- OK: work item {string.Join(", ", linked.Select(i => "#" + i))}, labels in all languages, messages from labels, no TODO"); + return sb.ToString().TrimEnd(); + } + + static void Add(Dictionary> map, string file, string id) + { + if (!map.TryGetValue(file, out var set)) map[file] = set = new HashSet(StringComparer.OrdinalIgnoreCase); + set.Add(id); + } + + /// Lines of X++ that are new in , with the method they are in. + internal static List<(string Method, string Line)> AddedLines(string? before, string after) + { + var a = Parse(before); + var b = Parse(after); + var oldMethods = AotDiff.Methods(a); + var result = new List<(string, string)>(); + foreach (var (name, source) in AotDiff.Methods(b)) + { + var was = oldMethods.GetValueOrDefault(name); + if (was == source) continue; + if (was is null) result.AddRange(Diff.Split(source).Select(l => (name, l))); + else result.AddRange(Diff.Lines(Diff.Split(was), Diff.Split(source)).Where(x => x.Op == Diff.Op.Add).Select(x => (name, x.Line))); + } + return result; + } + + static XDocument? Parse(string? xml) + { + if (string.IsNullOrWhiteSpace(xml)) return null; + try { return XDocument.Parse(xml); } + catch (System.Xml.XmlException) { return null; } + } + + static string? StripSource(string? xml) => Parse(xml) is { } d ? AotDiff.Metadata(d) : null; + + /// The LabelResources folder of a label file id on the change's branch (TFVC) or repository (Git). + static async Task FindFolderAsync(Session session, CodeChange c, string fileId, CancellationToken ct) + { + // The model folder of a changed AOT file: …/Metadata///Ax… → …/Metadata///AxLabelFile/LabelResources + foreach (var f in c.Files.Where(f => AotDiff.IsAot(f.Path))) + { + var p = f.Path; + var ax = p.LastIndexOf("/Ax", StringComparison.Ordinal); + if (ax <= 0) continue; + var folder = p[..ax] + "/AxLabelFile/LabelResources"; + var files = await ListAsync(session, c, folder, ct); + if (files.Any(x => x[(x.LastIndexOf('/') + 1)..].StartsWith(fileId + ".", StringComparison.OrdinalIgnoreCase))) return folder; + } + return null; + } + + static async Task> LanguageFilesAsync(Session session, CodeChange c, string folder, string fileId, CancellationToken ct) => + (await ListAsync(session, c, folder, ct)) + .Where(p => p.EndsWith(".label.txt", StringComparison.OrdinalIgnoreCase) && p[(p.LastIndexOf('/') + 1)..].StartsWith(fileId + ".", StringComparison.OrdinalIgnoreCase)) + .Select(p => (p[(p.LastIndexOf('/') + 1)..^".label.txt".Length][(fileId.Length + 1)..], p)) + .OrderBy(x => x.Item1, StringComparer.OrdinalIgnoreCase).ToList(); + + static async Task> ListAsync(Session session, CodeChange c, string folder, CancellationToken ct) + { + try + { + var version = c.HeadCommit ?? c.LandedCommit ?? (c.Kind == "commit" ? c.Id : null); + var url = c.Kind == "changeset" + ? $"_apis/tfvc/items?scopePath={Uri.EscapeDataString(folder)}&recursionLevel=Full&versionDescriptor.version={c.Id}&versionDescriptor.versionType=changeset" + : $"_apis/git/repositories/{c.RepoId}/items?scopePath={Uri.EscapeDataString(folder)}&recursionLevel=Full" + + (version is null ? "" : $"&versionDescriptor.version={version}&versionDescriptor.versionType=commit"); + return (await session.Client.GetListAsync(url, ct)).Select(i => Json.Str(i, "path")).Where(p => p is not null).Select(p => p!).ToList(); + } + catch (AdoApiException) { return []; } + } + + static Task ReadAtAsync(Session session, CodeChange c, string path, CancellationToken ct) => + c.Kind == "changeset" + ? session.Code.TfvcFileAsync(path, int.Parse(c.Id, CultureInfo.InvariantCulture), ct) + : session.Code.GitFileAsync(c.RepoId!, path, c.HeadCommit ?? c.LandedCommit ?? c.Id, ct); +} diff --git a/src/AdoLens/Settings.cs b/src/AdoLens/Settings.cs index 8832781..bbeee87 100644 --- a/src/AdoLens/Settings.cs +++ b/src/AdoLens/Settings.cs @@ -16,6 +16,12 @@ public sealed class Connection /// e.g. "Main"). Azure DevOps does not know it; ado_trace uses it to say where a change has arrived. /// public Dictionary Environments { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Languages every label must have (e.g. en-US, pl); others found in the label files are not reported as missing. + /// Empty: every language of the label file is required. + /// + public List LabelLanguages { get; set; } = []; } /// Non-secret settings (adolens.json in the data folder). PATs live in Windows Credential Manager. @@ -40,10 +46,15 @@ public sealed partial class Settings /// Ends every answer with its estimated size, e.g. "[~230 tokens]" (a few tokens each, so off by default). public bool ShowTokens { get; set; } + /// Language of the settings console and installer texts: "pl", "en", or null for the language of Windows. + public string? Language { get; set; } + static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + // People read and edit this file: "UAT + SCM" and Polish names stay as typed, not +. + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; /// %LOCALAPPDATA%\ado-lens (ADOLENS_DATA_DIR overrides it): settings, metadata, usage log. @@ -62,7 +73,10 @@ public static Settings Load(string path) // The deserializer replaces the dictionaries, so the case-insensitive comparers have to be restored. s.Connections = new Dictionary(s.Connections, StringComparer.OrdinalIgnoreCase); foreach (var c in s.Connections.Values) + { c.Environments = new Dictionary(c.Environments ?? [], StringComparer.OrdinalIgnoreCase); + c.LabelLanguages ??= []; + } return s; } @@ -127,6 +141,13 @@ public sealed record Config(string Connection, string Organization, string Pat, /// Branch → environment label of the connection (see ). public IReadOnlyDictionary Environments { get; init; } = new Dictionary(); + /// See . + public IReadOnlyList LabelLanguages { get; init; } = []; + + /// Whether a missing label text in this language is worth reporting. + public bool LabelLanguageRequired(string language) => + LabelLanguages.Count == 0 || LabelLanguages.Any(l => l.Equals(language, StringComparison.OrdinalIgnoreCase)); + public const string ConnectionVar = "ADOLENS_CONNECTION"; public const string OrgVar = "AZURE_DEVOPS_ORG"; public const string PatVar = "AZURE_DEVOPS_PAT"; @@ -179,9 +200,10 @@ public static Config Resolve(Settings settings, string? connection, Func +/// The skills of this program in Claude Code (%USERPROFILE%\.claude\skills): only those of the mode, because Claude +/// Code loads every installed skill's description in each session. po-* for a Product Owner, dev-* for a developer, +/// all for "full". Claude Desktop takes skills as uploaded ZIPs, prepared next to the program. +/// +public static class Skills +{ + public static string CodeDir => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "skills"); + + public static bool ForMode(string skill, string mode) => mode switch + { + "full" => true, + "dev" => skill.StartsWith("dev-", StringComparison.OrdinalIgnoreCase), + _ => skill.StartsWith("po-", StringComparison.OrdinalIgnoreCase), + }; + + /// Copies the mode's skills into Claude Code and removes this program's others; returns what it did. + public static string Sync(string mode, string? skillsDir = null, string? codeDir = null) + { + var source = skillsDir ?? Prompts.SkillsDir(); + var target = codeDir ?? CodeDir; + if (source is null || !Directory.Exists(source)) return "no skills next to the program"; + if (!Directory.Exists(Path.GetDirectoryName(target)!)) return "Claude Code is not installed for this user - skills for it skipped"; + var added = new List(); + var removed = new List(); + foreach (var dir in Directory.GetDirectories(source)) + { + var name = Path.GetFileName(dir); + var file = Path.Combine(dir, "SKILL.md"); + if (!File.Exists(file)) continue; + var dst = Path.Combine(target, name); + if (ForMode(name, mode)) + { + Directory.CreateDirectory(dst); + File.Copy(file, Path.Combine(dst, "SKILL.md"), overwrite: true); + added.Add(name); + } + else if (File.Exists(Path.Combine(dst, "SKILL.md"))) + { + Directory.Delete(dst, recursive: true); + removed.Add(name); + } + } + return $"Claude Code skills for mode {mode}: {string.Join(", ", added.DefaultIfEmpty("none"))}" + + (removed.Count > 0 ? $"; removed: {string.Join(", ", removed)}" : ""); + } +} diff --git a/src/AdoLens/Trace.cs b/src/AdoLens/Trace.cs index 7edc0c0..5b250d7 100644 --- a/src/AdoLens/Trace.cs +++ b/src/AdoLens/Trace.cs @@ -234,6 +234,9 @@ static string Names(IEnumerable list) => foreach (var d in deployments.OrderBy(d => Order(d.Branch, d.Definition, d.Stage)).ThenBy(d => d.Definition).ThenBy(d => d.Stage)) { + // With an environment map, a stage outside it that has not deployed for half a year is an old pipeline. + if (env.Count > 0 && EnvKey(d.Branch, d.Definition, d.Stage) is null && (d.When is not { } last || last < DateTime.UtcNow.AddDays(-183))) + continue; var label = $"- {Label(d.Branch, d.Definition, d.Stage)}: "; var deployed = $"{Date(d.When)} (build {d.BuildNumber})"; if (d.Branch?.StartsWith("$/", StringComparison.Ordinal) == true) @@ -367,6 +370,14 @@ static string Names(IEnumerable list) => public static async Task ChangesAsync(Lens lens, string? connection, string change, string? repo, string? file, string? detail, CancellationToken ct = default) { var session = lens.For(connection); + var (c, versions) = await ResolveAsync(session, change, repo, ct); + return await ChangesTextAsync(c, versions, file, detail); + } + + /// A changeset, pull request or commit, and how to read the two versions of each of its files. + internal static async Task<(CodeChange Change, Func> Versions)> ResolveAsync( + Session session, string change, string? repo, CancellationToken ct) + { var code = session.Code; var text = change.Trim(); CodeChange c; @@ -393,7 +404,12 @@ public static async Task ChangesAsync(Lens lens, string? connection, str f.ChangeType.Contains("delete") ? null : await code.GitFileAsync(r.Id, f.Path, c.Id, ct)); } else throw new ArgumentException("change is a changeset (523, C523), a pull request (PR 482) or a commit sha with repo"); + return (c, versions); + } + static async Task ChangesTextAsync(CodeChange c, Func> versions, string? file, string? detail) + { + var ct = CancellationToken.None; var sb = new StringBuilder(); var head = c.Kind == "pr" ? $"{c.Label} {c.State}: {c.TargetBranch} <- {c.Branch}, \"{c.Message}\", {c.Author}" @@ -402,17 +418,27 @@ public static async Task ChangesAsync(Lens lens, string? connection, str if (file is not null) { - var matches = c.Files.Where(f => f.Path.EndsWith(file.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase)).ToList(); - if (matches.Count == 0) matches = c.Files.Where(f => f.Path.Contains(file, StringComparison.OrdinalIgnoreCase)).ToList(); - if (matches.Count == 0) return sb.Append($"no changed file matches '{file}'; files: {string.Join(", ", c.Files.Select(f => f.Path[(f.Path.LastIndexOf('/') + 1)..]).Take(30))}").ToString(); - if (matches.Count > 1) return sb.Append($"'{file}' matches {matches.Count} files: {string.Join("; ", matches.Select(f => f.Path).Take(15))} - give more of the path").ToString(); - var f = matches[0]; - var (before, after) = await versions(f); - sb.AppendLine($"{f.Path} ({f.ChangeType})"); - if (before is null && after is null) return sb.Append("content not readable (binary file or no access)").ToString(); - var max = detail == "full" ? 5000 : 400; - sb.Append(AotDiff.IsAot(f.Path) ? AotDiff.Detail(before, after, max) - : Diff.Unified(before ?? "", after ?? "", 3, max) ?? "no change in the text"); + // Several files at once ("a;b;c"): one call instead of one per file, each diff a little shorter. + var names = file.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var max = detail == "full" ? 5000 : Math.Max(150, 400 / Math.Max(1, names.Length) * 2); + var picked = new List(); + foreach (var name in names) + { + var matches = c.Files.Where(f => f.Path.EndsWith(name.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase)).ToList(); + if (matches.Count == 0) matches = c.Files.Where(f => f.Path.Contains(name, StringComparison.OrdinalIgnoreCase)).ToList(); + if (matches.Count == 0) sb.AppendLine($"no changed file matches '{name}'; files: {string.Join(", ", c.Files.Select(f => f.Path[(f.Path.LastIndexOf('/') + 1)..]).Take(30))}"); + else if (matches.Count > 1) sb.AppendLine($"'{name}' matches {matches.Count} files: {string.Join("; ", matches.Select(f => f.Path).Take(15))} - give more of the path"); + else if (!picked.Contains(matches[0])) picked.Add(matches[0]); + } + var diffs = await Task.WhenAll(picked.Select(async f => + { + var (before, after) = await versions(f); + var body = before is null && after is null ? "content not readable (binary file or no access)" + : AotDiff.IsAot(f.Path) ? AotDiff.Detail(before, after, max) + : Diff.Unified(before ?? "", after ?? "", 3, max) ?? "no change in the text"; + return $"{f.Path} ({f.ChangeType})\n{body.TrimEnd()}"; + })); + sb.AppendJoin("\n\n", diffs); return sb.ToString().TrimEnd(); } diff --git a/src/AdoLens/Ui.cs b/src/AdoLens/Ui.cs index 5766f27..7d9befa 100644 --- a/src/AdoLens/Ui.cs +++ b/src/AdoLens/Ui.cs @@ -11,7 +11,19 @@ public static class Ui [DllImport("kernel32.dll")] static extern ushort GetUserDefaultUILanguage(); - public static bool Polish { get; set; } = OperatingSystem.IsWindows() && (GetUserDefaultUILanguage() & 0x3FF) == 0x15; + /// The language chosen in the settings (the installer saves it), else the language of Windows. + public static bool Polish { get; set; } = Chosen() switch + { + "pl" => true, + "en" => false, + _ => OperatingSystem.IsWindows() && (GetUserDefaultUILanguage() & 0x3FF) == 0x15, + }; + + static string? Chosen() + { + try { return Settings.Load(Settings.DefaultPath).Language; } + catch (Exception) { return null; } + } public static string T(string english, string polish) => Polish ? polish : english; } diff --git a/src/AdoLens/WorkQueries.cs b/src/AdoLens/WorkQueries.cs index 94b40a2..019ccfa 100644 --- a/src/AdoLens/WorkQueries.cs +++ b/src/AdoLens/WorkQueries.cs @@ -74,7 +74,12 @@ public static async Task ItemAsync(Lens lens, string? connection, string return sb.ToString().TrimEnd(); } - public static async Task CreateAsync(Lens lens, string? connection, string? project, string type, WriteRequest r, CancellationToken ct = default) + public static async Task CreateAsync(Lens lens, string? connection, string? project, string type, WriteRequest r, CancellationToken ct = default) => + (await CreateItemAsync(lens, connection, project, type, r, null, ct)).Text; + + /// Creates one work item; groups several creates for one undo. + internal static async Task<(int Id, string Text)> CreateItemAsync(Lens lens, string? connection, string? project, string type, WriteRequest r, + string? batch, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(r.Title)) throw new ArgumentException("title is required"); var session = lens.For(connection); @@ -125,9 +130,9 @@ public static async Task CreateAsync(Lens lens, string? connection, stri } 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", + Audit.Write(lens, session.Config.Connection, [new AuditEntry { Batch = batch ?? Audit.NewBatch(), Tool = batch is null ? "ado_create" : "ado_create_tree", Kind = "create", Id = WorkItems.Id(created), Project = s.Project.Name, Title = WorkItems.Field(created, "System.Title") }]); - return Written("created", created, s, session.Work, notes); + return (WorkItems.Id(created), Written("created", created, s, session.Work, notes)); } static string Written(string verb, JsonElement wi, Snapshot? s, WorkItems work, List notes) diff --git a/src/AdoLens/WorkTree.cs b/src/AdoLens/WorkTree.cs new file mode 100644 index 0000000..75a59e0 --- /dev/null +++ b/src/AdoLens/WorkTree.cs @@ -0,0 +1,119 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace AdoLens; + +/// +/// A whole tree of work items from one indented list (Epic → Features → Requirements → Tasks), previewed first and +/// created top-down with parent links; one undo removes it all. +/// +public static partial class WorkTree +{ + public sealed record Node(int Level, string Type, string Title, string? Description, string? AssignedTo, List Children); + + [GeneratedRegex(@"^(?\s*)[-*]\s+(?[^:]+?)\s*:\s*(?.+?)\s*$")] + private static partial Regex Line(); + + [GeneratedRegex(@"\s@(?<who>[^@|]+?)\s*$")] + private static partial Regex Assignee(); + + /// <summary> + /// "- Type: Title | description @Person", nested by indentation (2 spaces or a tab per level). Lines that are not + /// items are added to the description of the item above. + /// </summary> + public static List<Node> Parse(string text) + { + var roots = new List<Node>(); + var stack = new List<(int Indent, Node Node)>(); + Node? last = null; + foreach (var raw in text.Replace("\r", "").Replace("\t", " ").Split('\n')) + { + if (raw.Trim().Length == 0) continue; + var m = Line().Match(raw); + if (!m.Success) + { + if (last is null) throw new ArgumentException($"'{raw.Trim()}' is not an item: write '- Type: Title', e.g. '- Task: Add the field'"); + ReplaceLast(roots, stack, last, last with { Description = ((last.Description ?? "") + "\n" + raw.Trim()).Trim() }, out last); + continue; + } + var indent = m.Groups["indent"].Value.Length; + var title = m.Groups["title"].Value; + string? who = null; + if (Assignee().Match(title) is { Success: true } a) { who = a.Groups["who"].Value.Trim(); title = title[..a.Index].Trim(); } + string? description = null; + var bar = title.IndexOf(" | ", StringComparison.Ordinal); + if (bar > 0) { description = title[(bar + 3)..].Trim(); title = title[..bar].Trim(); } + while (stack.Count > 0 && stack[^1].Indent >= indent) stack.RemoveAt(stack.Count - 1); + var node = new Node(stack.Count, m.Groups["type"].Value.Trim(), title, description, who, []); + if (stack.Count == 0) roots.Add(node); + else stack[^1].Node.Children.Add(node); + stack.Add((indent, node)); + last = node; + } + if (roots.Count == 0) throw new ArgumentException("no items: write one per line, '- Type: Title', children indented under their parent"); + return roots; + } + + // Records are immutable: swap the node in its parent's list (or the roots) and on the stack. + static void ReplaceLast(List<Node> roots, List<(int Indent, Node Node)> stack, Node old, Node updated, out Node last) + { + last = updated; + var parentList = stack.Count >= 2 ? stack[^2].Node.Children : roots; + var i = parentList.IndexOf(old); + if (i >= 0) parentList[i] = updated; + for (var k = 0; k < stack.Count; k++) + if (ReferenceEquals(stack[k].Node, old)) stack[k] = (stack[k].Indent, updated); + } + + static IEnumerable<Node> All(IEnumerable<Node> nodes) => nodes.SelectMany(n => new[] { n }.Concat(All(n.Children))); + + public static async Task<string> CreateAsync(Lens lens, string? connection, string? project, int? parent, string items, string? iteration, string? area, + string? confirm, CancellationToken ct = default) + { + var roots = Parse(items); + var session = lens.For(connection); + var name = session.Project(project); + var s = (await session.Meta.GetAsync(name, ct)).Snapshot; + // Types checked before anything is written. + var unknown = All(roots).Select(n => n.Type).Distinct(StringComparer.OrdinalIgnoreCase).Where(t => s.FindType(t) is not { Disabled: false }).ToList(); + if (unknown.Count > 0) + throw new ArgumentException($"unknown type(s): {string.Join(", ", unknown)}; types: {string.Join(", ", s.Types.Where(t => !t.Disabled && t.Level != Levels.Hidden).Select(t => t.Name).Order())}"); + + var token = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{parent}|{iteration}|{area}|{items}")))[..8].ToLowerInvariant(); + var count = All(roots).Count(); + string Render(Node n, string indent) => + $"{indent}- {s.FindType(n.Type)!.Name}: {n.Title}{(n.AssignedTo is null ? "" : $" (@{n.AssignedTo})")}{(n.Description is null ? "" : " - " + Formatting.Collapse(n.Description, 60))}\n" + + string.Concat(n.Children.Select(c => Render(c, indent + " "))); + if (!string.Equals(confirm?.Trim(), token, StringComparison.OrdinalIgnoreCase)) + { + var sb = new StringBuilder($"PREVIEW - nothing written yet. {count} work item(s)"); + if (parent is { } p) sb.Append($" under #{p}"); + if (iteration is not null) sb.Append($", iteration {iteration}"); + sb.AppendLine(":"); + foreach (var r in roots) sb.Append(Render(r, "")); + return sb.Append($"Show this to the user. To create them, call again with the same arguments and confirm={token}.").ToString(); + } + + var batch = Audit.NewBatch(); + var lines = new List<string>(); + async Task Create(Node n, int? parentId, string indent) + { + var (id, _) = await WorkQueries.CreateItemAsync(lens, connection, name, n.Type, new WriteRequest + { + Title = n.Title, Description = n.Description, AssignedTo = n.AssignedTo, Parent = parentId, Iteration = iteration, Area = area, + }, batch, ct); + lines.Add($"{indent}#{id} {s.FindType(n.Type)!.Name}: {n.Title}"); + foreach (var c in n.Children) await Create(c, id, indent + " "); + } + try + { + foreach (var r in roots) await Create(r, parent, ""); + } + catch (Exception ex) when (ex is AdoApiException or InvalidOperationException) + { + return $"created {lines.Count} of {count}, then it failed: {ex.Message}\n{string.Join('\n', lines)}\nado_undo removes what was created."; + } + return $"created {count} work item(s):\n{string.Join('\n', lines)}\nado_undo removes them all."; + } +} diff --git a/tests/AdoLens.Tests/AttachmentTests.cs b/tests/AdoLens.Tests/AttachmentTests.cs new file mode 100644 index 0000000..3b6091c --- /dev/null +++ b/tests/AdoLens.Tests/AttachmentTests.cs @@ -0,0 +1,134 @@ +using System.IO.Compression; +using System.Text; + +namespace AdoLens.Tests; + +public class AttachmentTests +{ + static byte[] Zip(params (string Path, string Content)[] parts) + { + var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + foreach (var (path, content) in parts) + { + using var w = new StreamWriter(zip.CreateEntry(path).Open(), new UTF8Encoding(false)); + w.Write(content); + } + return ms.ToArray(); + } + + [Fact] + public void Word_documents_become_markdown_like_text() + { + var docx = Zip(("word/document.xml", """ + <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body> + <w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Goal</w:t></w:r></w:p> + <w:p><w:r><w:t>Stores consume </w:t></w:r><w:r><w:t>goods at once.</w:t></w:r></w:p> + <w:p><w:pPr><w:numPr/></w:pPr><w:r><w:t>first point</w:t></w:r></w:p> + <w:tbl><w:tr><w:tc><w:p><w:r><w:t>Field</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>Value</w:t></w:r></w:p></w:tc></w:tr></w:tbl> + </w:body></w:document> + """)); + var text = Attachments.Extract("spec.docx", docx)!.Replace("\r", ""); + Assert.Contains("# Goal\nStores consume goods at once.\n- first point", text); + Assert.Contains("| Field | Value |", text); + } + + [Fact] + public void Excel_sheets_become_rows() + { + var xlsx = Zip( + ("xl/sharedStrings.xml", """<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><si><t>Item</t></si><si><t>Qty</t></si></sst>"""), + ("xl/worksheets/sheet1.xml", """ + <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData> + <row><c t="s"><v>0</v></c><c t="s"><v>1</v></c></row> + <row><c t="inlineStr"><is><t>A-1</t></is></c><c><v>12</v></c></row> + </sheetData></worksheet> + """)); + var text = Attachments.Extract("list.xlsx", xlsx)!.Replace("\r", ""); + Assert.Contains("## sheet1\n| Item | Qty |\n| A-1 | 12 |", text); + } + + [Fact] + public void Text_files_are_read_and_binary_ones_are_not() + { + Assert.Equal("a;b", Attachments.Extract("x.csv", Encoding.UTF8.GetBytes("\uFEFFa;b"))); + Assert.Null(Attachments.Extract("shot.png", [0x89, 0x50, 0x4E, 0x47, 0, 0, 0])); + Assert.Null(Attachments.Extract("broken.docx", [1, 2, 3])); + } + + [Fact] + public void Images_are_recognized_by_their_first_bytes() + { + Assert.Equal("image/png", Attachments.ImageMime([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0])); + Assert.Equal("image/jpeg", Attachments.ImageMime([0xFF, 0xD8, 0xFF, 0xE0])); + Assert.Null(Attachments.ImageMime(Encoding.UTF8.GetBytes("plain text here"))); + } + + /// <summary>A minimal compound file (version 3, 512-byte sectors, no mini stream) with the given top-level streams.</summary> + static byte[] Msg(params (string Name, byte[] Data)[] streams) + { + const uint Free = 0xFFFFFFFF, End = 0xFFFFFFFE; + var sectors = new List<byte[]>(); + var fat = new List<uint> { 0xFFFFFFFD, 2, End }; // sector 0: the FAT, sectors 1-2: the directory + var starts = new List<uint>(); + foreach (var (_, data) in streams) + { + var count = Math.Max(1, (data.Length + 511) / 512); + starts.Add((uint)(3 + sectors.Count)); + for (var k = 0; k < count; k++) + { + var sec = new byte[512]; + Array.Copy(data, k * 512, sec, 0, Math.Min(512, data.Length - k * 512)); + sectors.Add(sec); + fat.Add(k == count - 1 ? End : (uint)(fat.Count + 1)); + } + } + var header = new byte[512]; + BitConverter.GetBytes(0xE11AB1A1E011CFD0).CopyTo(header, 0); + BitConverter.GetBytes((ushort)0x3E).CopyTo(header, 0x18); + BitConverter.GetBytes((ushort)3).CopyTo(header, 0x1A); + BitConverter.GetBytes((ushort)0xFFFE).CopyTo(header, 0x1C); + BitConverter.GetBytes((ushort)9).CopyTo(header, 0x1E); + BitConverter.GetBytes((ushort)6).CopyTo(header, 0x20); + BitConverter.GetBytes(1u).CopyTo(header, 0x2C); + BitConverter.GetBytes(1u).CopyTo(header, 0x30); + BitConverter.GetBytes(0u).CopyTo(header, 0x38); // cutoff 0: every stream in regular sectors + BitConverter.GetBytes(End).CopyTo(header, 0x3C); + BitConverter.GetBytes(End).CopyTo(header, 0x44); + for (var k = 0; k < 109; k++) BitConverter.GetBytes(k == 0 ? 0u : Free).CopyTo(header, 0x4C + k * 4); + var fatSector = new byte[512]; + for (var k = 0; k < 128; k++) BitConverter.GetBytes(k < fat.Count ? fat[k] : Free).CopyTo(fatSector, k * 4); + var dir = new byte[1024]; + void Entry(int index, string name, byte type, uint start, uint size, uint right, uint child) + { + var o = index * 128; + var n = Encoding.Unicode.GetBytes(name + "\0"); + n.CopyTo(dir, o); + BitConverter.GetBytes((ushort)n.Length).CopyTo(dir, o + 64); + dir[o + 66] = type; + dir[o + 67] = 1; + BitConverter.GetBytes(Free).CopyTo(dir, o + 68); + BitConverter.GetBytes(right).CopyTo(dir, o + 72); + BitConverter.GetBytes(child).CopyTo(dir, o + 76); + BitConverter.GetBytes(start).CopyTo(dir, o + 116); + BitConverter.GetBytes((ulong)size).CopyTo(dir, o + 120); + } + Entry(0, "Root Entry", 5, End, 0, Free, 1); + for (var k = 0; k < streams.Length; k++) + Entry(k + 1, streams[k].Name, 2, starts[k], (uint)streams[k].Data.Length, k + 2 <= streams.Length ? (uint)(k + 2) : Free, Free); + return [.. header, .. fatSector, .. dir, .. sectors.SelectMany(x => x)]; + } + + [Fact] + public void Outlook_messages_are_read_as_text() + { + var msg = Msg( + ("__substg1.0_0037001F", Encoding.Unicode.GetBytes("Re: shelf labels")), + ("__substg1.0_0C1A001F", Encoding.Unicode.GetBytes("Anna Nowak")), + ("__substg1.0_0E04001F", Encoding.Unicode.GetBytes("Ben Kowalski")), + ("__substg1.0_1000001F", Encoding.Unicode.GetBytes("Hi Ben,\r\nyes, switch it off.\r\n" + new string('.', 600)))); + var text = Attachments.Extract("reply.msg", msg)!; + Assert.StartsWith("Subject: Re: shelf labels\nFrom: Anna Nowak\nTo: Ben Kowalski\n\nHi Ben,\nyes, switch it off.", text.Replace("\r", "")); + Assert.Null(OutlookMsg.Read([1, 2, 3])); + } +} diff --git a/tests/AdoLens.Tests/BoardTests.cs b/tests/AdoLens.Tests/BoardTests.cs index 6798cd1..aa534a8 100644 --- a/tests/AdoLens.Tests/BoardTests.cs +++ b/tests/AdoLens.Tests/BoardTests.cs @@ -36,6 +36,7 @@ public BoardTests() {"id":"s2","name":"Sprint 2","path":"Demo\\Sprint 2","attributes":{"startDate":"2026-10-05T00:00:00Z","finishDate":"2026-10-16T00:00:00Z","timeFrame":"current"}}, {"id":"s3","name":"Sprint 3","path":"Demo\\Sprint 3","attributes":{"startDate":"2026-10-19T00:00:00Z","finishDate":"2026-10-30T00:00:00Z","timeFrame":"future"}}]} """) + .On(team + "teamsettings/iterations/s1/workitems", """{"workItemRelations":[{"rel":null,"source":null,"target":{"id":4}}]}""") .On(team + "teamsettings/iterations/s2/workitems", """ {"workItemRelations":[ {"rel":null,"source":null,"target":{"id":1}}, @@ -184,4 +185,12 @@ public void Working_days_skip_weekends_and_days_off() Assert.Equal(10, Boards.WorkingDays(new(2026, 10, 5), new(2026, 10, 16), week, [])); Assert.Equal(9, Boards.WorkingDays(new(2026, 10, 5), new(2026, 10, 16), week, [(new(2026, 10, 7), new(2026, 10, 7))])); } + + [Fact] + public async Task Metrics_compare_finished_sprints() + { + ((JsonObject)items.Items[4]["fields"]!)["System.State"] = "Done"; + var text = (await BoardQueries.MetricsAsync(lens, null, null, null, 6, Today)).Replace("\r", ""); + Assert.StartsWith("flow of Demo Team (Demo)\nsprint | done items | done effort\nSprint 1 | 1 | 0\naverage: 1 items, 0 effort per sprint", text); + } } diff --git a/tests/AdoLens.Tests/LabelTests.cs b/tests/AdoLens.Tests/LabelTests.cs new file mode 100644 index 0000000..ef65c1f --- /dev/null +++ b/tests/AdoLens.Tests/LabelTests.cs @@ -0,0 +1,87 @@ +namespace AdoLens.Tests; + +public class LabelTests +{ + [Theory] + [InlineData("Item %1: received %2, on hand %3. Quantity %3 was consumed.", "Item 510234 Sparkling water: received 12, on hand 5. Quantity 5 was consumed.")] + [InlineData("Order %1 for %2 to %3 on %4, %5, %6, %7, %8, %9, %10 and %11", "Order SO-1 for Alpha to Main store on 2026-10-15, a, b, c, d, e, f and g")] + [InlineData("No on-hand for item %1", "no ON-HAND for item X")] + [InlineData("The journal cannot be posted", "cannot be posted")] + public void A_pasted_message_with_its_values_matches_the_label(string label, string seen) => + Assert.True(Labels.Matches(label, seen)); + + [Theory] + [InlineData("Item %1: received %2", "Order 5: received 3")] + [InlineData("The journal cannot be posted", "The journal was posted")] + public void A_different_message_does_not_match(string label, string seen) => + Assert.False(Labels.Matches(label, seen)); + + [Fact] + public void Numbers_and_short_words_are_left_out_of_the_search() + { + Assert.Equal("Rozchodowano przyjęto fizyczny Towar stan", Labels.SearchWords("Towar 510234: przyjęto 12, stan fizyczny 5. Rozchodowano 5.")); + } + + [Fact] + public void Label_files_are_parsed_without_comments() + { + var map = Labels.Parse("\uFEFFFirst=One %1\n ;comment for First\nSecond=Two=2\n\nBad line\n"); + Assert.Equal("One %1", map["first"]); + Assert.Equal("Two=2", map["Second"]); + Assert.Equal(2, map.Count); + } + + [Fact] + public void Places_name_the_methods_and_the_fields_that_use_a_label() + { + const string xml = """ + <AxTable xmlns:i="http://www.w3.org/2001/XMLSchema-instance"> + <Name>DemoTable</Name> + <SourceCode><Methods> + <Method><Name>validateWrite</Name><Source><![CDATA[ret = checkFailed("@Demo:Missing");]]></Source></Method> + <Method><Name>other</Name><Source><![CDATA[info("x");]]></Source></Method> + </Methods></SourceCode> + <Fields><AxTableField><Name>Amount</Name><Label>@Demo:Missing</Label></AxTableField></Fields> + </AxTable> + """; + Assert.Equal(": validateWrite; Label of Amount", Labels.Places(xml, "@Demo:Missing")); + Assert.Equal("", Labels.Places(xml, "@Demo:Other")); + } + + [Fact] + public void Review_sees_new_lines_per_method() + { + const string before = """ + <AxClass><Name>Demo</Name><SourceCode><Methods> + <Method><Name>run</Name><Source><![CDATA[void run() + { + info("@Demo:Done"); + }]]></Source></Method> + </Methods></SourceCode></AxClass> + """; + const string after = """ + <AxClass><Name>Demo</Name><SourceCode><Methods> + <Method><Name>run</Name><Source><![CDATA[void run() + { + info("@Demo:Done"); + warning("Nothing to post"); // TODO label + }]]></Source></Method> + <Method><Name>check</Name><Source><![CDATA[boolean check() { return checkFailed("@Demo:Missing"); }]]></Source></Method> + </Methods></SourceCode></AxClass> + """; + var added = Review.AddedLines(before, after); + Assert.Contains(added, x => x.Method == "run" && x.Line.Contains("Nothing to post")); + Assert.Contains(added, x => x.Method == "check"); + Assert.DoesNotContain(added, x => x.Line.Contains("@Demo:Done")); + } + + [Fact] + public void Only_the_required_label_languages_are_reported() + { + var all = new Config("c", "contoso", "pat", "Demo", null, "test"); + var some = all with { LabelLanguages = ["en-US", "PL"] }; + Assert.True(all.LabelLanguageRequired("de-DE")); + Assert.True(some.LabelLanguageRequired("pl")); + Assert.False(some.LabelLanguageRequired("de-DE")); + } +} diff --git a/tests/AdoLens.Tests/PromptTests.cs b/tests/AdoLens.Tests/PromptTests.cs new file mode 100644 index 0000000..375a844 --- /dev/null +++ b/tests/AdoLens.Tests/PromptTests.cs @@ -0,0 +1,62 @@ +using ModelContextProtocol.Server; + +namespace AdoLens.Tests; + +public class PromptTests : IDisposable +{ + readonly string dir = Path.Combine(Path.GetTempPath(), $"adolens-skills-{Guid.NewGuid():N}"); + + public void Dispose() + { + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + + [Fact] + public void Prompts_carry_the_skill_and_the_request() + { + var text = Prompts.Verify("#37"); + Assert.Contains("# Checking work for a Product Owner", text); // read from skills/po-verify/SKILL.md, front matter left out + Assert.DoesNotContain("name: po-verify", text); + Assert.Contains("Request: Was work item #37 done as asked", text); + Assert.Contains("Request: Pull request for PR 12.", Prompts.DevPr("PR 12")); + } + + [Fact] + public void Every_prompt_has_its_skill_in_the_repository() + { + var skills = Prompts.SkillsDir()!; + foreach (var name in new[] { "po-verify", "po-explain", "po-story", "po-sprint", "po-refine", "po-test", "po-wiki", "dev-start", "dev-pr", "dev-build", "dev-why" }) + Assert.True(File.Exists(Path.Combine(skills, name, "SKILL.md")), name); + } + + [Fact] + public void The_server_registers_the_prompts() + { + var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + Microsoft.Extensions.DependencyInjection.McpServerBuilderExtensions.WithPrompts( + Microsoft.Extensions.DependencyInjection.McpServerServiceCollectionExtensions.AddMcpServer(services), [typeof(Prompts)]); + var sp = Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(services); + var names = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetServices<McpServerPrompt>(sp).Select(p => p.ProtocolPrompt.Name).Order().ToList(); + Assert.Equal(["dev_build", "dev_pr", "dev_start", "dev_why", "explain", "label", "refine", "sprint", "story", "test", "verify", "wiki"], names); + } + + [Fact] + public void Claude_Code_gets_only_the_skills_of_the_mode() + { + var source = Path.Combine(dir, "skills"); + var code = Path.Combine(dir, ".claude", "skills"); + foreach (var name in new[] { "po-verify", "dev-why" }) + { + Directory.CreateDirectory(Path.Combine(source, name)); + File.WriteAllText(Path.Combine(source, name, "SKILL.md"), name); + } + Directory.CreateDirectory(Path.Combine(dir, ".claude")); + Assert.StartsWith("Claude Code skills for mode po: po-verify", Skills.Sync("po", source, code)); + Assert.True(File.Exists(Path.Combine(code, "po-verify", "SKILL.md"))); + Assert.False(Directory.Exists(Path.Combine(code, "dev-why"))); + + Assert.Contains("removed: po-verify", Skills.Sync("dev", source, code)); + Assert.True(File.Exists(Path.Combine(code, "dev-why", "SKILL.md"))); + Assert.Contains("dev-why, po-verify", Skills.Sync("full", source, code).Replace("po-verify, dev-why", "dev-why, po-verify")); + } +} diff --git a/tests/AdoLens.Tests/SetupTests.cs b/tests/AdoLens.Tests/SetupTests.cs index 3abed75..04b3d4b 100644 --- a/tests/AdoLens.Tests/SetupTests.cs +++ b/tests/AdoLens.Tests/SetupTests.cs @@ -287,8 +287,8 @@ public void Tool_definitions_do_not_grow_by_accident(string mode, int maxTokens) 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_test_create", "ado_tests", + Assert.Equal(["ado_attachments", "ado_backlog", "ado_board", "ado_changes", "ado_comment", "ado_create", "ado_create_tree", "ado_fields", "ado_file", "ado_health", "ado_label", "ado_link", "ado_metrics", "ado_my_day", + "ado_query", "ado_recipe", "ado_release_notes", "ado_review", "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/WorkTreeTests.cs b/tests/AdoLens.Tests/WorkTreeTests.cs new file mode 100644 index 0000000..e33481c --- /dev/null +++ b/tests/AdoLens.Tests/WorkTreeTests.cs @@ -0,0 +1,63 @@ +namespace AdoLens.Tests; + +public class WorkTreeTests : IDisposable +{ + readonly string dir = Path.Combine(Path.GetTempPath(), $"adolens-tree-{Guid.NewGuid():N}"); + readonly DemoItems items = new(); + readonly Lens lens; + + public WorkTreeTests() + { + var ado = items.Routes(new DemoProject().Routes(new FakeAdo())); + 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); + } + + const string Tree = """ + - Feature: Self-service returns + - Product Backlog Item: Return form | customer picks the order line + The form shows only delivered lines. + - Task: Build the form @Anna Nowak + - Task: Test the form + - Bug: Old return link 404 + """; + + [Fact] + public void An_indented_list_becomes_a_tree() + { + var roots = WorkTree.Parse(Tree); + var feature = Assert.Single(roots); + Assert.Equal(("Feature", "Self-service returns"), (feature.Type, feature.Title)); + var pbi = feature.Children[0]; + Assert.Equal("customer picks the order line\nThe form shows only delivered lines.", pbi.Description); + Assert.Equal(["Build the form", "Test the form"], pbi.Children.Select(c => c.Title)); + Assert.Equal("Anna Nowak", pbi.Children[0].AssignedTo); + Assert.Equal("Bug", feature.Children[1].Type); + Assert.Throws<ArgumentException>(() => WorkTree.Parse("just text")); + } + + [Fact] + public async Task The_tree_is_previewed_then_created_top_down_and_undone_at_once() + { + var preview = await WorkTree.CreateAsync(lens, null, null, 3, Tree, null, null, null); + Assert.StartsWith("PREVIEW - nothing written yet. 5 work item(s) under #3:", preview); + Assert.Contains(" - Task: Build the form (@Anna Nowak)", preview); + Assert.Empty(items.Created); + + var token = preview[(preview.LastIndexOf("confirm=", StringComparison.Ordinal) + 8)..].TrimEnd('.'); + var done = await WorkTree.CreateAsync(lens, null, null, 3, Tree, null, null, token); + Assert.StartsWith("created 5 work item(s):", done); + Assert.Equal(5, items.Created.Count); + Assert.Contains("workItems/3", items.Created[0]); // the feature goes under #3 + Assert.Contains("ado_create_tree", Audit.List(lens, null, 5)); + + await Assert.ThrowsAsync<ArgumentException>(() => WorkTree.CreateAsync(lens, null, null, null, "- Story: x", null, null, null)); + } +}