From 3d1fa79001dc69308c8ec209fc9e2b5c681b1de6 Mon Sep 17 00:00:00 2001
From: WillowDev <216433317+willowdev-code@users.noreply.github.com>
Date: Tue, 27 Oct 2026 15:38:54 +0100
Subject: [PATCH] ADO Lens 0.11.5
- Fixed: the Environments tab of the settings window showed every row twice (two loads at once: choosing the
connection and opening the tab). Only the last load fills the table now.
- Environments: a column "Where from" (and a tooltip) with where each row comes from - the TFVC path and its last
changeset, the repository and its project, or the release, stage, date and branch it was deployed from - so a "Main"
or "Release" can be told from another. The terminal menu shows it under each row; `adolens setup --detect --sources`
prints it.
---
CHANGELOG.md | 9 +++++++
src/AdoLens/AdoLens.csproj | 2 +-
src/AdoLens/Cli.cs | 8 ++++--
src/AdoLens/CommandLine.cs | 2 +-
src/AdoLens/EnvironmentFlow.cs | 48 ++++++++++++++++++++++++++--------
src/AdoLens/SettingsWindow.cs | 27 ++++++++++++++-----
6 files changed, 74 insertions(+), 22 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6270420..ae56000 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 0.11.5 — 2026-10-27
+
+- Fixed: the Environments tab of the settings window showed every row twice (two loads at once: choosing the
+ connection and opening the tab). Only the last load fills the table now.
+- Environments: a column "Where from" (and a tooltip) with where each row comes from - the TFVC path and its last
+ changeset, the repository and its project, or the release, stage, date and branch it was deployed from - so a "Main"
+ or "Release" can be told from another. The terminal menu shows it under each row; `adolens setup --detect --sources`
+ prints it.
+
## 0.11.4 — 2026-10-27
The environments are set the same way in the window, the terminal and the installer.
diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj
index 7fb6ab3..1de9ba8 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.4
+ 0.11.5
diff --git a/src/AdoLens/Cli.cs b/src/AdoLens/Cli.cs
index e176385..307ccc3 100644
--- a/src/AdoLens/Cli.cs
+++ b/src/AdoLens/Cli.cs
@@ -602,6 +602,8 @@ async Task SetupUnattendedAsync()
var lines = new List();
var (effective, source) = await EnvironmentFlow.EffectiveAsync(session, project, default);
if (effective.Count > 0) lines.Add($"existing\t{string.Join(" -> ", effective.Values)}\t{source}");
+ // --sources adds where each comes from (the installer reads three columns only).
+ else if (cl.Flag("sources")) lines.AddRange((await EnvironmentFlow.DetectWithSourcesAsync(session, project, default)).Select(d => $"row\t{d.Key}\t{d.Guess}\t{d.Source}"));
else lines.AddRange((await EnvironmentFlow.DetectAsync(session, project, default)).Select(d => $"row\t{d.Key}\t{d.Guess}"));
if (cl.Opt("out") is { } file)
{
@@ -678,7 +680,7 @@ async Task EnvironmentsAsync(string name, Session session, string project, bool
Console.WriteLine(Ui.T($"{name} / {project}: code goes {string.Join(" -> ", effective.Values)} ({source}).",
$"{name} / {project}: kod trafia kolejno na {string.Join(" -> ", effective.Values)} ({source})."));
if (effective.Count > 0 && !change) return;
- var found = (await EnvironmentFlow.DetectAsync(session, project, default)).Where(d => !effective.ContainsKey(d.Key)).ToList();
+ var found = (await EnvironmentFlow.DetectWithSourcesAsync(session, project, default)).Where(d => !effective.ContainsKey(d.Key)).ToList();
if ((effective.Count == 0 && found.Count == 0) || !interactive)
{
Console.WriteLine(Ui.T($"{name} / {project}: no release stages or long-lived branches found - Claude will ask when needed.",
@@ -716,8 +718,10 @@ async Task EnvironmentsAsync(string name, Session session, string project, bool
if (found.Count > 0)
Console.WriteLine(Ui.T("Found - type the name as people say it (e.g. UAT (sbx03)), '+' takes the hint, Enter skips:",
"Znalezione - wpisz nazwę, jakiej używacie (np. UAT (sbx03)), '+' przyjmuje podpowiedź, Enter pomija:"));
- foreach (var (key, hint) in found)
+ foreach (var (key, hint, from) in found)
{
+ // Where it comes from: two "Main" or "Release" are told apart by their path or release.
+ if (from.Length > 0) Console.WriteLine($" ({from})");
var label = Prompt($" {key}{(hint is null ? "" : Ui.T($" (looks like {hint})", $" (wygląda na {hint})"))}: ")?.Trim();
if (label == "+" && hint is not null) map[key] = hint;
else if (!string.IsNullOrEmpty(label) && label != "+" && label != "-") map[key] = label;
diff --git a/src/AdoLens/CommandLine.cs b/src/AdoLens/CommandLine.cs
index 8745c33..38f08dd 100644
--- a/src/AdoLens/CommandLine.cs
+++ b/src/AdoLens/CommandLine.cs
@@ -13,7 +13,7 @@ public sealed partial class CommandLine
{
["mcp"] = [],
["upgrade"] = ["install", "pre"],
- ["setup"] = ["mode=", "connection=", "detect", "out=", "env=", "scope=", "wizard", "window"],
+ ["setup"] = ["mode=", "connection=", "detect", "out=", "env=", "scope=", "wizard", "window", "sources"],
["login"] = ["connection=", "org=", "project=", "team=", "pat-stdin", "pat-env=", "default"],
["tests"] = ["connection=", "team=", "detail="],
["test-create"] = ["connection=", "file=", "plan=", "suite="],
diff --git a/src/AdoLens/EnvironmentFlow.cs b/src/AdoLens/EnvironmentFlow.cs
index febf086..8302531 100644
--- a/src/AdoLens/EnvironmentFlow.cs
+++ b/src/AdoLens/EnvironmentFlow.cs
@@ -20,15 +20,29 @@ public static class EnvironmentFlow
/// 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)
+ public static async Task> DetectAsync(Session session, string project, CancellationToken ct) =>
+ (await DetectWithSourcesAsync(session, project, ct)).Select(d => (d.Key, d.Guess)).ToList();
+
+ ///
+ /// As , with where each one comes from ("$/Project/Trunk/Main, last changeset 2026-10-20";
+ /// "release Deploy, stage 1, deployed 2026-10-24 from …"), so two of the same name can be told apart.
+ ///
+ public static async Task> DetectWithSourcesAsync(Session session, string project, CancellationToken ct)
{
+ var sources = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ void From(string key, string source)
+ {
+ if (!sources.TryGetValue(key, out var list)) sources[key] = list = [];
+ if (!list.Contains(source)) list.Add(source);
+ }
+ static string Day(DateTime? d) => d?.ToLocalTime().ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture) ?? "?";
var projects = new[] { session.Config.CodeProject, project }.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p!)
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var keys = new List();
// 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.
- var active = new List<(string Id, string Name)>();
+ var active = new List<(string Id, string Name, string Project, DateTime Last)>();
foreach (var p in projects)
{
try
@@ -40,19 +54,24 @@ public static class EnvironmentFlow
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));
+ active.Add((id, Json.Str(repo, "name") ?? id, p, when));
}
}
catch (AdoApiException) { } // no Git, or no Code (Read) scope
}
var several = active.Count > 1;
- foreach (var (id, name) in active)
+ foreach (var (id, name, inProject, last) 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 => several ? $"{name}:{n}" : n));
+ foreach (var n in refs.Select(r => Code.Short(Json.Str(r, "name")) ?? "").Where(n => n.Length > 0 && !n.Contains('/')))
+ {
+ var key = several ? $"{name}:{n}" : n;
+ keys.Add(key);
+ From(key, Ui.T($"Git: branch {n} of repository {name} ({inProject}; last commit in the repository {Day(last)})",
+ $"Git: gałąź {n} repozytorium {name} ({inProject}; ostatni commit w repozytorium {Day(last)})"));
+ }
}
catch (AdoApiException) { }
}
@@ -67,11 +86,13 @@ public static class EnvironmentFlow
{
var path = Json.Str(b, "path") ?? "";
var name = path[(path.LastIndexOf('/') + 1)..];
- if (name.Length == 0 || tfvc.Any(t => t.Key.Equals(name, StringComparison.OrdinalIgnoreCase))) continue;
+ if (name.Length == 0) continue;
var last = await session.Client.GetListAsync($"_apis/tfvc/changesets?searchCriteria.itemPath={Uri.EscapeDataString(path)}&$top=1", ct);
if (last.Count == 0 || !DateTime.TryParse(Json.Str(last[0], "createdDate"), System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal, out var when) || when < DateTime.UtcNow.AddDays(-183)) continue;
- tfvc.Add((name, TfvcGuess(name)));
+ // Two branches with one name (Main in two projects) are one key: ado_trace knows a TFVC branch by its last part.
+ From(name, Ui.T($"TFVC: {path} (last changeset {Day(when)})", $"TFVC: {path} (ostatni changeset {Day(when)})"));
+ if (!tfvc.Any(t => t.Key.Equals(name, StringComparison.OrdinalIgnoreCase))) tfvc.Add((name, TfvcGuess(name)));
}
}
catch (AdoApiException) { } // no TFVC, or no Code (Read) scope
@@ -85,13 +106,18 @@ public static class EnvironmentFlow
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);
+ var key = d.Branch?.StartsWith("$/", StringComparison.Ordinal) == true || branch is null ? $"{d.Definition}/{d.Stage}"
+ : several && repo is not null ? $"{repo}:{branch}" : branch;
+ keys.Add(key);
+ From(key, Ui.T($"release {d.Definition}, stage {d.Stage}: deployed {Day(d.When)}", $"release {d.Definition}, etap {d.Stage}: wdrożony {Day(d.When)}")
+ + (d.Branch is { } from ? Ui.T($" from {from}", $" z {from}") : "") + $" ({p})");
}
var git = TraceQueries.ProposeFlow(keys.Distinct(StringComparer.OrdinalIgnoreCase));
var order = new[] { "DEV", "UAT", "PROD" };
return tfvc.OrderBy(t => t.Guess is null ? 3 : Array.IndexOf(order, t.Guess)).ThenBy(t => t.Key, StringComparer.OrdinalIgnoreCase)
- .Concat(git.Where(g => !tfvc.Any(t => t.Key.Equals(g.Key, StringComparison.OrdinalIgnoreCase)))).ToList();
+ .Concat(git.Where(g => !tfvc.Any(t => t.Key.Equals(g.Key, StringComparison.OrdinalIgnoreCase))))
+ .Select(k => (k.Key, k.Guess, string.Join("; ", sources.GetValueOrDefault(k.Key) ?? [])))
+ .DistinctBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToList();
}
/// A TFVC branch and the branches under it.
diff --git a/src/AdoLens/SettingsWindow.cs b/src/AdoLens/SettingsWindow.cs
index c0f94ee..53ff2b2 100644
--- a/src/AdoLens/SettingsWindow.cs
+++ b/src/AdoLens/SettingsWindow.cs
@@ -283,6 +283,13 @@ TabPage EnvironmentsPage()
envGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "name", HeaderText = Ui.T("Environment, as people say it (empty = skip)", "Środowisko, jak o nim mówicie (puste = pomiń)"), FillWeight = 35 });
envGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "hint", HeaderText = Ui.T("Looks like", "Wygląda na"), FillWeight = 12, ReadOnly = true,
DefaultCellStyle = new DataGridViewCellStyle { ForeColor = SystemColors.GrayText } });
+ // Where a row comes from: two "Main" or "Release" rows are told apart by their path, repository or release.
+ envGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "source", HeaderText = Ui.T("Where from", "Skąd"), FillWeight = 45, ReadOnly = true,
+ DefaultCellStyle = new DataGridViewCellStyle { ForeColor = SystemColors.GrayText } });
+ envGrid.CellToolTipTextNeeded += (_, e) =>
+ {
+ if (e.RowIndex >= 0 && envGrid.Rows[e.RowIndex].Cells[4].Value is string { Length: > 0 } source) e.ToolTipText = source.Replace("; ", "\n");
+ };
envConnection.SelectedIndexChanged += async (_, _) => await LoadEnvironmentsAsync(detect: false);
var top = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, WrapContents = false };
@@ -346,9 +353,13 @@ void MoveRow(int by)
envGrid.CurrentCell = row.Cells[0];
}
+ /// Each load gets a number; only the last one fills the table (two loads at once doubled every row).
+ int envLoad;
+
async Task LoadEnvironmentsAsync(bool detect)
{
if (envConnection.SelectedItem is not string name) return;
+ var load = ++envLoad;
UseWaitCursor = true;
envInfo.Text = detect ? Ui.T("Looking for release stages and branches...", "Szukam etapów release'ów i gałęzi...") : "";
try
@@ -357,13 +368,15 @@ async Task LoadEnvironmentsAsync(bool detect)
var session = lens.For(name);
var project = session.Project(null);
var (map, source) = await EnvironmentFlow.EffectiveAsync(session, project, default);
+ // Found rows get no name yet, only the hint: with many repositories, saving would otherwise take them all.
+ var detected = detect || map.Count == 0 ? await EnvironmentFlow.DetectWithSourcesAsync(session, project, default) : [];
+ if (load != envLoad) return; // a newer load is on its way
envGrid.Rows.Clear();
- foreach (var (key, label) in map) AddRow(key, label, null);
+ foreach (var (key, label) in map)
+ AddRow(key, label, null, detected.FirstOrDefault(d => d.Key.Equals(key, StringComparison.OrdinalIgnoreCase)).Source);
var found = 0;
- // Found rows get no name yet, only the hint: with many repositories, saving would otherwise take them all.
- if (detect || map.Count == 0)
- foreach (var (key, guess) in await EnvironmentFlow.DetectAsync(session, project, default))
- if (!map.ContainsKey(key)) { AddRow(key, "", guess); found++; }
+ foreach (var (key, guess, from) in detected)
+ if (!map.ContainsKey(key)) { AddRow(key, "", guess, from); found++; }
FillRepositories();
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"))
@@ -378,8 +391,8 @@ async Task LoadEnvironmentsAsync(bool detect)
finally { UseWaitCursor = false; }
}
- void AddRow(string key, string label, string? hint) =>
- envGrid.Rows.Add(EnvMap.RepoOf(key) ?? "", EnvMap.BranchOf(key), label, hint ?? "");
+ void AddRow(string key, string label, string? hint, string? source = null) =>
+ envGrid.Rows.Add(EnvMap.RepoOf(key) ?? "", EnvMap.BranchOf(key), label, hint ?? "", source ?? "");
const int AllRepositories = 0, NoRepository = 1;