Skip to content

Commit 715d20e

Browse files
ADO Lens 0.11.3
ADO Lens 0.11.3
2 parents 8992597 + b609272 commit 715d20e

5 files changed

Lines changed: 138 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## 0.11.3 — 2026-10-27
4+
5+
- Finding the environments includes TFVC branches ($/Project/Trunk/Main, Release, SIT…) with a changeset in the last
6+
half year, named by the last part of their path as `ado_trace` reads them; Main is offered as DEV, a test branch as
7+
UAT, Release as PROD.
8+
- Settings window, Environments: a filter (text, repository - or only TFVC branches and release stages - and "named
9+
only"). Rows that were found get no name yet, only a hint in the column "Looks like"; "Use the hints for the rows
10+
shown" fills the names of the rows left after filtering, so a project with many repositories does not save every
11+
branch of every one.
12+
313
## 0.11.2 — 2026-10-27
414

515
- Fixed: finding the environments (settings window, `adolens setup`, `ado_settings`, the SETUP hint of `ado_trace`)

‎src/AdoLens/AdoLens.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
<AssemblyTitle>ADO Lens for Azure DevOps</AssemblyTitle>
2626
<Description>Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP</Description>
2727
<Copyright>Copyright © 2026 WillowDev</Copyright>
28-
<Version>0.11.2</Version>
28+
<Version>0.11.3</Version>
2929
</PropertyGroup>
3030

3131
<ItemGroup>

‎src/AdoLens/EnvironmentFlow.cs‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,27 @@ public static class EnvironmentFlow
5656
}
5757
catch (AdoApiException) { }
5858
}
59+
// TFVC branches ($/Project/Trunk/Main): named by the last part of their path, as ado_trace reads them. Only
60+
// branches with a changeset in the last half year; they come first, in their own flow order.
61+
var tfvc = new List<(string Key, string? Guess)>();
62+
foreach (var p in projects)
63+
{
64+
try
65+
{
66+
foreach (var b in Flatten(await session.Client.GetListAsync($"{AdoClient.Scope(p)}_apis/tfvc/branches?includeChildren=true&includeDeleted=false", ct)))
67+
{
68+
var path = Json.Str(b, "path") ?? "";
69+
var name = path[(path.LastIndexOf('/') + 1)..];
70+
if (name.Length == 0 || tfvc.Any(t => t.Key.Equals(name, StringComparison.OrdinalIgnoreCase))) continue;
71+
var last = await session.Client.GetListAsync($"_apis/tfvc/changesets?searchCriteria.itemPath={Uri.EscapeDataString(path)}&$top=1", ct);
72+
if (last.Count == 0 || !DateTime.TryParse(Json.Str(last[0], "createdDate"), System.Globalization.CultureInfo.InvariantCulture,
73+
System.Globalization.DateTimeStyles.AdjustToUniversal, out var when) || when < DateTime.UtcNow.AddDays(-183)) continue;
74+
tfvc.Add((name, TfvcGuess(name)));
75+
}
76+
}
77+
catch (AdoApiException) { } // no TFVC, or no Code (Read) scope
78+
}
79+
5980
// Release stages deployed in the last half year; a Git stage is named by its branch (with its repository when
6081
// there are several), a TFVC one as "Definition/Stage".
6182
foreach (var p in projects)
@@ -67,8 +88,33 @@ public static class EnvironmentFlow
6788
keys.Add(d.Branch?.StartsWith("$/", StringComparison.Ordinal) == true || branch is null ? $"{d.Definition}/{d.Stage}"
6889
: several && repo is not null ? $"{repo}:{branch}" : branch);
6990
}
70-
return TraceQueries.ProposeFlow(keys.Distinct(StringComparer.OrdinalIgnoreCase));
91+
var git = TraceQueries.ProposeFlow(keys.Distinct(StringComparer.OrdinalIgnoreCase));
92+
var order = new[] { "DEV", "UAT", "PROD" };
93+
return tfvc.OrderBy(t => t.Guess is null ? 3 : Array.IndexOf(order, t.Guess)).ThenBy(t => t.Key, StringComparer.OrdinalIgnoreCase)
94+
.Concat(git.Where(g => !tfvc.Any(t => t.Key.Equals(g.Key, StringComparison.OrdinalIgnoreCase)))).ToList();
7195
}
96+
97+
/// <summary>A TFVC branch and the branches under it.</summary>
98+
static IEnumerable<JsonElement> Flatten(IEnumerable<JsonElement> branches)
99+
{
100+
foreach (var b in branches)
101+
{
102+
yield return b;
103+
foreach (var c in Flatten(Json.Arr(b, "children"))) yield return c;
104+
}
105+
}
106+
107+
/// <summary>
108+
/// A first guess for a TFVC branch of a D365 project: Main (or Trunk, Dev) is where development goes in, Release is
109+
/// production; a test branch (SIT, UAT, Test) in between. Dated copies (SIT_20261021) and personal branches get none.
110+
/// </summary>
111+
internal static string? TfvcGuess(string name) => name.ToLowerInvariant() switch
112+
{
113+
"main" or "trunk" or "dev" or "develop" or "development" => "DEV",
114+
"sit" or "uat" or "test" or "qa" or "staging" or "preprod" => "UAT",
115+
"release" or "prod" or "production" or "live" => "PROD",
116+
_ => null,
117+
};
72118
}
73119

