From e1a0866be137c23640eea40bfe01ba892aac312a Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:45:32 +0100 Subject: [PATCH] ADO Lens 0.10.1 - A Polish installation shows Polish texts people read: the descriptions of the skills in Claude Code and in the ZIPs for Claude Desktop (from `description.pl.txt` next to each skill; the instructions for Claude stay in English), and the titles and descriptions of the prompts. `adolens skills` writes both - the installer and `install.ps1` call it, and changing the language in the settings menu writes them again. - `ado_create` / `ado_update` with a parent: a parent of a lower or the same backlog level (a requirement under a task, a task under a task) gets a note "unusual parent" - the link is still made, but it is usually a mistaken id. - A test for the warning about a required label language without a label file. --- CHANGELOG.md | 10 ++++++ install.ps1 | 12 ++----- packaging/inno/ado-lens.iss | 3 +- skills/dev-build/description.pl.txt | 1 + skills/dev-pr/description.pl.txt | 1 + skills/dev-start/description.pl.txt | 1 + skills/dev-why/description.pl.txt | 1 + skills/po-explain/description.pl.txt | 1 + skills/po-refine/description.pl.txt | 1 + skills/po-sprint/description.pl.txt | 1 + skills/po-story/description.pl.txt | 1 + skills/po-test/description.pl.txt | 1 + skills/po-verify/description.pl.txt | 1 + skills/po-wiki/description.pl.txt | 1 + src/AdoLens/AdoLens.csproj | 2 +- src/AdoLens/Cli.cs | 3 ++ src/AdoLens/Program.cs | 6 +++- src/AdoLens/Prompts.cs | 31 +++++++++++++++++ src/AdoLens/Review.cs | 11 ++++-- src/AdoLens/Skills.cs | 52 ++++++++++++++++++++++++++-- src/AdoLens/WorkItems.cs | 22 ++++++++++++ tests/AdoLens.Tests/LabelTests.cs | 8 +++++ tests/AdoLens.Tests/PromptTests.cs | 35 +++++++++++++++++++ tests/AdoLens.Tests/WorkItemTests.cs | 11 ++++++ 24 files changed, 199 insertions(+), 18 deletions(-) create mode 100644 skills/dev-build/description.pl.txt create mode 100644 skills/dev-pr/description.pl.txt create mode 100644 skills/dev-start/description.pl.txt create mode 100644 skills/dev-why/description.pl.txt create mode 100644 skills/po-explain/description.pl.txt create mode 100644 skills/po-refine/description.pl.txt create mode 100644 skills/po-sprint/description.pl.txt create mode 100644 skills/po-story/description.pl.txt create mode 100644 skills/po-test/description.pl.txt create mode 100644 skills/po-verify/description.pl.txt create mode 100644 skills/po-wiki/description.pl.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 128095f..4ab2920 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.10.1 — 2026-10-26 + +- A Polish installation shows Polish texts people read: the descriptions of the skills in Claude Code and in the ZIPs + for Claude Desktop (from `description.pl.txt` next to each skill; the instructions for Claude stay in English), and + the titles and descriptions of the prompts. `adolens skills` writes both - the installer and `install.ps1` call it, + and changing the language in the settings menu writes them again. +- `ado_create` / `ado_update` with a parent: a parent of a lower or the same backlog level (a requirement under a task, + a task under a task) gets a note "unusual parent" - the link is still made, but it is usually a mistaken id. +- A test for the warning about a required label language without a label file. + ## 0.10.0 — 2026-10-26 Environments per repository, code in another project, a tidier installer page. diff --git a/install.ps1 b/install.ps1 index 1d1658f..2b5c8d6 100644 --- a/install.ps1 +++ b/install.ps1 @@ -147,17 +147,9 @@ 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) { - $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) { - $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) - } - # Claude Code loads every installed skill in each session: only the skills of the mode go there. + # Claude Code loads every installed skill in each session: only the skills of the mode go there. The ZIPs for + # Claude Desktop are written next to the program; both with descriptions in the language of the installation. 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 6ad8736..4068807 100644 --- a/packaging/inno/ado-lens.iss +++ b/packaging/inno/ado-lens.iss @@ -619,7 +619,8 @@ begin RunAdoLens(Exe, 'config --mode ' + Mode, Output); end else Mode := SavedMode; - InstallCodeSkills(Mode); + { The skills of the mode for Claude Code and the ZIPs for Claude Desktop, with descriptions in the chosen language. } + if RunAdoLens(Exe, 'skills', Output) <> 0 then 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 diff --git a/skills/dev-build/description.pl.txt b/skills/dev-build/description.pl.txt new file mode 100644 index 0000000..275d460 --- /dev/null +++ b/skills/dev-build/description.pl.txt @@ -0,0 +1 @@ +Diagnozuje nieudany build albo release dla programisty - który krok padł, prawdziwe linie błędów, kod za nimi i propozycja poprawki. Użyj, gdy ktoś mówi „build jest czerwony”, „dlaczego padł build 900”, „pipeline się wysypał”, „co zepsuło build”. Wymaga serwera MCP ado-lens. diff --git a/skills/dev-pr/description.pl.txt b/skills/dev-pr/description.pl.txt new file mode 100644 index 0000000..71c66d5 --- /dev/null +++ b/skills/dev-pr/description.pl.txt @@ -0,0 +1 @@ +Pisze opis pull requesta na podstawie work itemu albo przegląda pull request względem kryteriów akceptacji work itemu i kontroli projektu. Użyj, gdy programista prosi „napisz opis PR”, „otwórz PR dla #123”, „zrób review PR 482”, „czy ten PR jest gotowy”. Wymaga serwera MCP ado-lens. diff --git a/skills/dev-start/description.pl.txt b/skills/dev-start/description.pl.txt new file mode 100644 index 0000000..9d9a71f --- /dev/null +++ b/skills/dev-start/description.pl.txt @@ -0,0 +1 @@ +Rozpoczyna pracę programisty nad work itemem - co jest do zrobienia (kryteria z elementu i jego rodzica, załączniki), gdzie w kodzie to wypada, plan na każde kryterium, otwarte pytania. Użyj, gdy programista mówi „zaczynam #123”, „co mam zrobić w tym tasku”, „zaplanuj tę historyjkę”, „gdzie to zmienić”. Wymaga serwera MCP ado-lens. diff --git a/skills/dev-why/description.pl.txt b/skills/dev-why/description.pl.txt new file mode 100644 index 0000000..f64bd9d --- /dev/null +++ b/skills/dev-why/description.pl.txt @@ -0,0 +1 @@ +Wyjaśnia, dlaczego kod wygląda tak, a nie inaczej - historia pliku albo obiektu X++, zmiany, które go ukształtowały, work itemy i decyzje za nimi. Użyj, gdy programista pyta „czemu to tak jest”, „kto to zmienił i po co”, „do czego to było”, „czy mogę to usunąć”. Wymaga serwera MCP ado-lens. diff --git a/skills/po-explain/description.pl.txt b/skills/po-explain/description.pl.txt new file mode 100644 index 0000000..35ab5fd --- /dev/null +++ b/skills/po-explain/description.pl.txt @@ -0,0 +1 @@ +Wyjaśnia językiem biznesu, jak działa funkcja i dlaczego tak działa - z work itemów, wiki i kodu w Azure DevOps. Użyj, gdy ktoś pyta „jak działa X”, „co się dzieje, gdy…”, „czemu system robi…”, „gdzie to jest opisane”, albo potrzebuje opisu funkcji dla użytkowników, testerów czy nowej osoby w zespole. Wymaga serwera MCP ado-lens. diff --git a/skills/po-refine/description.pl.txt b/skills/po-refine/description.pl.txt new file mode 100644 index 0000000..47139fc --- /dev/null +++ b/skills/po-refine/description.pl.txt @@ -0,0 +1 @@ +Przygotowuje refinement backlogu dla Product Ownera - co nie jest gotowe do planowania (bez kryteriów akceptacji, bez estymacji, za duże, niejasne), konkretne poprawki i pytania do zespołu, podział dużych elementów. Użyj, gdy ktoś prosi „przygotuj refinement”, „uporządkuj backlog”, „co nie jest gotowe na sprint”, „podziel tę historyjkę”, „o co zapytać zespół”. Wymaga serwera MCP ado-lens. diff --git a/skills/po-sprint/description.pl.txt b/skills/po-sprint/description.pl.txt new file mode 100644 index 0000000..a1a272c --- /dev/null +++ b/skills/po-sprint/description.pl.txt @@ -0,0 +1 @@ +Podsumowuje sprint albo okres dla Product Ownera i interesariuszy - postęp, ryzyka, co dostarczono i gdzie jest wdrożone - oraz pisze release notes. Użyj, gdy ktoś pyta „jak idzie sprint”, „przygotuj sprint review”, „co dostarczyliśmy”, „release notes”, „status dla interesariuszy”, „co nowego dzisiaj”. Wymaga serwera MCP ado-lens. diff --git a/skills/po-story/description.pl.txt b/skills/po-story/description.pl.txt new file mode 100644 index 0000000..4522721 --- /dev/null +++ b/skills/po-story/description.pl.txt @@ -0,0 +1 @@ +Pisze work item dla Product Ownera - wymaganie (user story, product backlog item, requirement), błąd albo task - krótko i kompletnie, z testowalnymi kryteriami akceptacji, na prawdziwych typach i polach projektu. Użyj, gdy ktoś prosi „załóż historyjkę”, „napisz wymaganie”, „zgłoś błąd”, „rozpisz na taski”, albo opisuje potrzebę, która ma trafić do Azure DevOps. Wymaga serwera MCP ado-lens. diff --git a/skills/po-test/description.pl.txt b/skills/po-test/description.pl.txt new file mode 100644 index 0000000..51d0172 --- /dev/null +++ b/skills/po-test/description.pl.txt @@ -0,0 +1 @@ +Pisze przypadki testowe akceptacji z kryteriów work itemu i pokazuje stan jego testów, dla Product Ownera albo testera. Użyj, gdy ktoś prosi „napisz przypadki testowe”, „przygotuj scenariusze UAT”, „jak to przetestować”, „czy to jest przetestowane”, „co nie przeszło w testach”. Wymaga serwera MCP ado-lens. diff --git a/skills/po-verify/description.pl.txt b/skills/po-verify/description.pl.txt new file mode 100644 index 0000000..10ce94b --- /dev/null +++ b/skills/po-verify/description.pl.txt @@ -0,0 +1 @@ +Sprawdza, czy work item zrobiono tak, jak proszono, i gdzie jest zmiana (DEV / UAT / PROD), dla Product Ownera. Użyj, gdy ktoś pyta „czy zrobili to, o co prosiłem”, „czy #123 jest zrobione dobrze”, „czy to już jest na UAT”, „czy mogę to zamknąć”, albo chce odbioru historyjki, błędu czy taska w Azure DevOps. Wymaga serwera MCP ado-lens. diff --git a/skills/po-wiki/description.pl.txt b/skills/po-wiki/description.pl.txt new file mode 100644 index 0000000..8f5d03f --- /dev/null +++ b/skills/po-wiki/description.pl.txt @@ -0,0 +1 @@ +Pisze albo aktualizuje stronę dokumentacji na wiki projektu dla Product Ownera - jak działa funkcja, co dostarczona zmiana znaczy dla użytkowników, opis procesu - z work itemów, załączników i kodu. Użyj, gdy ktoś prosi „udokumentuj to”, „napisz stronę wiki”, „zaktualizuj dokumentację po tej historyjce”, „opisz proces dla nowych osób”. Wymaga serwera MCP ado-lens. diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index 21f83a6..c600bf8 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.10.0 + 0.10.1 diff --git a/src/AdoLens/Cli.cs b/src/AdoLens/Cli.cs index 3d7e860..73152ec 100644 --- a/src/AdoLens/Cli.cs +++ b/src/AdoLens/Cli.cs @@ -795,6 +795,9 @@ async Task SetupMenuAsync() 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; + // Skill descriptions follow the language. + Console.WriteLine(" " + Skills.Sync(settings.Mode)); + if (Skills.DesktopZips(Ui.Polish) is { Length: > 0 } zips) Console.WriteLine(" " + zips); break; case "9": Console.WriteLine(Ui.T(" 1. auto - on demand in Claude Code, all at start in other clients (Claude Desktop)", diff --git a/src/AdoLens/Program.cs b/src/AdoLens/Program.cs index 6f82662..40d4d7b 100644 --- a/src/AdoLens/Program.cs +++ b/src/AdoLens/Program.cs @@ -92,6 +92,7 @@ return await cli.SearchAsync(); case "skills": Console.WriteLine(Skills.Sync(Settings.Load(Settings.DefaultPath).Mode)); + if (Skills.DesktopZips(Ui.Polish) is { Length: > 0 } zips) Console.WriteLine(zips); return 0; case "review": return await cli.ReviewAsync(); @@ -169,7 +170,10 @@ async Task RunServerAsync() return next(context, ct); }) .AddCallToolFilter(ToolArguments.Errors)) - .WithPrompts(Prompts.TypesFor(lens.Settings)); + ; + // Prompts one by one (not WithPrompts) so a Polish installation gets Polish titles and descriptions. + foreach (var method in Prompts.Methods(lens.Settings)) + builder.Services.AddSingleton(sp => Prompts.Create(method, sp, Ui.Polish)); await builder.Build().RunAsync(); return 0; } diff --git a/src/AdoLens/Prompts.cs b/src/AdoLens/Prompts.cs index 401f07a..f91888e 100644 --- a/src/AdoLens/Prompts.cs +++ b/src/AdoLens/Prompts.cs @@ -14,6 +14,37 @@ public static class Prompts { /// The prompt classes of a mode: developer prompts (dev_*) only in the dev and full modes. /// IEnumerable on purpose: an array would bind to the generic WithPrompts<T>(target) and register nothing. + /// Title and description of each prompt for a Polish installation (what people see in the prompt list). + public static readonly Dictionary Polish = new() + { + ["verify"] = ("Sprawdź work item: czy zrobione jak trzeba i gdzie jest", "Czy work item zrobiono tak, jak proszono, i gdzie jest (DEV / UAT / PROD) - kryterium po kryterium."), + ["explain"] = ("Wyjaśnij, jak działa funkcja", "Jak działa funkcja i dlaczego, językiem biznesu, ze źródłami."), + ["story"] = ("Napisz work item", "Wymaganie, błąd albo task - krótko i kompletnie, z kryteriami akceptacji; zakładany po twojej zgodzie."), + ["sprint"] = ("Stan sprintu albo review", "Jak idzie sprint, co jest zagrożone, co dostarczono i gdzie to jest."), + ["refine"] = ("Refinement backlogu", "Co w backlogu trzeba poprawić przed planowaniem: brak kryteriów, estymacji, za duże elementy, pytania do zespołu."), + ["test"] = ("Przypadki testowe z kryteriów akceptacji", "Przypadki testowe napisane z kryteriów akceptacji work itemu i stan jego testów."), + ["wiki"] = ("Napisz stronę dokumentacji", "Strona wiki o funkcji albo dostarczonej zmianie, na podstawie work itemów i kodu."), + ["label"] = ("Skąd jest ten komunikat", "Komunikat D365 wklejony tak, jak widać go na ekranie: jego etykieta, tłumaczenia oraz gdzie i w jakim procesie się pojawia."), + ["dev_start"] = ("Zacznij pracę nad work itemem", "Programista: co zrobić, gdzie w kodzie, plan z kryteriów akceptacji."), + ["dev_pr"] = ("Przygotuj albo sprawdź pull request", "Programista: opis PR z work itemu albo przegląd PR względem jego work itemu."), + ["dev_build"] = ("Dlaczego build jest czerwony", "Programista: błędy nieudanego builda i propozycja poprawki."), + ["dev_why"] = ("Dlaczego ten kod tak wygląda", "Programista: historia pliku albo obiektu - kto zmieniał, kiedy, dla jakich work itemów i po co."), + }; + + /// The prompt methods of the mode. + public static IEnumerable Methods(Settings settings) => + TypesFor(settings).SelectMany(t => t.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)) + .Where(m => m.GetCustomAttributes(typeof(McpServerPromptAttribute), false).Length > 0); + + /// A prompt, with its Polish title and description when the installation is Polish. + public static McpServerPrompt Create(System.Reflection.MethodInfo method, IServiceProvider services, bool polish) + { + var name = ((McpServerPromptAttribute)method.GetCustomAttributes(typeof(McpServerPromptAttribute), false)[0]).Name ?? method.Name; + var options = new McpServerPromptCreateOptions { Services = services }; + if (polish && Polish.TryGetValue(name, out var pl)) { options.Title = pl.Title; options.Description = pl.Description; } + return McpServerPrompt.Create(method, options: options); + } + public static IEnumerable TypesFor(Settings settings) => settings.Mode == "po" ? [typeof(Prompts)] : [typeof(Prompts), typeof(DevPrompts)]; diff --git a/src/AdoLens/Review.cs b/src/AdoLens/Review.cs index c28794a..d686563 100644 --- a/src/AdoLens/Review.cs +++ b/src/AdoLens/Review.cs @@ -117,9 +117,7 @@ static async Task ReviewOneAsync(Session session, string change, string? 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); - // A required language with no label file at all: every label is missing in it. - foreach (var lang in session.Config.LabelLanguages.Where(l => !languages.Any(x => x.Language.Equals(l, StringComparison.OrdinalIgnoreCase)))) - missing.Add($"{lang}: no label file {fileId}.{lang}.label.txt in the model - none of its labels are translated"); + missing.AddRange(MissingLanguageFiles(session.Config.LabelLanguages, languages.Select(x => x.Language), fileId)); foreach (var (lang, path) in languages) { if (!session.Config.LabelLanguageRequired(lang)) continue; @@ -136,6 +134,13 @@ static async Task ReviewOneAsync(Session session, string change, string? return sb.ToString().TrimEnd(); } + /// A required language with no label file at all: every label is missing in it. + internal static IEnumerable MissingLanguageFiles(IEnumerable required, IEnumerable present, string fileId) + { + var have = present.ToHashSet(StringComparer.OrdinalIgnoreCase); + return required.Where(l => !have.Contains(l)).Select(l => $"{l}: no label file {fileId}.{l}.label.txt in the model - none of its labels are translated"); + } + static void Add(Dictionary> map, string file, string id) { if (!map.TryGetValue(file, out var set)) map[file] = set = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/src/AdoLens/Skills.cs b/src/AdoLens/Skills.cs index 47ecd46..171c0c1 100644 --- a/src/AdoLens/Skills.cs +++ b/src/AdoLens/Skills.cs @@ -16,8 +16,56 @@ public static class Skills _ => skill.StartsWith("po-", StringComparison.OrdinalIgnoreCase), }; + /// + /// A skill's SKILL.md in the language of the installation: in Polish, the description (what people see in the list + /// of skills, and what Claude matches their words against) comes from description.pl.txt. The instructions stay in + /// English - Claude answers in the language the user writes in. + /// + public static string Text(string skillDir, bool polish) + { + var text = File.ReadAllText(Path.Combine(skillDir, "SKILL.md")); + var pl = Path.Combine(skillDir, "description.pl.txt"); + if (!polish || !File.Exists(pl)) return text; + var description = File.ReadAllText(pl).Replace("\r", "").Replace('\n', ' ').Trim(); + return System.Text.RegularExpressions.Regex.Replace(text, @"(?m)^description:[^\r\n]*", _ => "description: " + description, System.Text.RegularExpressions.RegexOptions.None, TimeSpan.FromSeconds(1)); + } + + /// Folder of the ZIPs for Claude Desktop next to an installed program, or null when running from the sources. + public static string? DesktopDir(string? skillsDir = null) + { + var source = skillsDir ?? Prompts.SkillsDir(); + if (source is null || Path.GetDirectoryName(source) is not { } app || !File.Exists(Path.Combine(app, "bin", "adolens.exe"))) return null; + return Path.Combine(app, "skills-for-claude-desktop"); + } + + /// One ZIP per skill ("name/SKILL.md"), in the language of the installation, for upload in Claude Desktop. + public static string DesktopZips(bool polish, string? skillsDir = null, string? outDir = null) + { + var source = skillsDir ?? Prompts.SkillsDir(); + var target = outDir ?? DesktopDir(source); + if (source is null || target is null) return ""; + Directory.CreateDirectory(target); + var made = 0; + foreach (var dir in Directory.GetDirectories(source).Where(d => File.Exists(Path.Combine(d, "SKILL.md")))) + { + var name = Path.GetFileName(dir); + var zip = Path.Combine(target, name + ".zip"); + if (File.Exists(zip)) File.Delete(zip); + using var archive = System.IO.Compression.ZipFile.Open(zip, System.IO.Compression.ZipArchiveMode.Create); + foreach (var file in Directory.GetFiles(dir, "*", SearchOption.AllDirectories).Where(f => !Path.GetFileName(f).StartsWith("description.", StringComparison.OrdinalIgnoreCase))) + { + var entry = archive.CreateEntry(name + "/" + Path.GetRelativePath(dir, file).Replace('\\', '/')); + using var w = new StreamWriter(entry.Open(), new System.Text.UTF8Encoding(false)); + if (Path.GetFileName(file).Equals("SKILL.md", StringComparison.OrdinalIgnoreCase)) w.Write(Text(dir, polish)); + else { w.Flush(); using var r = File.OpenRead(file); r.CopyTo(w.BaseStream); } + } + made++; + } + return $"Claude Desktop: {made} skill ZIP(s) in {target} - upload the ones you want in Settings > Capabilities > Skills"; + } + /// 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) + public static string Sync(string mode, string? skillsDir = null, string? codeDir = null, bool? polish = null) { var source = skillsDir ?? Prompts.SkillsDir(); var target = codeDir ?? CodeDir; @@ -34,7 +82,7 @@ public static string Sync(string mode, string? skillsDir = null, string? codeDir if (ForMode(name, mode)) { Directory.CreateDirectory(dst); - File.Copy(file, Path.Combine(dst, "SKILL.md"), overwrite: true); + File.WriteAllText(Path.Combine(dst, "SKILL.md"), Text(dir, polish ?? Ui.Polish), new System.Text.UTF8Encoding(false)); added.Add(name); } else if (File.Exists(Path.Combine(dst, "SKILL.md"))) diff --git a/src/AdoLens/WorkItems.cs b/src/AdoLens/WorkItems.cs index defa15d..d28f2c4 100644 --- a/src/AdoLens/WorkItems.cs +++ b/src/AdoLens/WorkItems.cs @@ -257,7 +257,10 @@ public async Task> BuildOpsAsync(Snapshot s, TypeInfo type, WriteRe else ops.Add(new { op = "add", path = "/fields/" + reference, value }); } if (r.Parent is { } parent) + { ops.Add(new { op = "add", path = "/relations/-", value = new { rel = "System.LinkTypes.Hierarchy-Reverse", url = $"{WebBase}_apis/wit/workItems/{parent}" } }); + if (await OddParentAsync(s, type, parent, ct) is { } odd) notes.Add(odd); + } Lengths(type, r, notes, Has(s, type, AcceptanceRef)); return ops; @@ -330,6 +333,25 @@ public async Task> RequiredRulesAsync(Snapshot s, TypeInfo ty return list; } + /// + /// A note when the parent is of a lower or the same backlog level (a requirement under a task, a task under a task): + /// the link is still made - some teams do it on purpose - but it is usually a mistaken id. + /// + async Task OddParentAsync(Snapshot s, TypeInfo child, int parent, CancellationToken ct) + { + static int? Rank(string level) => level switch { Levels.Portfolio => 0, Levels.Requirement or Levels.Bug => 1, Levels.Task => 2, _ => null }; + if (Rank(child.Level) is not { } c) return null; + List found; + try { found = await GetAsync([parent], relations: false, ["System.Id", "System.WorkItemType"], ct); } + catch (AdoApiException) { return null; } + if (found.Count == 0) return $"parent #{parent} was not found (missing, deleted or not visible with this PAT)"; + var parentType = Field(found[0], "System.WorkItemType") ?? ""; + if (s.FindType(parentType) is not { } pt || Rank(pt.Level) is not { } p) return null; + // Same level: fine for portfolio (Epic > Feature) and for a bug under a requirement. + var odd = p > c || p == c && child.Level != Levels.Portfolio && !(child.Level == Levels.Bug && pt.Level == Levels.Requirement); + return odd ? $"unusual parent: #{parent} is a {parentType} ({pt.Level}) and this is a {child.Name} ({child.Level}) - check the parent id" : null; + } + /// Fields set by Azure DevOps on every save, never by the caller. internal static bool FilledByAzureDevOps(string reference) => reference is "System.State" or "System.Reason" or "System.AreaPath" or "System.IterationPath" or "System.AreaId" or "System.IterationId" diff --git a/tests/AdoLens.Tests/LabelTests.cs b/tests/AdoLens.Tests/LabelTests.cs index ef65c1f..4f814f9 100644 --- a/tests/AdoLens.Tests/LabelTests.cs +++ b/tests/AdoLens.Tests/LabelTests.cs @@ -84,4 +84,12 @@ public void Only_the_required_label_languages_are_reported() Assert.True(some.LabelLanguageRequired("pl")); Assert.False(some.LabelLanguageRequired("de-DE")); } + + [Fact] + public void A_required_language_without_a_label_file_is_reported() + { + var missing = Review.MissingLanguageFiles(["en-US", "pl", "de-DE"], ["en-US", "PL"], "Demo").ToList(); + Assert.Equal(["de-DE: no label file Demo.de-DE.label.txt in the model - none of its labels are translated"], missing); + Assert.Empty(Review.MissingLanguageFiles([], ["en-US"], "Demo")); + } } diff --git a/tests/AdoLens.Tests/PromptTests.cs b/tests/AdoLens.Tests/PromptTests.cs index d83d626..08bcb0e 100644 --- a/tests/AdoLens.Tests/PromptTests.cs +++ b/tests/AdoLens.Tests/PromptTests.cs @@ -47,6 +47,41 @@ public void The_server_registers_the_prompts_of_the_mode() Assert.Contains("dev_pr", PromptNames("dev")); } + [Fact] + public void A_Polish_installation_gets_Polish_skill_descriptions_and_prompt_titles() + { + var skills = Prompts.SkillsDir()!; + foreach (var dir in Directory.GetDirectories(skills).Where(d => File.Exists(Path.Combine(d, "SKILL.md")))) + { + var pl = Path.Combine(dir, "description.pl.txt"); + Assert.True(File.Exists(pl), Path.GetFileName(dir)); + // The description stays one plain YAML line: no ": " that would start a mapping. + Assert.DoesNotContain(": ", File.ReadAllText(pl)); + var text = Skills.Text(dir, polish: true); + Assert.Contains("description: " + File.ReadAllText(pl).Trim(), text); + Assert.Equal(File.ReadAllText(Path.Combine(dir, "SKILL.md")), Skills.Text(dir, polish: false)); + } + // Every prompt has its Polish title and description. + var names = Prompts.Methods(new Settings { Mode = "full" }) + .Select(m => ((McpServerPromptAttribute)m.GetCustomAttributes(typeof(McpServerPromptAttribute), false)[0]).Name).ToList(); + Assert.All(names, n => Assert.True(Prompts.Polish.ContainsKey(n!), n)); + } + + [Fact] + public void Claude_Desktop_gets_one_zip_per_skill_in_the_language_of_the_installation() + { + var source = Path.Combine(dir, "skills"); + Directory.CreateDirectory(Path.Combine(source, "po-verify")); + File.WriteAllText(Path.Combine(source, "po-verify", "SKILL.md"), "---\nname: po-verify\ndescription: Checks work.\n---\n# Steps\n"); + File.WriteAllText(Path.Combine(source, "po-verify", "description.pl.txt"), "Sprawdza pracę."); + var zips = Path.Combine(dir, "zips"); + Assert.Contains("1 skill ZIP(s)", Skills.DesktopZips(polish: true, source, zips)); + using var zip = System.IO.Compression.ZipFile.OpenRead(Path.Combine(zips, "po-verify.zip")); + Assert.Equal(["po-verify/SKILL.md"], zip.Entries.Select(e => e.FullName)); + using var reader = new StreamReader(zip.Entries[0].Open()); + Assert.Contains("description: Sprawdza pracę.", reader.ReadToEnd()); + } + [Fact] public void Claude_Code_gets_only_the_skills_of_the_mode() { diff --git a/tests/AdoLens.Tests/WorkItemTests.cs b/tests/AdoLens.Tests/WorkItemTests.cs index c5de436..92a99a8 100644 --- a/tests/AdoLens.Tests/WorkItemTests.cs +++ b/tests/AdoLens.Tests/WorkItemTests.cs @@ -182,6 +182,17 @@ public async Task Create_writes_markdown_as_html_people_and_canonical_values() Assert.StartsWith("created #10 Product Backlog Item", text); } + [Fact] + public async Task A_parent_of_a_lower_level_is_noted() + { + // A backlog item under a task (#2): made, with a note - usually a mistaken id. + var odd = await WorkQueries.CreateAsync(lens, null, null, "Product Backlog Item", new WriteRequest { Title = "Export", Parent = 2 }); + Assert.Contains("unusual parent: #2 is a Task", odd); + // Under a feature (#3): as it should be. + var fine = await WorkQueries.CreateAsync(lens, null, null, "Product Backlog Item", new WriteRequest { Title = "Export", Parent = 3 }); + Assert.DoesNotContain("unusual parent", fine); + } + [Fact] public async Task Criteria_go_into_the_description_when_the_type_has_no_field() {