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().ToArray()); + envRepo.SelectedIndex = keep is not null && envRepo.Items.IndexOf(keep) is var i and >= 0 ? i : AllRepositories; + ApplyFilter(); + } + + /// Hides the rows that do not match; hidden rows keep their names and are saved like the others. + void ApplyFilter() + { + var text = envFilter.Text.Trim(); + envGrid.CurrentCell = null; + foreach (DataGridViewRow row in envGrid.Rows) + { + if (row.IsNewRow) continue; + var repo = row.Cells[0].Value as string ?? ""; + var cells = string.Join(" ", row.Cells.Cast().Select(c => c.Value as string)); + var index = envRepo.SelectedIndex; + var inRepo = index <= AllRepositories || (index == NoRepository ? repo.Length == 0 : repo.Equals(envRepo.SelectedItem as string, StringComparison.OrdinalIgnoreCase)); + row.Visible = (text.Length == 0 || cells.Contains(text, StringComparison.OrdinalIgnoreCase)) && inRepo + && (!envNamed.Checked || !string.IsNullOrWhiteSpace(row.Cells[2].Value as string)); + } + } /// The rows as a map in their order; a row without a name is left out. internal static Dictionary MapOf(IEnumerable<(string? Repo, string? Key, string? Name)> rows) diff --git a/tests/AdoLens.Tests/ImprovementTests.cs b/tests/AdoLens.Tests/ImprovementTests.cs index b930021..084a222 100644 --- a/tests/AdoLens.Tests/ImprovementTests.cs +++ b/tests/AdoLens.Tests/ImprovementTests.cs @@ -51,6 +51,15 @@ public void Code_can_live_in_another_project_than_the_work_items() } } + [Theory] + [InlineData("Main", "DEV")] + [InlineData("SIT", "UAT")] + [InlineData("Release", "PROD")] + [InlineData("SIT_20261021", null)] + [InlineData("joma", null)] + public void Tfvc_branches_get_a_first_guess(string branch, string? guess) => + Assert.Equal(guess, EnvironmentFlow.TfvcGuess(branch)); + [Fact] public void Work_branches_are_not_environments() {