74120
/// <summary>

‎src/AdoLens/SettingsWindow.cs‎

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ public sealed class SettingsWindow : Form
2727
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, SelectionMode = DataGridViewSelectionMode.FullRowSelect, BackgroundColor = SystemColors.Window,
2828
};
2929
readonly Label envInfo = new() { Dock = DockStyle.Fill, AutoEllipsis = true };
30+
readonly TextBox envFilter = new() { Width = 160 };
31+
readonly ComboBox envRepo = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 200 };
32+
readonly CheckBox envNamed = new() { AutoSize = true };
3033

3134
readonly TextBox status = new() { Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both, WordWrap = false, Dock = DockStyle.Fill, Font = new Font(FontFamily.GenericMonospace, 9) };
3235

@@ -278,6 +281,8 @@ TabPage EnvironmentsPage()
278281
envGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "repo", HeaderText = Ui.T("Repository (empty = all)", "Repozytorium (puste = wszystkie)"), FillWeight = 25 });
279282
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 });
280283
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 });
284+
envGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "hint", HeaderText = Ui.T("Looks like", "Wygląda na"), FillWeight = 12, ReadOnly = true,
285+
DefaultCellStyle = new DataGridViewCellStyle { ForeColor = SystemColors.GrayText } });
281286
envConnection.SelectedIndexChanged += async (_, _) => await LoadEnvironmentsAsync(detect: false);
282287

283288
var top = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, WrapContents = false };
@@ -289,11 +294,31 @@ TabPage EnvironmentsPage()
289294
top.Controls.Add(Button("↓", () => MoveRow(+1)));
290295
top.Controls.Add(Button(Ui.T("Remove row", "Usuń wiersz"), () => { foreach (DataGridViewRow r in envGrid.SelectedRows) if (!r.IsNewRow) envGrid.Rows.Remove(r); }));
291296

297+
// Many repositories give many rows: narrow them down, then fill the names of the rows left.
298+
var filter = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, WrapContents = false };
299+
filter.Controls.Add(new Label { Text = Ui.T("Filter:", "Filtr:"), AutoSize = true, Padding = new Padding(0, 6, 0, 0) });
300+
filter.Controls.Add(envFilter);
301+
filter.Controls.Add(new Label { Text = Ui.T("Repository:", "Repozytorium:"), AutoSize = true, Padding = new Padding(8, 6, 0, 0) });
302+
filter.Controls.Add(envRepo);
303+
envNamed.Text = Ui.T("named only", "tylko nazwane");
304+
envNamed.Padding = new Padding(8, 4, 0, 0);
305+
filter.Controls.Add(envNamed);
306+
filter.Controls.Add(Button(Ui.T("Use the hints for the rows shown", "Użyj podpowiedzi dla widocznych"), () =>
307+
{
308+
foreach (DataGridViewRow row in envGrid.Rows)
309+
if (!row.IsNewRow && row.Visible && string.IsNullOrWhiteSpace(row.Cells[2].Value as string) && row.Cells[3].Value is string { Length: > 0 } hint)
310+
row.Cells[2].Value = hint;
311+
}));
312+
envFilter.TextChanged += (_, _) => ApplyFilter();
313+
envRepo.SelectedIndexChanged += (_, _) => ApplyFilter();
314+
envNamed.CheckedChanged += (_, _) => ApplyFilter();
315+
292316
var bottom = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true };
293317
bottom.Controls.Add(Button(Ui.T("Save for me", "Zapisz dla mnie"), async () => await SaveEnvironmentsAsync(team: false)));
294318
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)));
295319

296-
var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 5 };
320+
var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 6 };
321+
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
297322
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
298323
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
299324
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
@@ -302,9 +327,10 @@ TabPage EnvironmentsPage()
302327
layout.Controls.Add(top, 0, 0);
303328
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).",
304329
"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);
305-
layout.Controls.Add(envGrid, 0, 2);
306-
layout.Controls.Add(envInfo, 0, 3);
307-
layout.Controls.Add(bottom, 0, 4);
330+
layout.Controls.Add(filter, 0, 2);
331+
layout.Controls.Add(envGrid, 0, 3);
332+
layout.Controls.Add(envInfo, 0, 4);
333+
layout.Controls.Add(bottom, 0, 5);
308334
return Page(Ui.T("Environments", "Środowiska"), layout);
309335
}
310336

