From a266cb3dc7ac9b4c5ae69a53bf4e06a41a7c5d03 Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:57:09 +0100 Subject: [PATCH] ADO Lens 0.11.2 - Fixed: finding the environments (settings window, `adolens setup`, `ado_settings`, the SETUP hint of `ado_trace`) looked only in the project of the work items, not in the project with the code set with `--code-project`. It now looks in both: the repositories, branches and release stages of the code project are offered too. The settings window says which project the code is in. --- CHANGELOG.md | 7 ++++ src/AdoLens/AdoLens.csproj | 2 +- src/AdoLens/EnvironmentFlow.cs | 54 ++++++++++++++++++--------- src/AdoLens/SettingsWindow.cs | 3 +- tests/AdoLens.Tests/SetupFlowTests.cs | 14 +++++++ 5 files changed, 61 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c513563..dacd8f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.11.2 — 2026-10-27 + +- Fixed: finding the environments (settings window, `adolens setup`, `ado_settings`, the SETUP hint of `ado_trace`) + looked only in the project of the work items, not in the project with the code set with `--code-project`. It now + looks in both: the repositories, branches and release stages of the code project are offered too. The settings + window says which project the code is in. + ## 0.11.1 — 2026-10-27 - Fixed: the program did not start ("Failed to determine default extraction location") when TEMP pointed to a folder diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index 2133575..fafae75 100644 --- a/src/AdoLens/AdoLens.csproj +++ b/src/AdoLens/AdoLens.csproj @@ -25,7 +25,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.11.1 + 0.11.2 diff --git a/src/AdoLens/EnvironmentFlow.cs b/src/AdoLens/EnvironmentFlow.cs index d538228..bf443e2 100644 --- a/src/AdoLens/EnvironmentFlow.cs +++ b/src/AdoLens/EnvironmentFlow.cs @@ -16,37 +16,57 @@ public static class EnvironmentFlow return (new Dictionary(), "none"); } + /// + /// Release stages and long-lived branches of the project with the code (the connection's code project, when the + /// code lives elsewhere than the work items) and of itself. + /// public static async Task> DetectAsync(Session session, string project, CancellationToken ct) { + var projects = new[] { session.Config.CodeProject, project }.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p!) + .Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var keys = new List(); - foreach (var d in await session.Code.DeploymentsAsync(project, ct)) - { - if (d.When is not { } when || when < DateTime.UtcNow.AddDays(-183)) continue; - keys.Add(d.Branch?.StartsWith("$/", StringComparison.Ordinal) == true ? $"{d.Definition}/{d.Stage}" : Code.Short(d.Branch) ?? $"{d.Definition}/{d.Stage}"); - } // Long-lived branches of the repositories 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 active = new List<(string Id, string Name)>(); + foreach (var p in projects) { - 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)) + try { - 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)); + var repos = await session.Client.GetListAsync($"{AdoClient.Scope(p)}_apis/git/repositories", ct); + foreach (var repo in repos.Where(r => !Json.Bool(r, "isDisabled") && r.TryGetProperty("size", out var s) && s.GetInt64() > 0).Take(30)) + { + var 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) + catch (AdoApiException) { } // no Git, or no Code (Read) scope + } + var several = active.Count > 1; + foreach (var (id, name) in active) + { + try { 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)); + .Select(n => several ? $"{name}:{n}" : n)); } + catch (AdoApiException) { } } - catch (AdoApiException) { } // no Git, or no Code (Read) scope + // Release stages deployed in the last half year; a Git stage is named by its branch (with its repository when + // there are several), a TFVC one as "Definition/Stage". + foreach (var p in projects) + foreach (var d in await session.Code.DeploymentsAsync(p, ct)) + { + if (d.When is not { } when || when < DateTime.UtcNow.AddDays(-183)) continue; + var branch = Code.Short(d.Branch); + var repo = active.FirstOrDefault(a => string.Equals(a.Id, d.Repository, StringComparison.OrdinalIgnoreCase)).Name; + keys.Add(d.Branch?.StartsWith("$/", StringComparison.Ordinal) == true || branch is null ? $"{d.Definition}/{d.Stage}" + : several && repo is not null ? $"{repo}:{branch}" : branch); + } return TraceQueries.ProposeFlow(keys.Distinct(StringComparer.OrdinalIgnoreCase)); } } diff --git a/src/AdoLens/SettingsWindow.cs b/src/AdoLens/SettingsWindow.cs index a236fbd..0bf6283 100644 --- a/src/AdoLens/SettingsWindow.cs +++ b/src/AdoLens/SettingsWindow.cs @@ -337,7 +337,8 @@ async Task LoadEnvironmentsAsync(bool detect) if (detect || map.Count == 0) foreach (var (key, guess) in await EnvironmentFlow.DetectAsync(session, project, default)) if (!map.ContainsKey(key)) { AddRow(key, guess ?? ""); found++; } - envInfo.Text = $"{project}: " + (map.Count > 0 ? Ui.T($"in use: {source}", $"w użyciu: {source}") : Ui.T("no map yet", "brak mapy")) + var code = session.Config.CodeProject is { } cp && !cp.Equals(project, StringComparison.OrdinalIgnoreCase) ? Ui.T($" (code in {cp})", $" (kod w {cp})") : ""; + envInfo.Text = $"{project}{code}: " + (map.Count > 0 ? Ui.T($"in use: {source}", $"w użyciu: {source}") : Ui.T("no map yet", "brak mapy")) + (found > 0 ? Ui.T($"; {found} found (check the names)", $"; znalezione: {found} (sprawdź nazwy)") : ""); } catch (Exception ex) when (ex is InvalidOperationException or AdoApiException or HttpRequestException) diff --git a/tests/AdoLens.Tests/SetupFlowTests.cs b/tests/AdoLens.Tests/SetupFlowTests.cs index 3c43c4c..9d80072 100644 --- a/tests/AdoLens.Tests/SetupFlowTests.cs +++ b/tests/AdoLens.Tests/SetupFlowTests.cs @@ -73,6 +73,9 @@ public TeamSettingsTests() {"value":[{"releaseDefinition":{"name":"Shop"},"releaseEnvironment":{"name":"lcs"},"completedOn":"2099-01-01T00:00:00Z", "release":{"artifacts":[{"definitionReference":{"branch":{"id":"refs/heads/staging"},"sourceVersion":{"id":"x"}}}]}}]} """) + .On("/contoso/ERP/_apis/git/repositories", """{"value":[{"id":"r2","name":"erp","size":100}]}""") + .On("/contoso/_apis/git/repositories/r2/commits", () => "{\"value\":[{\"commitId\":\"c2\",\"committer\":{\"date\":\"" + DateTime.UtcNow.AddDays(-1).ToString("o") + "\"}}]}") + .On("/contoso/_apis/git/repositories/r2/refs", """{"value":[{"name":"refs/heads/staging"},{"name":"refs/heads/123456_LoginForm_AB"}]}""") .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"}]}"""); @@ -87,6 +90,17 @@ public void Dispose() if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } + [Fact] + public async Task With_the_code_in_another_project_its_branches_are_found_too() + { + settings.Connections["contoso"].CodeProject = "ERP"; + var keys = (await EnvironmentFlow.DetectAsync(lens.For(null), "Demo", default)).Select(d => d.Key).ToList(); + // Two repositories, one per project: each branch with its repository; work branches left out. + Assert.Contains("erp:staging", keys); + Assert.Contains("portal:dev", keys); + Assert.DoesNotContain(keys, k => k.Contains("LoginForm")); + } + [Fact] public void The_wiki_table_round_trips_in_order() {