From f44cf15a080594f213acb9bfb83d7d0a2e3fcca6 Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:25:23 +0100 Subject: [PATCH] ADO Lens 0.10.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Environments per repository, code in another project, a tidier installer page. - Environment map keys `Repository:branch` ("ERP:staging=UAT (sbx03)"): a repository with keys of its own uses only those, so the same branch name can mean different environments - or none - in different repositories. `ado_trace` no longer reports "NOT merged yet" for branches a repository does not use. Plain keys (`staging=UAT`) work as before. - Detecting the flow (installer, `adolens setup`, `ado_settings`): with several repositories each branch is named with its repository; repositories without a commit in the last half year and work branches (`000000_TestBranch`, `123456_LoginForm_…`) are left out. - `adolens config --code-project ` / `ado_settings codeProject=`: the project with the code, pull requests and pipelines when it is not the work items' one; `ado_file`, the code part of `ado_search`, `ado_pr`, `ado_pr_update`, `ado_pipeline`, `ado_changes` and `ado_label` look there. - Installer, environments page: a larger wizard, as many rows as fit (the rest is named, to set later in the settings), the repository shown next to each branch, and one drop-down for where to save instead of three options that did not fit. --- CHANGELOG.md | 21 ++++++- README.md | 13 +++++ README.pl.md | 13 +++++ packaging/inno/ado-lens.iss | 78 +++++++++++++++---------- src/AdoLens/AdoLens.csproj | 2 +- src/AdoLens/Cli.cs | 4 +- src/AdoLens/CommandLine.cs | 2 +- src/AdoLens/EnvironmentFlow.cs | 60 ++++++++++++++++++- src/AdoLens/Knowledge.cs | 16 +++-- src/AdoLens/Labels.cs | 2 +- src/AdoLens/Lens.cs | 4 ++ src/AdoLens/McpTools.cs | 5 +- src/AdoLens/Pipelines.cs | 10 ++-- src/AdoLens/PullRequests.cs | 10 ++-- src/AdoLens/Queries.cs | 27 +++++---- src/AdoLens/Settings.cs | 12 +++- src/AdoLens/ToolArguments.cs | 4 +- src/AdoLens/Trace.cs | 52 ++++++++++------- tests/AdoLens.Tests/ArgumentTests.cs | 4 +- tests/AdoLens.Tests/ImprovementTests.cs | 47 +++++++++++++++ tests/AdoLens.Tests/SetupFlowTests.cs | 1 + 21 files changed, 292 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2857ebb..128095f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.10.0 — 2026-10-26 + +Environments per repository, code in another project, a tidier installer page. + +- Environment map keys `Repository:branch` ("ERP:staging=UAT (sbx03)"): a repository with keys of its own uses only + those, so the same branch name can mean different environments - or none - in different repositories. `ado_trace` + no longer reports "NOT merged yet" for branches a repository does not use. Plain keys (`staging=UAT`) work as before. +- Detecting the flow (installer, `adolens setup`, `ado_settings`): with several repositories each branch is named with + its repository; repositories without a commit in the last half year and work branches (`000000_TestBranch`, + `123456_LoginForm_…`) are left out. +- `adolens config --code-project ` / `ado_settings codeProject=`: the project with the code, pull requests + and pipelines when it is not the work items' one; `ado_file`, the code part of `ado_search`, `ado_pr`, + `ado_pr_update`, `ado_pipeline`, `ado_changes` and `ado_label` look there. +- Installer, environments page: a larger wizard, as many rows as fit (the rest is named, to set later in the settings), + the repository shown next to each branch, and one drop-down for where to save instead of three options that did not + fit. + ## 0.9.5 — 2026-10-26 - `ado_file` by name works without Code Search (not installed or failing): the file lists of the TFVC Metadata @@ -14,7 +31,7 @@ - `ado_trace`: a branch earlier in the environment flow than the one a change was merged into is not reported as "NOT merged yet" (a change merged straight into master of a library repository says nothing about its dev branch). - Before "not yet", one question per source branch: completed PRs from the same source branch into that branch that - are not linked to the item ("merged by PRs not linked to the item: PR 31002 (2021-09-01, staging <- 243623_…)"). + are not linked to the item ("merged by PRs not linked to the item: PR 31002 (2021-09-01, staging <- 123456_…)"). ## 0.9.3 — 2026-10-19 @@ -96,7 +113,7 @@ Fixes from testing on a real project. setup EXE already did; `uninstall.ps1` takes it off. - Tool arguments in another JSON shape are read instead of failing with a bare "An error occurred invoking": - `ids=714800` (a number for text), `workItems=[1,2]`, `file=1`, `top="20"`, `draft="true"`. This was behind the failing + `ids=123456` (a number for text), `workItems=[1,2]`, `file=1`, `top="20"`, `draft="true"`. This was behind the failing `ado_update` (even with only a comment), `ado_pr_update action=create` with work items and `ado_attachments file=`. An argument that still cannot be read says which and why. - `ado_attachments`: two files with the same name are listed by number to pick one. diff --git a/README.md b/README.md index 930911c..1ea4e4f 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,19 @@ adolens config --connection fabrikam --env "Main-Deploy/Stage 1=UAT + SCM" `ado_trace` then lists the stages in that order with their labels ("UAT (sbx03): YES") and, for Git, also checks whether the work is merged into each mapped branch even when no release pipeline deploys it. +**Several repositories.** The same branch name can mean different things in different repositories — or nothing (a +library merged straight into `master`). A key `Repository:branch` belongs to one repository; a repository with keys of +its own uses only those, the others use the plain branch keys. The installer and `adolens setup` name branches this way +when a project has more than one active repository, and leave out work branches (`000000_TestBranch`, `123456_…`): + +```powershell +adolens config --connection contoso --env "ERP:dev=DEV (sbx01)" --env "ERP:staging=UAT (sbx03)" --env "ERP:master=PROD" --env "Common:master=PROD" +``` + +**Code in another project.** When the work items live in one project and the code, pull requests and pipelines in +another, set it once: `adolens config --connection contoso --code-project ERP` (or ask Claude: `ado_settings +codeProject=ERP`). `ado_file`, `ado_search` (code), `ado_pr`, `ado_pipeline` and `ado_label` then look there. + **One map for the whole team.** The map can live in Azure DevOps itself: a page "ADO Lens" in the project wiki with a two-column table (branch or release stage | environment), in flow order. Everyone using ADO Lens on the project reads it, so Product Owners configure nothing; a local map (`--env`) overrides it for one person. The installer, `adolens diff --git a/README.pl.md b/README.pl.md index 1352bc7..3bc200b 100644 --- a/README.pl.md +++ b/README.pl.md @@ -157,6 +157,19 @@ adolens config --connection fabrikam --env "Main-Deploy/Stage 1=UAT + SCM" Wtedy `ado_trace` wypisuje etapy w tej kolejności z ich etykietami („UAT (sbx03): YES”), a dla Gita sprawdza też, czy praca jest scalona do każdej zmapowanej gałęzi, nawet gdy żaden pipeline jej nie wdraża. +**Kilka repozytoriów.** Ta sama nazwa gałęzi może znaczyć co innego w różnych repozytoriach — albo nic (biblioteka +scalana prosto do `master`). Klucz `Repozytorium:gałąź` należy do jednego repozytorium; repozytorium z własnymi kluczami +używa tylko ich, pozostałe — zwykłych kluczy gałęzi. Instalator i `adolens setup` nazywają tak gałęzie, gdy projekt ma +więcej niż jedno aktywne repozytorium, i pomijają gałęzie robocze (`000000_TestBranch`, `123456_…`): + +```powershell +adolens config --connection contoso --env "ERP:dev=DEV (sbx01)" --env "ERP:staging=UAT (sbx03)" --env "ERP:master=PROD" --env "Common:master=PROD" +``` + +**Kod w innym projekcie.** Gdy work itemy są w jednym projekcie, a kod, pull requesty i pipeline'y w innym, ustaw to +raz: `adolens config --connection contoso --code-project ERP` (albo poproś Claude'a: `ado_settings codeProject=ERP`). +Wtedy `ado_file`, `ado_search` (kod), `ado_pr`, `ado_pipeline` i `ado_label` szukają tam. + **Jedna mapa dla całego zespołu.** Mapa może mieszkać w samym Azure DevOps: strona „ADO Lens” w wiki projektu z tabelą w dwóch kolumnach (gałąź albo etap release'u | środowisko), w kolejności przepływu. Każdy, kto używa ADO Lens w projekcie, ją czyta, więc Product Ownerzy niczego nie konfigurują; lokalna mapa (`--env`) nadpisuje ją dla jednej osoby. Zapisują diff --git a/packaging/inno/ado-lens.iss b/packaging/inno/ado-lens.iss index f364d55..6ad8736 100644 --- a/packaging/inno/ado-lens.iss +++ b/packaging/inno/ado-lens.iss @@ -41,6 +41,8 @@ OutputBaseFilename=ado-lens-setup-{#Version} Compression=lzma2/max SolidCompression=yes WizardStyle=modern +; Taller and wider than the default: the environments page lists release stages and branches. +WizardSizePercent=120,130 UninstallDisplayName=ADO Lens UninstallDisplayIcon={app}\bin\adolens.exe LicenseFile={#Source}\LICENSE @@ -88,6 +90,7 @@ en.EnvLater=Later: Start menu > ADO Lens - settings > Environments (change, add, 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.EnvMore=... and %1 more: Start menu > ADO Lens - settings > Environments. 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 @@ -104,6 +107,7 @@ pl.EnvLater=Później: menu Start > ADO Lens - ustawienia > Środowiska (zmiana, 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.EnvMore=... i jeszcze %1: menu Start > ADO Lens - ustawienia > Środowiska. 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 @@ -170,7 +174,8 @@ var EnvKeys: TArrayOfString; EnvLabels: array of TNewStaticText; EnvEdits: array of TNewEdit; - EnvMine, EnvTeam, EnvSkip: TNewRadioButton; + EnvScope: TNewComboBox; + EnvMore: TNewStaticText; EnvDetected: Boolean; function SetEnvironmentVariable(lpName: String; lpValue: String): Boolean; @@ -384,22 +389,7 @@ begin 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; + { One drop-down for where to save, at the bottom above the hint: always visible, whatever the number of rows. } EnvLater := TNewStaticText.Create(EnvPage); EnvLater.Parent := EnvPage.Surface; EnvLater.AutoSize := False; @@ -408,17 +398,37 @@ begin EnvLater.Height := ScaleY(30); EnvLater.Caption := CustomMessage('EnvLater'); EnvLater.Top := EnvPage.SurfaceHeight - EnvLater.Height; + EnvScope := TNewComboBox.Create(EnvPage); + EnvScope.Parent := EnvPage.Surface; + EnvScope.Style := csDropDownList; + EnvScope.Width := EnvPage.SurfaceWidth; + EnvScope.Items.Add(CustomMessage('EnvMine')); + EnvScope.Items.Add(CustomMessage('EnvTeam')); + EnvScope.Items.Add(CustomMessage('EnvSkip')); + EnvScope.ItemIndex := 0; + EnvScope.Top := EnvLater.Top - EnvScope.Height - ScaleY(6); + EnvScope.Visible := False; + EnvMore := TNewStaticText.Create(EnvPage); + EnvMore.Parent := EnvPage.Surface; + EnvMore.AutoSize := False; + EnvMore.Width := EnvPage.SurfaceWidth; + EnvMore.Height := ScaleY(16); + EnvMore.Top := EnvScope.Top - EnvMore.Height - ScaleY(2); + EnvMore.Visible := False; 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; + Exe, OutFile, Line, Key, Guess, Caption: String; Lines: TArrayOfString; - I, P, N, Top: Integer; + I, P, N, Top, MaxRows, Skipped: Integer; Code: Integer; begin + { As many rows as fit between the text and the drop-down; the rest is named and can be set in the settings later. } + MaxRows := (EnvMore.Top - (EnvInfo.Top + EnvInfo.Height + ScaleY(4))) div ScaleY(26); + Skipped := 0; EnvDetected := True; Exe := ExpandConstant('{tmp}\adolens.exe'); OutFile := ExpandConstant('{tmp}\adolens-envs.txt'); @@ -437,7 +447,9 @@ begin 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 + if (Copy(Line, 1, 4) = 'row' + #9) and (N >= MaxRows) then + Skipped := Skipped + 1 + else if Copy(Line, 1, 4) = 'row' + #9 then begin Delete(Line, 1, 4); P := Pos(#9, Line); @@ -459,7 +471,14 @@ begin 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; + { "D365FO:staging" is the branch staging of the repository D365FO. } + Caption := Key; + P := Pos(':', Key); + if (P > 1) and (Pos('/', Key) = 0) then + Caption := Copy(Key, P + 1, Length(Key)) + ' (' + Copy(Key, 1, P - 1) + ')'; + EnvLabels[N - 1].Caption := Caption; + EnvLabels[N - 1].AutoSize := False; + EnvLabels[N - 1].Height := ScaleY(18); EnvLabels[N - 1].Top := Top + ScaleY(3); EnvLabels[N - 1].Width := EnvPage.SurfaceWidth div 2 - ScaleX(8); EnvEdits[N - 1] := TNewEdit.Create(EnvPage); @@ -476,13 +495,12 @@ begin 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; + if Skipped > 0 then + begin + EnvMore.Caption := FmtMessage(CustomMessage('EnvMore'), [IntToStr(Skipped)]); + EnvMore.Visible := True; + end; + EnvScope.Visible := True; end; { Saves the names typed on the environment page; returns False to stay on the page when it failed. } @@ -492,7 +510,7 @@ var I, Code: Integer; begin Result := True; - if EnvSkip.Checked or (GetArrayLength(EnvKeys) = 0) then Exit; + if (EnvScope.ItemIndex = 2) or (GetArrayLength(EnvKeys) = 0) then Exit; Env := ''; for I := 0 to GetArrayLength(EnvKeys) - 1 do begin @@ -506,7 +524,7 @@ begin end; end; if Env = '' then Exit; - if EnvTeam.Checked then Scope := 'team' else Scope := 'local'; + if EnvScope.ItemIndex = 1 then Scope := 'team' else Scope := 'local'; Code := RunAdoLens(ExpandConstant('{tmp}\adolens.exe'), 'setup --env "' + Env + '" --scope ' + Scope, Output); if Code <> 0 then begin diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index 3eb1e49..21f83a6 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.9.5 + 0.10.0 diff --git a/src/AdoLens/Cli.cs b/src/AdoLens/Cli.cs index 2525189..3d7e860 100644 --- a/src/AdoLens/Cli.cs +++ b/src/AdoLens/Cli.cs @@ -27,6 +27,7 @@ logout remove the stored PAT of a connection --connection connections list the connections config show or change settings --connection --project --team (defaults of a connection) + --code-project (code, pull requests and pipelines in another project; "" clears) --default-connection --mode po|dev|full --usage-log true|false --show-tokens true|false --tool-loading auto|demand|all --add-toolset --remove-toolset --confirm-above --language pl|en|auto (settings console and installer texts) @@ -243,7 +244,7 @@ public int Connections() { var pat = CredentialStore.Read(name) is not null ? "PAT stored" : "no stored PAT"; Console.WriteLine($"{(name.Equals(settings.DefaultConnection, StringComparison.OrdinalIgnoreCase) ? "*" : " ")} {name,-16} " + - $"https://dev.azure.com/{c.Organization} project {c.Project ?? "-"} team {c.Team ?? "-"} ({pat})" + + $"https://dev.azure.com/{c.Organization} project {c.Project ?? "-"}{(c.CodeProject is { } cp ? $" code in {cp}" : "")} team {c.Team ?? "-"} ({pat})" + (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)}" : "")); } @@ -262,6 +263,7 @@ public int Config() throw new ArgumentException($"no connection named '{name}' - add it with 'adolens login --connection {name}'"); if (cl.Opt("project") is { } p) { conn.Project = NullIfEmpty(p); changed = true; } if (cl.Opt("team") is { } t) { conn.Team = NullIfEmpty(t); changed = true; } + if (cl.Opt("code-project") is { } cp) { conn.CodeProject = NullIfEmpty(cp); changed = true; } while (cl.Opt("env") is { } e) { var eq = e.IndexOf('='); diff --git a/src/AdoLens/CommandLine.cs b/src/AdoLens/CommandLine.cs index c92aeda..cb02fc5 100644 --- a/src/AdoLens/CommandLine.cs +++ b/src/AdoLens/CommandLine.cs @@ -23,7 +23,7 @@ public sealed partial class CommandLine ["pr"] = ["connection=", "project=", "repo=", "list=", "detail=", "merged=", "since=", "top="], ["pipeline"] = ["connection=", "project=", "definition=", "branch=", "top=", "detail=", "artifacts", "artifact=", "part=", "save", "to=", "deployments", "stage="], ["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=", "tool-loading=", "language=", "label-languages=", "download-dir="], + ["config"] = ["env=", "remove-env=", "connection=", "default-connection=", "project=", "code-project=", "team=", "mode=", "usage-log=", "add-toolset=", "remove-toolset=", "confirm-above=", "show-tokens=", "tool-loading=", "language=", "label-languages=", "download-dir="], ["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="], diff --git a/src/AdoLens/EnvironmentFlow.cs b/src/AdoLens/EnvironmentFlow.cs index 1d48a9b..d538228 100644 --- a/src/AdoLens/EnvironmentFlow.cs +++ b/src/AdoLens/EnvironmentFlow.cs @@ -24,17 +24,71 @@ public static class EnvironmentFlow if (d.When is not { } when || when < DateTime.UtcNow.AddDays(-183)) continue; keys.Add(d.Branch?.StartsWith("$/", StringComparison.Ordinal) == true ? $"{d.Definition}/{d.Stage}" : Code.Short(d.Branch) ?? $"{d.Definition}/{d.Stage}"); } - // Long-lived branches of the repositories that changed lately (feature branches are filtered by ProposeFlow). + // Long-lived branches of the repositories in use (a commit in the last half year); feature and work branches are + // filtered by ProposeFlow. With several repositories each branch is named with its repository ("D365FO:staging"), + // since the same branch name can mean different environments - or none - in different repositories. try { var repos = await session.Client.GetListAsync($"{AdoClient.Scope(project)}_apis/git/repositories", ct); + var active = new List<(string Id, string Name)>(); foreach (var repo in repos.Where(r => !Json.Bool(r, "isDisabled") && r.TryGetProperty("size", out var s) && s.GetInt64() > 0).Take(30)) { - var refs = await session.Client.GetListAsync($"_apis/git/repositories/{Json.Str(repo, "id")}/refs?filter=heads/&$top=200", ct); - keys.AddRange(refs.Select(r => Code.Short(Json.Str(r, "name")) ?? "").Where(n => n.Length > 0 && !n.Contains('/'))); + var id = Json.Str(repo, "id") ?? ""; + var last = await session.Client.GetListAsync($"_apis/git/repositories/{id}/commits?searchCriteria.$top=1", ct); + if (last.Count > 0 && DateTime.TryParse(Json.Str(Json.Obj(last[0], "committer"), "date"), System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AdjustToUniversal, out var when) && when > DateTime.UtcNow.AddDays(-183)) + active.Add((id, Json.Str(repo, "name") ?? id)); + } + foreach (var (id, name) in active) + { + var refs = await session.Client.GetListAsync($"_apis/git/repositories/{id}/refs?filter=heads/&$top=200", ct); + keys.AddRange(refs.Select(r => Code.Short(Json.Str(r, "name")) ?? "").Where(n => n.Length > 0 && !n.Contains('/')) + .Select(n => active.Count > 1 ? $"{name}:{n}" : n)); } } catch (AdoApiException) { } // no Git, or no Code (Read) scope return TraceQueries.ProposeFlow(keys.Distinct(StringComparer.OrdinalIgnoreCase)); } } + +/// +/// The environment map read with its three kinds of keys: a release stage ("Definition/Stage"), a branch of every +/// repository ("staging"), or a branch of one repository ("D365FO:staging"). A repository with keys of its own uses only +/// those, so the same branch name can mean different things in different repositories (or nothing at all). +/// +public sealed class EnvMap(IReadOnlyDictionary map) +{ + public IReadOnlyDictionary Raw => map; + public int Count => map.Count; + readonly List order = map.Keys.ToList(); + + public static string? RepoOf(string key) => key.IndexOf(':') is var i and > 0 && !key.Contains('/') ? key[..i] : null; + public static string BranchOf(string key) => key.IndexOf(':') is var i and > 0 && !key.Contains('/') ? key[(i + 1)..] : key; + public static bool IsStage(string key) => key.Contains('/'); + + bool HasOwn(string? repo) => repo is not null && map.Keys.Any(k => string.Equals(RepoOf(k), repo, StringComparison.OrdinalIgnoreCase)); + + /// The key that names of , or null. + public string? BranchKey(string? repo, string? branch) + { + if (string.IsNullOrEmpty(branch)) return null; + if (HasOwn(repo)) return map.Keys.FirstOrDefault(k => string.Equals(RepoOf(k), repo, StringComparison.OrdinalIgnoreCase) && BranchOf(k).Equals(branch, StringComparison.OrdinalIgnoreCase)); + return map.Keys.FirstOrDefault(k => RepoOf(k) is null && !IsStage(k) && k.Equals(branch, StringComparison.OrdinalIgnoreCase)); + } + + public bool Has(string key) => map.ContainsKey(key); + public string this[string key] => map[key]; + public string? Label(string? key) => key is not null && map.TryGetValue(key, out var v) ? v : null; + + /// Position in the flow (DEV first), or int.MaxValue. + public int Order(string? key) => key is null ? int.MaxValue : order.FindIndex(k => k.Equals(key, StringComparison.OrdinalIgnoreCase)) is var i and >= 0 ? i : int.MaxValue; + + /// The branches that mean an environment in , in flow order. + public IEnumerable<(string Branch, string Label)> BranchesOf(string? repo) => + (HasOwn(repo) ? map.Where(e => string.Equals(RepoOf(e.Key), repo, StringComparison.OrdinalIgnoreCase)) + : map.Where(e => RepoOf(e.Key) is null && !IsStage(e.Key))) + .Select(e => (BranchOf(e.Key), e.Value)); + + /// Whether is an environment branch of any repository (a promotion source). + public bool IsEnvironmentBranch(string? repo, string branch) => BranchKey(repo, branch) is not null; +} diff --git a/src/AdoLens/Knowledge.cs b/src/AdoLens/Knowledge.cs index 4710478..e3a4510 100644 --- a/src/AdoLens/Knowledge.cs +++ b/src/AdoLens/Knowledge.cs @@ -29,8 +29,9 @@ public static async Task SearchAsync(Lens lens, string? connection, stri var parts = new List<(string Scope, Task Task)>(); if (wanted.Contains("work")) parts.Add(("work", Part(() => WorkItemsAsync(session, name, text, top, ct)))); if (wanted.Contains("wiki")) parts.Add(("wiki", Part(() => WikiAsync(session.Client, name, text, top, ct)))); - if (wanted.Contains("code")) parts.Add(("code", Part(() => CodeAsync(session.Client, name, text, top, null, ct)))); - var sb = new StringBuilder($"search '{text}' in {name}\n"); + var codeIn = project is null ? session.CodeProject(null) : name; + if (wanted.Contains("code")) parts.Add(("code", Part(() => CodeAsync(session.Client, codeIn, text, top, null, ct)))); + var sb = new StringBuilder($"search '{text}' in {name}{(codeIn != name && wanted.Contains("code") ? $" (code in {codeIn})" : "")}\n"); foreach (var (_, task) in parts) sb.AppendLine(await task); return sb.ToString().TrimEnd(); } @@ -284,13 +285,16 @@ public static async Task FileAsync(Lens lens, string? connection, string string? part, bool history, int top, string? detail, CancellationToken ct = default) { var session = lens.For(connection); - var name = session.Project(project); + var name = session.CodeProject(project); var (file, choices) = await ResolveAsync(session, name, path, repo, ct); if (file is null) return choices!; // An environment name ("sbx03", "UAT") instead of a branch: the branch of the environment map. - if (version is not null && session.Config.Environments.FirstOrDefault(e => !e.Key.Contains('/') && - (e.Value.Equals(version, StringComparison.OrdinalIgnoreCase) || e.Value.Contains(version, StringComparison.OrdinalIgnoreCase))) is { Key: { } branch }) - version = branch; + if (version is not null) + { + var env = new EnvMap(session.Config.Environments); + var named = env.BranchesOf(file.RepoName).Where(e => e.Label.Equals(version, StringComparison.OrdinalIgnoreCase) || e.Label.Contains(version, StringComparison.OrdinalIgnoreCase)).ToList(); + if (named.Count > 0) version = named[0].Branch; + } if (history) return await HistoryAsync(session, file, version, top, ct); if (path.Trim() == "/" || path.TrimEnd().EndsWith('/')) return $"{path} is a folder: history=true lists its changes"; diff --git a/src/AdoLens/Labels.cs b/src/AdoLens/Labels.cs index dedb4a9..6a04c2b 100644 --- a/src/AdoLens/Labels.cs +++ b/src/AdoLens/Labels.cs @@ -75,7 +75,7 @@ public static async Task FindAsync(Lens lens, string? connection, string { 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 name = session.CodeProject(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); diff --git a/src/AdoLens/Lens.cs b/src/AdoLens/Lens.cs index 943994e..e154157 100644 --- a/src/AdoLens/Lens.cs +++ b/src/AdoLens/Lens.cs @@ -14,6 +14,10 @@ public sealed record Session(Config Config, AdoClient Client, MetadataService Me public TeamSettings Team { get; } = new(Client); + /// For code, pull requests and pipelines: the project given, else the code project of the connection, else its default project. + public string CodeProject(string? project) => + !string.IsNullOrWhiteSpace(project) ? project.Trim() : Config.CodeProject ?? Project(null); + /// The project given, else the connection's default project. public string Project(string? project) => !string.IsNullOrWhiteSpace(project) ? project.Trim() diff --git a/src/AdoLens/McpTools.cs b/src/AdoLens/McpTools.cs index f94d7b4..2ee8efc 100644 --- a/src/AdoLens/McpTools.cs +++ b/src/AdoLens/McpTools.cs @@ -164,14 +164,15 @@ public static Task Status(Lens lens, => McpTools.Run("ado_status", new { connection }, () => Queries.StatusAsync(lens, connection, offline: false, ct)); [McpServerTool(Name = "ado_settings")] - [Description("Shows the connection's settings and the environment flow (detected when not set); with environments saves for good what branches and release stages mean, in flow order: \"dev=DEV (sbx01); staging=UAT (sbx03); master=PROD\" (a stage: \"Release/Stage=UAT + SCM\"). scope=team saves it on the project's wiki page for everyone.")] + [Description("Shows the connection's settings and the environment flow (detected when not set); with environments saves for good what branches and release stages mean, in flow order: \"dev=DEV (sbx01); staging=UAT (sbx03); master=PROD\" (one repository's branch: \"ERP:staging=UAT\"; a stage: \"Release/Stage=UAT + SCM\"). scope=team saves it on the project's wiki page for everyone.")] public static Task Settings(Lens lens, string? environments = null, + [Description("Project with the code, PRs and pipelines when not the work items' one; '-' clears.")] string? codeProject = null, [Description("local (default) | team.")] string? scope = null, string? project = null, string? connection = null, CancellationToken ct = default) - => McpTools.Run("ado_settings", new { environments, scope, project, connection }, () => Queries.SettingsAsync(lens, connection, project, environments, scope, ct)); + => McpTools.Run("ado_settings", new { environments, codeProject, scope, project, connection }, () => Queries.SettingsAsync(lens, connection, project, environments, scope, ct, codeProject)); [McpServerTool(Name = "ado_upgrade")] [Description("Checks for a newer ADO Lens; install=true downloads it (SHA-256 checked) and starts its installer in its own window. Only when the user asks.")] diff --git a/src/AdoLens/Pipelines.cs b/src/AdoLens/Pipelines.cs index 3028d7d..56433ba 100644 --- a/src/AdoLens/Pipelines.cs +++ b/src/AdoLens/Pipelines.cs @@ -25,7 +25,7 @@ static string Line(JsonElement b) => public static async Task ListAsync(Lens lens, string? connection, string? project, string? definition, string? branch, int top, CancellationToken ct = default) { var session = lens.For(connection); - var name = session.Project(project); + var name = session.CodeProject(project); var query = new List { $"$top={Math.Clamp(top, 1, 100)}", "queryOrder=queueTimeDescending" }; if (definition is not null) query.Add("definitions=" + (await DefinitionAsync(session, name, definition, ct)).Id); if (branch is not null) query.Add("branchName=" + Uri.EscapeDataString(branch.StartsWith("refs/", StringComparison.Ordinal) || branch.StartsWith("$/", StringComparison.Ordinal) ? branch : "refs/heads/" + branch)); @@ -82,7 +82,7 @@ public static async Task DeploymentsAsync(Lens lens, string? connection, CancellationToken ct = default) { var session = lens.For(connection); - var name = session.Project(project); + var name = session.CodeProject(project); var vsrm = session.Client.ServiceUrl("vsrm") + Uri.EscapeDataString(name) + "/"; var filter = ""; if (!string.IsNullOrWhiteSpace(definition)) @@ -121,7 +121,7 @@ public static async Task ArtifactsAsync(Lens lens, string? connection, s CancellationToken ct = default) { var session = lens.For(connection); - var scope = AdoClient.Scope(session.Project(project)); + var scope = AdoClient.Scope(session.CodeProject(project)); var list = await session.Client.GetListAsync($"{scope}_apis/build/builds/{build}/artifacts", ct); static long? SizeOf(JsonElement a) => long.TryParse(Json.Str(Json.Obj(Json.Obj(a, "resource"), "properties"), "artifactsize"), out var n) ? n : null; @@ -149,7 +149,7 @@ public static async Task ArtifactsAsync(Lens lens, string? connection, s public static async Task BuildAsync(Lens lens, string? connection, string? project, int id, string? detail, CancellationToken ct = default) { var session = lens.For(connection); - var name = session.Project(project); + var name = session.CodeProject(project); var scope = AdoClient.Scope(name); var b = await session.Client.GetAsync($"{scope}_apis/build/builds/{id}", ct); var sb = new StringBuilder(Line(b)).AppendLine(); @@ -185,7 +185,7 @@ public static async Task BuildAsync(Lens lens, string? connection, strin public static async Task RunAsync(Lens lens, string? connection, string? project, string definition, string? branch, string? parameters, CancellationToken ct = default) { var session = lens.For(connection); - var name = session.Project(project); + var name = session.CodeProject(project); var def = await DefinitionAsync(session, name, definition, ct); var body = new Dictionary { ["definition"] = new { id = def.Id } }; if (branch is not null) body["sourceBranch"] = branch.StartsWith("refs/", StringComparison.Ordinal) || branch.StartsWith("$/", StringComparison.Ordinal) ? branch : "refs/heads/" + branch; diff --git a/src/AdoLens/PullRequests.cs b/src/AdoLens/PullRequests.cs index 06ac610..04565ac 100644 --- a/src/AdoLens/PullRequests.cs +++ b/src/AdoLens/PullRequests.cs @@ -56,7 +56,7 @@ static string Line(JsonElement pr) public static async Task ListAsync(Lens lens, string? connection, string? project, string? list, CancellationToken ct = default) { var session = lens.For(connection); - var name = session.Project(project); + var name = session.CodeProject(project); var (me, meName) = await new Boards(session).MeAsync(ct); var all = await session.Client.GetListAsync($"{AdoClient.Scope(name)}_apis/git/pullrequests?searchCriteria.status=active&$top=200", ct); var which = (list ?? "review").Trim().ToLowerInvariant(); @@ -84,7 +84,7 @@ public static async Task MergedAsync(Lens lens, string? connection, stri CancellationToken ct = default) { var session = lens.For(connection); - var name = session.Project(project); + var name = session.CodeProject(project); var target = branch.StartsWith("refs/", StringComparison.Ordinal) ? branch : "refs/heads/" + branch.Trim(); var from = since is null ? (DateTime?)null : Formatting.ParseSince(since); top = Math.Clamp(top, 1, 200); @@ -134,7 +134,7 @@ static string Since(DateTime utc) static async Task GetAsync(Session session, string? repo, int id, CancellationToken ct) { - var repoId = repo is null ? null : (await session.Code.RepoAsync(repo, session.Config.Project, ct)).Id; + var repoId = repo is null ? null : (await session.Code.RepoAsync(repo, session.CodeProject(null), ct)).Id; return await session.Client.GetAsync(repoId is null ? $"_apis/git/pullrequests/{id}" : $"_apis/git/repositories/{repoId}/pullrequests/{id}", ct); } @@ -155,7 +155,7 @@ public static async Task ShowAsync(Lens lens, string? connection, int id var pr = await GetAsync(session, repo, id, ct); var repoEl = Json.Obj(pr, "repository"); var repoId = Json.Str(repoEl, "id")!; - var project = Json.Str(Json.Obj(repoEl, "project"), "name") ?? session.Project(null); + var project = Json.Str(Json.Obj(repoEl, "project"), "name") ?? session.CodeProject(null); var projectId = Json.Str(Json.Obj(repoEl, "project"), "id"); var baseUrl = $"_apis/git/repositories/{repoId}/pullRequests/{id}"; @@ -282,7 +282,7 @@ public static async Task ActAsync(Lens lens, string? connection, PrActio { if (a.Repo is null || a.Source is null || a.Target is null || string.IsNullOrWhiteSpace(a.Title)) throw new ArgumentException("create needs repo, source, target and title"); - var repo = await session.Code.RepoAsync(a.Repo, session.Config.Project, ct); + var repo = await session.Code.RepoAsync(a.Repo, session.CodeProject(null), ct); var reviewers = (await ReviewersAsync(session, a.Reviewers, a.RequiredReviewers, ct)).Select(r => (object)new { id = r.Who.Id, isRequired = r.Required }).ToList(); var body = new Dictionary { diff --git a/src/AdoLens/Queries.cs b/src/AdoLens/Queries.cs index 8fe2eef..b704166 100644 --- a/src/AdoLens/Queries.cs +++ b/src/AdoLens/Queries.cs @@ -49,18 +49,18 @@ public static async Task StatusAsync(Lens lens, string? connection, bool /// Shows the settings of a connection, or replaces its environment map ("dev=DEV (sbx01); staging=UAT; master=PROD", /// in flow order; empty clears it). Saved for good, so the question is asked only once. /// - public static string SettingsAnswer(Lens lens, string? connection, string? environments) + public static string SettingsAnswer(Lens lens, string? connection, string? environments, string? codeProject = null) { var cfg = lens.For(connection).Config; if (!lens.Settings.Connections.TryGetValue(cfg.Connection, out var conn)) throw new InvalidOperationException($"connection '{cfg.Connection}' comes from environment variables and cannot be changed here"); - if (environments is not null) - { - conn.Environments = ParseEnvironments(environments); - lens.SaveSettings(); - } - var sb = new StringBuilder(environments is null ? "" : "saved.\n"); - sb.AppendLine($"connection {cfg.Connection}: https://dev.azure.com/{conn.Organization}, project {conn.Project ?? "-"}, team {conn.Team ?? "-"}, mode {lens.Settings.Mode}"); + if (environments is not null) conn.Environments = ParseEnvironments(environments); + // "-" (or empty) clears it: the code is in the default project again. + if (codeProject is not null) conn.CodeProject = codeProject.Trim() is "" or "-" ? null : codeProject.Trim(); + if (environments is not null || codeProject is not null) lens.SaveSettings(); + var sb = new StringBuilder(environments is null && codeProject is null ? "" : "saved (new conversations use it).\n"); + sb.AppendLine($"connection {cfg.Connection}: https://dev.azure.com/{conn.Organization}, project {conn.Project ?? "-"}" + + $"{(conn.CodeProject is { } cp ? $" (code, pull requests and pipelines in {cp})" : "")}, team {conn.Team ?? "-"}, mode {lens.Settings.Mode}"); sb.Append(conn.Environments.Count == 0 ? "local environments: not set" : "environments (in flow order): " + string.Join(" -> ", conn.Environments.Select(e => Flow(e.Key, e.Value)))); return sb.ToString(); @@ -70,7 +70,8 @@ public static string SettingsAnswer(Lens lens, string? connection, string? envir /// The settings and the environment flow of a project: saved locally or (scope=team) on the project's wiki page for /// everyone; without a map, the flow detected from releases and branches, so Claude can ask the user once. /// - public static async Task SettingsAsync(Lens lens, string? connection, string? project, string? environments, string? scope, CancellationToken ct = default) + public static async Task SettingsAsync(Lens lens, string? connection, string? project, string? environments, string? scope, CancellationToken ct = default, + string? codeProject = null) { var team = (scope ?? "local").Trim().ToLowerInvariant() switch { @@ -87,8 +88,8 @@ public static async Task SettingsAsync(Lens lens, string? connection, st return $"saved for the whole team on the wiki page \"{TeamSettings.PagePath}\" of {name}: " + string.Join(" -> ", map.Select(e => Flow(e.Key, e.Value))) + $"\n{url}"; } - var text = SettingsAnswer(lens, connection, environments); - if (environments is not null) return text; + var text = SettingsAnswer(lens, connection, environments, codeProject); + if (environments is not null || codeProject is not null) return text; var s = lens.For(connection); var p = s.Project(project); @@ -105,7 +106,9 @@ public static async Task SettingsAsync(Lens lens, string? connection, st /// "UAT (sbx03) = branch staging", "UAT + SCM = release stage Main-Deploy/Stage 1": the name, then what it is. internal static string Flow(string key, string name) => - $"{name} = {(key.Contains('/') ? "release stage" : "branch")} {key}"; + EnvMap.IsStage(key) ? $"{name} = release stage {key}" + : EnvMap.RepoOf(key) is { } repo ? $"{name} = branch {EnvMap.BranchOf(key)} of {repo}" + : $"{name} = branch {key}"; static Dictionary ParseEnvironments(string environments) { diff --git a/src/AdoLens/Settings.cs b/src/AdoLens/Settings.cs index 5680b4d..3a52d30 100644 --- a/src/AdoLens/Settings.cs +++ b/src/AdoLens/Settings.cs @@ -22,6 +22,12 @@ public sealed class Connection /// Empty: every language of the label file is required. /// public List LabelLanguages { get; set; } = []; + + /// + /// The project with the code, pull requests and pipelines when it is not the project of the work items (work + /// items in "Sales", code in "ERP"). Null: the default project. + /// + public string? CodeProject { get; set; } } /// Non-secret settings (adolens.json in the data folder). PATs live in Windows Credential Manager. @@ -163,6 +169,9 @@ public sealed record Config(string Connection, string Organization, string Pat, /// See . public IReadOnlyList LabelLanguages { get; init; } = []; + /// See . + public string? CodeProject { 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)); @@ -220,9 +229,10 @@ public static Config Resolve(Settings settings, string? connection, Func -/// How tool arguments are read. Claude does not always send the JSON type of the schema: ids=714800 for a text +/// How tool arguments are read. Claude does not always send the JSON type of the schema: ids=123456 for a text /// parameter, workItems=[1,2], file=1, top="20", draft="true". Without this the MCP SDK fails before the tool runs and -/// Claude only gets "An error occurred invoking …". A number or a list given for text becomes that text ("714800", +/// Claude only gets "An error occurred invoking …". A number or a list given for text becomes that text ("123456", /// "1;2"), a number given as text is read as a number, "true"/"false" as a yes/no. /// public static class ToolArguments diff --git a/src/AdoLens/Trace.cs b/src/AdoLens/Trace.cs index 8f09475..6bc03a5 100644 --- a/src/AdoLens/Trace.cs +++ b/src/AdoLens/Trace.cs @@ -164,12 +164,16 @@ async Task ReadAsync(IEnumerable batch) } Mark($"{changes.Count} changes"); changes = Combine(changes.OrderBy(c => c.Change.Date).ToList()); - var (env, envSource) = await EnvironmentFlow.EffectiveAsync(session, project, ct); + var (envRaw, envSource) = await EnvironmentFlow.EffectiveAsync(session, project, ct); + var env = new EnvMap(envRaw); + // Repository names by id (deployments name their repository by id; the map by name). + var repoNames = changes.Select(x => x.Change).Where(x => x.RepoId is not null && x.Repo is not null) + .GroupBy(x => x.RepoId!, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First().Repo!, StringComparer.OrdinalIgnoreCase); // A PR from a long-lived branch (a release branch into master, staging into master…) carries many items at once: // it is shown in one line and counts as evidence of where the work is, not as work of its own. var prTargets = changes.Where(x => x.Change.Kind == "pr").Select(x => x.Change.TargetBranch).Where(t => t is not null).ToHashSet(StringComparer.OrdinalIgnoreCase); bool IsPromotion(CodeChange x) => - x.Kind == "pr" && x.Branch is { } src && (env.ContainsKey(src) || prTargets.Contains(src) || x.Files.Count > 100); + x.Kind == "pr" && x.Branch is { } src && (env.IsEnvironmentBranch(x.Repo, src) || prTargets.Contains(src) || x.Files.Count > 100); var branches = new Dictionary(); // One call per branch path, all at the same time (changesets of one item are mostly on the same branches). var tfvc = changes.Select(x => x.Change).Where(x => x.Kind == "changeset").ToList(); @@ -270,15 +274,17 @@ static IEnumerable Texts(JsonElement x) => Json.Obj(x, "fields") is { Va Mark($"{deployments.Count} release stages"); // The environment map names a release stage ("Definition/Stage", e.g. one stage deploying to UAT and SCM at once), // a whole release definition, or a branch ("staging"; TFVC: the last part of the branch path, e.g. "Main"). - var envOrder = env.Keys.Select((k, i) => (k, i)).ToDictionary(x => x.k, x => x.i, StringComparer.OrdinalIgnoreCase); - string? EnvKey(string? branch, string def, string stage) + string? EnvKey(string? branch, string def, string stage, string? repoId = null) { - var branchKey = branch is null ? null : branch.StartsWith("$/", StringComparison.Ordinal) ? branch[(branch.LastIndexOf('/') + 1)..] : Code.Short(branch); - return new[] { $"{def}/{stage}", def, branchKey }.FirstOrDefault(k => k is not null && env.ContainsKey(k)); + if (env.Has($"{def}/{stage}")) return $"{def}/{stage}"; + if (env.Has(def)) return def; + if (branch is null) return null; + if (branch.StartsWith("$/", StringComparison.Ordinal)) return env.BranchKey(null, branch[(branch.LastIndexOf('/') + 1)..]); + return env.BranchKey(repoId is not null && repoNames.TryGetValue(repoId, out var rn) ? rn : null, Code.Short(branch)); } - string Label(string? branch, string def, string stage) => - EnvKey(branch, def, stage) is { } k ? $"{env[k]} ({def} / {stage})" : $"{def} / {stage}"; - int Order(string? branch, string def, string stage) => EnvKey(branch, def, stage) is { } k ? envOrder[k] : int.MaxValue; + string Label(Deployment d) => + EnvKey(d.Branch, d.Definition, d.Stage, d.Repository) is { } k ? $"{env[k]} ({d.Definition} / {d.Stage})" : $"{d.Definition} / {d.Stage}"; + int Order(Deployment d) => env.Order(EnvKey(d.Branch, d.Definition, d.Stage, d.Repository)); var where = new List(); var anyIn = false; // Git: a stage has the work when any of its changes (a PR merge, a commit) is in what was deployed. Changes that @@ -291,8 +297,8 @@ string Label(string? branch, string def, string stage) => containsCache.GetOrAdd((commit, target, isBranch), _ => Gated(() => code.ContainsAsync(repo, commit, target, isBranch, dt, committed))); // Code moves forward through the environments (dev -> staging -> master): a change merged into a later one is // not in an earlier one, so that is not asked. - bool Later(string? changeTarget, string? branch) => - changeTarget is not null && branch is not null && envOrder.TryGetValue(changeTarget, out var a) && envOrder.TryGetValue(branch, out var b) && a > b; + bool Later(string? repo, string? changeTarget, string? branch) => + env.BranchKey(repo, changeTarget) is { } a && env.BranchKey(repo, branch) is { } b && env.Order(a) > env.Order(b); async Task Gated(Func> check) { await gate.WaitAsync(dt); @@ -312,7 +318,7 @@ bool Later(string? changeTarget, string? branch) => : isBranch && g.Kind == "pr" && string.Equals(g.TargetBranch, target, StringComparison.OrdinalIgnoreCase) ? true // A deployment that finished before a change was merged cannot contain it: no need to ask. : deployedAt is { } at && g.Date is { } merged && at < merged ? false - : Later(g.TargetBranch, isBranch ? target : targetBranch) ? (bool?)null + : Later(g.Repo, g.TargetBranch, isBranch ? target : targetBranch) ? (bool?)null : !newest.Contains(g) ? (bool?)null : await Contains(repo, commit, target, isBranch, g.Date))); // The older changes of a pair follow the answer for its newest one. @@ -324,7 +330,7 @@ bool Later(string? changeTarget, string? branch) => var known = answers.Any(a => a is not null); // Not yet: unless a change from the same source branch is in and no older than a day before it (the same work // merged into each environment by its own PR). - var notYet = git.Where(g => !inside.Contains(g) && !IsPromotion(g) && !Later(g.TargetBranch, isBranch ? target : targetBranch) + var notYet = git.Where(g => !inside.Contains(g) && !IsPromotion(g) && !Later(g.Repo, g.TargetBranch, isBranch ? target : targetBranch) && !inside.Any(i => i.Branch is not null && i.Branch == g.Branch && i.Date >= g.Date?.AddDays(-1))).ToList(); return (inside, notYet, known); } @@ -332,12 +338,12 @@ static string Names(IEnumerable list) => string.Join(", ", list.Select(x => x.Kind == "pr" ? $"PR {x.Id} ({x.TargetBranch}, {x.Date?.ToLocalTime():MM-dd})" : $"commit {x.Id[..Math.Min(7, x.Id.Length)]}")); var stageTasks = new List>(); - foreach (var d in deployments.OrderBy(d => Order(d.Branch, d.Definition, d.Stage)).ThenBy(d => d.Definition).ThenBy(d => d.Stage)) + foreach (var d in deployments.OrderBy(Order).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))) + if (env.Count > 0 && EnvKey(d.Branch, d.Definition, d.Stage, d.Repository) is null && (d.When is not { } last || last < DateTime.UtcNow.AddDays(-183))) continue; - var label = $"- {Label(d.Branch, d.Definition, d.Stage)}: "; + var label = $"- {Label(d)}: "; var deployed = $"{Date(d.When)} (build {d.BuildNumber})"; if (d.Branch?.StartsWith("$/", StringComparison.Ordinal) == true) { @@ -368,8 +374,9 @@ static string Names(IEnumerable list) => } // Git branches named in the environment map, even without release pipelines: is the work merged there? // Keys that are no branch of the repository (a release definition, a TFVC branch) give no answer and are skipped. - foreach (var (branch, envLabel) in env.Where(e => !e.Key.Contains('/'))) - foreach (var group in changes.Select(x => x.Change).Where(x => x.Kind != "changeset" && x.RepoId is not null).GroupBy(x => x.RepoId!)) + // Each repository with its own branches ("D365FO:staging"), or the branches of every repository ("staging"). + foreach (var group in changes.Select(x => x.Change).Where(x => x.Kind != "changeset" && x.RepoId is not null).GroupBy(x => x.RepoId!)) + foreach (var (branch, envLabel) in env.BranchesOf(group.First().Repo)) { var repoName = group.First().Repo; stageTasks.Add(Task.Run(async () => @@ -450,9 +457,12 @@ static string Names(IEnumerable list) => { static (int Order, string? Guess) Classify(string key) { - var k = key.ToLowerInvariant(); + // "Repo:branch" is classified by its branch. + var k = EnvMap.BranchOf(key).ToLowerInvariant(); var last = k[(k.LastIndexOf('/') + 1)..]; if (Regex.IsMatch(k, @"^(r\d+|release.*|hotfix.*|feature.*|bugfix.*|users/.*)$")) return (-1, null); + // Work branches named after a work item ("000000_TestBranch", "123456_LoginForm_AB"). + if (Regex.IsMatch(k, @"^\d{3,}[_\-]")) return (-1, null); if (last is "dev" or "develop" or "development" or "sit" or "int" or "integration") return (0, "DEV"); if (last is "staging" or "stage" or "uat" or "test" or "qa" or "preprod") return (1, "UAT"); if (last is "master" or "main" or "prod" or "production") return (2, "PROD"); @@ -522,7 +532,7 @@ public static async Task ChangesAsync(Lens lens, string? connection, str Func> versions; if (PullRequestRef().Match(text) is { Success: true } pm) { - c = await code.PullRequestAsync(repo is null ? null : (await code.RepoAsync(repo, session.Config.Project, ct)).Id, int.Parse(pm.Groups["n"].Value, CultureInfo.InvariantCulture), withFiles: true, ct); + c = await code.PullRequestAsync(repo is null ? null : (await code.RepoAsync(repo, session.CodeProject(null), ct)).Id, int.Parse(pm.Groups["n"].Value, CultureInfo.InvariantCulture), withFiles: true, ct); versions = async f => (f.ChangeType.Contains("add") ? null : await code.GitFileAsync(c.RepoId!, f.OldPath ?? f.Path, c.BaseCommit, ct), f.ChangeType.Contains("delete") ? null : await code.GitFileAsync(c.RepoId!, f.Path, c.HeadCommit, ct)); } @@ -536,7 +546,7 @@ public static async Task ChangesAsync(Lens lens, string? connection, str else if (CommitRef().Match(text) is { Success: true } gm) { if (repo is null) throw new ArgumentException("a commit needs repo= (ado_trace shows it next to the commit)"); - var r = await code.RepoAsync(repo, session.Config.Project, ct); + var r = await code.RepoAsync(repo, session.CodeProject(null), ct); c = await code.CommitAsync(r.Id, gm.Groups["sha"].Value, ct); versions = async f => (f.ChangeType.Contains("add") ? null : await code.GitFileAsync(r.Id, f.OldPath ?? f.Path, c.Parent, ct), f.ChangeType.Contains("delete") ? null : await code.GitFileAsync(r.Id, f.Path, c.Id, ct)); diff --git a/tests/AdoLens.Tests/ArgumentTests.cs b/tests/AdoLens.Tests/ArgumentTests.cs index 47ee48d..d0b2f6a 100644 --- a/tests/AdoLens.Tests/ArgumentTests.cs +++ b/tests/AdoLens.Tests/ArgumentTests.cs @@ -9,8 +9,8 @@ public class ArgumentTests [Fact] public void Arguments_in_another_JSON_shape_are_still_read() { - // Claude sends ids=714800 or workItems=[1,2] for text parameters, numbers and yes/no as text. - Assert.Equal("714800", Read("714800")); + // Claude sends ids=123456 or workItems=[1,2] for text parameters, numbers and yes/no as text. + Assert.Equal("123456", Read("123456")); Assert.Equal("1;2", Read("[1, 2]")); Assert.Equal("a;b", Read("[\"a\", \"b\"]")); Assert.Equal("true", Read("true")); diff --git a/tests/AdoLens.Tests/ImprovementTests.cs b/tests/AdoLens.Tests/ImprovementTests.cs index 6b621c6..b930021 100644 --- a/tests/AdoLens.Tests/ImprovementTests.cs +++ b/tests/AdoLens.Tests/ImprovementTests.cs @@ -12,6 +12,53 @@ public class ImprovementTests public void Display_names_are_compared_without_a_company_suffix(string name, string plain) => Assert.Equal(plain, Identities.Plain(name)); + [Fact] + public void A_repository_with_its_own_keys_uses_only_those() + { + var env = new EnvMap(new Dictionary + { + ["dev"] = "DEV", ["portal:staging"] = "UAT", ["staging"] = "UAT-old", ["Deploy/Stage 1"] = "UAT + SCM", ["portal:master"] = "PROD", ["lib:master"] = "PROD", + }); + Assert.Equal("portal:staging", env.BranchKey("portal", "staging")); + Assert.Null(env.BranchKey("portal", "dev")); // portal has keys of its own: "dev" means nothing there + Assert.Equal("dev", env.BranchKey("other", "dev")); // a repository without own keys reads the shared ones + Assert.Equal("staging", env.BranchKey(null, "staging")); + Assert.Equal(["master"], env.BranchesOf("lib").Select(b => b.Branch)); + Assert.Equal(["staging", "master"], env.BranchesOf("portal").Select(b => b.Branch)); + Assert.Equal(["dev", "staging"], env.BranchesOf("other").Select(b => b.Branch)); + Assert.True(env.Order("portal:staging") < env.Order("portal:master")); + Assert.Equal("UAT = branch staging of portal", Queries.Flow("portal:staging", "UAT")); + Assert.Equal("UAT + SCM = release stage Deploy/Stage 1", Queries.Flow("Deploy/Stage 1", "UAT + SCM")); + } + + [Fact] + public void Code_can_live_in_another_project_than_the_work_items() + { + var dir = Path.Combine(Path.GetTempPath(), $"adolens-cp-{Guid.NewGuid():N}"); + try + { + var s = new Settings { DefaultConnection = "contoso" }; + s.Connections["contoso"] = new Connection { Organization = "contoso", Project = "Demo", CodeProject = "ERP" }; + var session = new Lens(s, "0.0.0", dir, _ => null, _ => "pat", _ => new FakeAdo().Client()).For(null); + Assert.Equal("Demo", session.Project(null)); + Assert.Equal("ERP", session.CodeProject(null)); + Assert.Equal("Other", session.CodeProject("Other")); + } + finally + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void Work_branches_are_not_environments() + { + var flow = TraceQueries.ProposeFlow(["portal:dev", "portal:000000_TestBranch", "123456_LoginForm_AB", "portal:master", "lib:staging"]); + Assert.Equal(["portal:dev", "lib:staging", "portal:master"], flow.Select(f => f.Key)); + Assert.Equal("UAT", flow.Single(f => f.Key == "lib:staging").Guess); + } + [Fact] public void Numbered_lists_keep_their_start() { diff --git a/tests/AdoLens.Tests/SetupFlowTests.cs b/tests/AdoLens.Tests/SetupFlowTests.cs index a9b7f50..3c43c4c 100644 --- a/tests/AdoLens.Tests/SetupFlowTests.cs +++ b/tests/AdoLens.Tests/SetupFlowTests.cs @@ -74,6 +74,7 @@ public TeamSettingsTests() "release":{"artifacts":[{"definitionReference":{"branch":{"id":"refs/heads/staging"},"sourceVersion":{"id":"x"}}}]}}]} """) .On("/contoso/Demo/_apis/git/repositories", """{"value":[{"id":"r1","name":"portal","size":100}]}""") + .On("/contoso/_apis/git/repositories/r1/commits", () => "{\"value\":[{\"commitId\":\"c1\",\"committer\":{\"date\":\"" + DateTime.UtcNow.AddDays(-3).ToString("o") + "\"}}]}") .On("/contoso/_apis/git/repositories/r1/refs", """{"value":[{"name":"refs/heads/dev"},{"name":"refs/heads/master"},{"name":"refs/heads/feature/x"}]}"""); settings = new Settings { DefaultConnection = "contoso" }; settings.Connections["contoso"] = new Connection { Organization = "contoso", Project = "Demo" };