@@ -332,24 +358,60 @@ async Task LoadEnvironmentsAsync(bool detect)
332358
var project = session.Project(null);
333359
var (map, source) = await EnvironmentFlow.EffectiveAsync(session, project, default);
334360
envGrid.Rows.Clear();
335-
foreach (var (key, label) in map) AddRow(key, label);
361+
foreach (var (key, label) in map) AddRow(key, label, null);
336362
var found = 0;
363+
// Found rows get no name yet, only the hint: with many repositories, saving would otherwise take them all.
337364
if (detect || map.Count == 0)
338365
foreach (var (key, guess) in await EnvironmentFlow.DetectAsync(session, project, default))
339-
if (!map.ContainsKey(key)) { AddRow(key, guess ?? ""); found++; }
366+
if (!map.ContainsKey(key)) { AddRow(key, "", guess); found++; }
367+
FillRepositories();
340368
var code = session.Config.CodeProject is { } cp && !cp.Equals(project, StringComparison.OrdinalIgnoreCase) ? Ui.T($" (code in {cp})", $" (kod w {cp})") : "";
341369
envInfo.Text = $"{project}{code}: " + (map.Count > 0 ? Ui.T($"in use: {source}", $"w użyciu: {source}") : Ui.T("no map yet", "brak mapy"))
342-
+ (found > 0 ? Ui.T($"; {found} found (check the names)", $"; znalezione: {found} (sprawdź nazwy)") : "");
370+
+ (found > 0 ? Ui.T($"; {found} found - name the ones that are environments (or filter, then use the hints)",
371+
$"; znalezione: {found} - nazwij te, które są środowiskami (albo przefiltruj i użyj podpowiedzi)") : "");
343372
}
344373
catch (Exception ex) when (ex is InvalidOperationException or AdoApiException or HttpRequestException)
345374
{
346375
envInfo.Text = ex.Message;
376+
FillRepositories();
347377
}
348378
finally { UseWaitCursor = false; }
349379
}
350380

351-
void AddRow(string key, string label) =>
352-
envGrid.Rows.Add(EnvMap.RepoOf(key) ?? "", EnvMap.BranchOf(key), label);
381+
void AddRow(string key, string label, string? hint) =>
382+
envGrid.Rows.Add(EnvMap.RepoOf(key) ?? "", EnvMap.BranchOf(key), label, hint ?? "");
383+
384+
const int AllRepositories = 0, NoRepository = 1;
385+
386+
/// <summary>The repository filter: everything, the rows without a repository (TFVC branches, release stages), each repository.</summary>
387+
void FillRepositories()
388+
{
389+
var keep = envRepo.SelectedItem as string;
390+
envRepo.Items.Clear();
391+
envRepo.Items.Add(Ui.T("(all)", "(wszystkie)"));
392+
envRepo.Items.Add(Ui.T("(none: TFVC branches, release stages)", "(bez: gałęzie TFVC, etapy release'ów)"));
393+
envRepo.Items.AddRange(envGrid.Rows.Cast<DataGridViewRow>().Where(r => !r.IsNewRow).Select(r => r.Cells[0].Value as string)
394+
.Where(v => !string.IsNullOrWhiteSpace(v)).Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase).Cast<object>().ToArray());
395+
envRepo.SelectedIndex = keep is not null && envRepo.Items.IndexOf(keep) is var i and >= 0 ? i : AllRepositories;
396+
ApplyFilter();
397+
}
398+
399+
/// <summary>Hides the rows that do not match; hidden rows keep their names and are saved like the others.</summary>
400+
void ApplyFilter()
401+
{
402+
var text = envFilter.Text.Trim();
403+
envGrid.CurrentCell = null;
404+
foreach (DataGridViewRow row in envGrid.Rows)
405+
{
406+
if (row.IsNewRow) continue;
407+
var repo = row.Cells[0].Value as string ?? "";
408+
var cells = string.Join(" ", row.Cells.Cast<DataGridViewCell>().Select(c => c.Value as string));
409+
var index = envRepo.SelectedIndex;
410+
var inRepo = index <= AllRepositories || (index == NoRepository ? repo.Length == 0 : repo.Equals(envRepo.SelectedItem as string, StringComparison.OrdinalIgnoreCase));
411+
row.Visible = (text.Length == 0 || cells.Contains(text, StringComparison.OrdinalIgnoreCase)) && inRepo
412+
&& (!envNamed.Checked || !string.IsNullOrWhiteSpace(row.Cells[2].Value as string));
413+
}
414+
}
353415

354416
/// <summary>The rows as a map in their order; a row without a name is left out.</summary>
355417
internal static Dictionary<string, string> MapOf(IEnumerable<(string? Repo, string? Key, string? Name)> rows)

‎tests/AdoLens.Tests/ImprovementTests.cs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ public void Code_can_live_in_another_project_than_the_work_items()
5151
}
5252
}
5353

54+
[Theory]
55+
[InlineData("Main", "DEV")]
56+
[InlineData("SIT", "UAT")]
57+
[InlineData("Release", "PROD")]
58+
[InlineData("SIT_20261021", null)]
59+
[InlineData("joma", null)]
60+
public void Tfvc_branches_get_a_first_guess(string branch, string? guess) =>
61+
Assert.Equal(guess, EnvironmentFlow.TfvcGuess(branch));
62+
5463
[Fact]
5564
public void Work_branches_are_not_environments()
5665
{

0 commit comments

Comments
 (0)