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;