diff --git a/CHANGELOG.md b/CHANGELOG.md
index dacd8f5..6b047ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## 0.11.3 — 2026-10-27
+
+- Finding the environments includes TFVC branches ($/Project/Trunk/Main, Release, SIT…) with a changeset in the last
+ half year, named by the last part of their path as `ado_trace` reads them; Main is offered as DEV, a test branch as
+ UAT, Release as PROD.
+- Settings window, Environments: a filter (text, repository - or only TFVC branches and release stages - and "named
+ only"). Rows that were found get no name yet, only a hint in the column "Looks like"; "Use the hints for the rows
+ shown" fills the names of the rows left after filtering, so a project with many repositories does not save every
+ branch of every one.
+
## 0.11.2 — 2026-10-27
- Fixed: finding the environments (settings window, `adolens setup`, `ado_settings`, the SETUP hint of `ado_trace`)
diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj
index fafae75..e6106c9 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.2
+ 0.11.3
diff --git a/src/AdoLens/EnvironmentFlow.cs b/src/AdoLens/EnvironmentFlow.cs
index bf443e2..febf086 100644
--- a/src/AdoLens/EnvironmentFlow.cs
+++ b/src/AdoLens/EnvironmentFlow.cs
@@ -56,6 +56,27 @@ public static class EnvironmentFlow
}
catch (AdoApiException) { }
}
+ // TFVC branches ($/Project/Trunk/Main): named by the last part of their path, as ado_trace reads them. Only
+ // branches with a changeset in the last half year; they come first, in their own flow order.
+ var tfvc = new List<(string Key, string? Guess)>();
+ foreach (var p in projects)
+ {
+ try
+ {
+ foreach (var b in Flatten(await session.Client.GetListAsync($"{AdoClient.Scope(p)}_apis/tfvc/branches?includeChildren=true&includeDeleted=false", ct)))
+ {
+ 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;
+ 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)));
+ }
+ }
+ catch (AdoApiException) { } // no TFVC, 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)
@@ -67,8 +88,33 @@ public static class EnvironmentFlow
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));
+ 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();
}
+
+ /// A TFVC branch and the branches under it.
+ static IEnumerable Flatten(IEnumerable branches)
+ {
+ foreach (var b in branches)
+ {
+ yield return b;
+ foreach (var c in Flatten(Json.Arr(b, "children"))) yield return c;
+ }
+ }
+
+ ///
+ /// A first guess for a TFVC branch of a D365 project: Main (or Trunk, Dev) is where development goes in, Release is
+ /// production; a test branch (SIT, UAT, Test) in between. Dated copies (SIT_20261021) and personal branches get none.
+ ///
+ internal static string? TfvcGuess(string name) => name.ToLowerInvariant() switch
+ {
+ "main" or "trunk" or "dev" or "develop" or "development" => "DEV",
+ "sit" or "uat" or "test" or "qa" or "staging" or "preprod" => "UAT",
+ "release" or "prod" or "production" or "live" => "PROD",
+ _ => null,
+ };
}
///
diff --git a/src/AdoLens/SettingsWindow.cs b/src/AdoLens/SettingsWindow.cs
index 0bf6283..c0f94ee 100644
--- a/src/AdoLens/SettingsWindow.cs
+++ b/src/AdoLens/SettingsWindow.cs
@@ -27,6 +27,9 @@ public sealed class SettingsWindow : Form
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, SelectionMode = DataGridViewSelectionMode.FullRowSelect, BackgroundColor = SystemColors.Window,
};
readonly Label envInfo = new() { Dock = DockStyle.Fill, AutoEllipsis = true };
+ readonly TextBox envFilter = new() { Width = 160 };
+ readonly ComboBox envRepo = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 200 };
+ readonly CheckBox envNamed = new() { AutoSize = true };
readonly TextBox status = new() { Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both, WordWrap = false, Dock = DockStyle.Fill, Font = new Font(FontFamily.GenericMonospace, 9) };
@@ -278,6 +281,8 @@ TabPage EnvironmentsPage()
envGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "repo", HeaderText = Ui.T("Repository (empty = all)", "Repozytorium (puste = wszystkie)"), FillWeight = 25 });
envGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "key", HeaderText = Ui.T("Branch or release stage (Definition/Stage)", "Gałąź albo etap release'u (Definicja/Etap)"), FillWeight = 40 });
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 } });
envConnection.SelectedIndexChanged += async (_, _) => await LoadEnvironmentsAsync(detect: false);
var top = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, WrapContents = false };
@@ -289,11 +294,31 @@ TabPage EnvironmentsPage()
top.Controls.Add(Button("↓", () => MoveRow(+1)));
top.Controls.Add(Button(Ui.T("Remove row", "Usuń wiersz"), () => { foreach (DataGridViewRow r in envGrid.SelectedRows) if (!r.IsNewRow) envGrid.Rows.Remove(r); }));
+ // Many repositories give many rows: narrow them down, then fill the names of the rows left.
+ var filter = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, WrapContents = false };
+ filter.Controls.Add(new Label { Text = Ui.T("Filter:", "Filtr:"), AutoSize = true, Padding = new Padding(0, 6, 0, 0) });
+ filter.Controls.Add(envFilter);
+ filter.Controls.Add(new Label { Text = Ui.T("Repository:", "Repozytorium:"), AutoSize = true, Padding = new Padding(8, 6, 0, 0) });
+ filter.Controls.Add(envRepo);
+ envNamed.Text = Ui.T("named only", "tylko nazwane");
+ envNamed.Padding = new Padding(8, 4, 0, 0);
+ filter.Controls.Add(envNamed);
+ filter.Controls.Add(Button(Ui.T("Use the hints for the rows shown", "Użyj podpowiedzi dla widocznych"), () =>
+ {
+ foreach (DataGridViewRow row in envGrid.Rows)
+ if (!row.IsNewRow && row.Visible && string.IsNullOrWhiteSpace(row.Cells[2].Value as string) && row.Cells[3].Value is string { Length: > 0 } hint)
+ row.Cells[2].Value = hint;
+ }));
+ envFilter.TextChanged += (_, _) => ApplyFilter();
+ envRepo.SelectedIndexChanged += (_, _) => ApplyFilter();
+ envNamed.CheckedChanged += (_, _) => ApplyFilter();
+
var bottom = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true };
bottom.Controls.Add(Button(Ui.T("Save for me", "Zapisz dla mnie"), async () => await SaveEnvironmentsAsync(team: false)));
bottom.Controls.Add(Button(Ui.T("Save for the whole team (wiki page \"ADO Lens\")", "Zapisz dla zespołu (strona wiki \"ADO Lens\")"), async () => await SaveEnvironmentsAsync(team: true)));
- var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 5 };
+ var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 6 };
+ layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
@@ -302,9 +327,10 @@ TabPage EnvironmentsPage()
layout.Controls.Add(top, 0, 0);
layout.Controls.Add(Note(Ui.T("In flow order: DEV first, production last. A repository with rows of its own uses only those (the same branch may mean something else, or nothing, in another repository).",
"W kolejności przepływu: najpierw DEV, na końcu produkcja. Repozytorium z własnymi wierszami używa tylko ich (ta sama gałąź w innym repozytorium może znaczyć co innego albo nic).")), 0, 1);
- layout.Controls.Add(envGrid, 0, 2);
- layout.Controls.Add(envInfo, 0, 3);
- layout.Controls.Add(bottom, 0, 4);
+ layout.Controls.Add(filter, 0, 2);
+ layout.Controls.Add(envGrid, 0, 3);
+ layout.Controls.Add(envInfo, 0, 4);
+ layout.Controls.Add(bottom, 0, 5);
return Page(Ui.T("Environments", "Środowiska"), layout);
}
@@ -332,24 +358,60 @@ async Task LoadEnvironmentsAsync(bool detect)
var project = session.Project(null);
var (map, source) = await EnvironmentFlow.EffectiveAsync(session, project, default);
envGrid.Rows.Clear();
- foreach (var (key, label) in map) AddRow(key, label);
+ foreach (var (key, label) in map) AddRow(key, label, null);
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++; }
+ if (!map.ContainsKey(key)) { AddRow(key, "", guess); 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"))
- + (found > 0 ? Ui.T($"; {found} found (check the names)", $"; znalezione: {found} (sprawdź nazwy)") : "");
+ + (found > 0 ? Ui.T($"; {found} found - name the ones that are environments (or filter, then use the hints)",
+ $"; znalezione: {found} - nazwij te, które są środowiskami (albo przefiltruj i użyj podpowiedzi)") : "");
}
catch (Exception ex) when (ex is InvalidOperationException or AdoApiException or HttpRequestException)
{
envInfo.Text = ex.Message;
+ FillRepositories();
}
finally { UseWaitCursor = false; }
}
- void AddRow(string key, string label) =>
- envGrid.Rows.Add(EnvMap.RepoOf(key) ?? "", EnvMap.BranchOf(key), label);
+ void AddRow(string key, string label, string? hint) =>
+ envGrid.Rows.Add(EnvMap.RepoOf(key) ?? "", EnvMap.BranchOf(key), label, hint ?? "");
+
+ const int AllRepositories = 0, NoRepository = 1;
+
+ /// The repository filter: everything, the rows without a repository (TFVC branches, release stages), each repository.
+ void FillRepositories()
+ {
+ var keep = envRepo.SelectedItem as string;
+ envRepo.Items.Clear();
+ envRepo.Items.Add(Ui.T("(all)", "(wszystkie)"));
+ envRepo.Items.Add(Ui.T("(none: TFVC branches, release stages)", "(bez: gałęzie TFVC, etapy release'ów)"));
+ envRepo.Items.AddRange(envGrid.Rows.Cast().Where(r => !r.IsNewRow).Select(r => r.Cells[0].Value as string)
+ .Where(v => !string.IsNullOrWhiteSpace(v)).Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase).Cast