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()
{