Skip to content

Commit 1252dd7

Browse files
ADO Lens 0.11.5
ADO Lens 0.11.5
2 parents e6ffa36 + 3d1fa79 commit 1252dd7

6 files changed

Lines changed: 74 additions & 22 deletions

File tree

‎CHANGELOG.md‎

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

3+
## 0.11.5 — 2026-10-27
4+
5+
- Fixed: the Environments tab of the settings window showed every row twice (two loads at once: choosing the
6+
connection and opening the tab). Only the last load fills the table now.
7+
- Environments: a column "Where from" (and a tooltip) with where each row comes from - the TFVC path and its last
8+
changeset, the repository and its project, or the release, stage, date and branch it was deployed from - so a "Main"
9+
or "Release" can be told from another. The terminal menu shows it under each row; `adolens setup --detect --sources`
10+
prints it.
11+
312
## 0.11.4 — 2026-10-27
413

514
The environments are set the same way in the window, the terminal and the installer.

‎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.4</Version>
28+
<Version>0.11.5</Version>
2929
</PropertyGroup>
3030

3131
<ItemGroup>

‎src/AdoLens/Cli.cs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,8 @@ async Task<int> SetupUnattendedAsync()
602602
var lines = new List<string>();
603603
var (effective, source) = await EnvironmentFlow.EffectiveAsync(session, project, default);
604604
if (effective.Count > 0) lines.Add($"existing\t{string.Join(" -> ", effective.Values)}\t{source}");
605+
// --sources adds where each comes from (the installer reads three columns only).
606+
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}"));
605607
else lines.AddRange((await EnvironmentFlow.DetectAsync(session, project, default)).Select(d => $"row\t{d.Key}\t{d.Guess}"));
606608
if (cl.Opt("out") is { } file)
607609
{
@@ -678,7 +680,7 @@ async Task EnvironmentsAsync(string name, Session session, string project, bool
678680
Console.WriteLine(Ui.T($"{name} / {project}: code goes {string.Join(" -> ", effective.Values)} ({source}).",
679681
$"{name} / {project}: kod trafia kolejno na {string.Join(" -> ", effective.Values)} ({source})."));
680682
if (effective.Count > 0 && !change) return;
681-
var found = (await EnvironmentFlow.DetectAsync(session, project, default)).Where(d => !effective.ContainsKey(d.Key)).ToList();
683+
var found = (await EnvironmentFlow.DetectWithSourcesAsync(session, project, default)).Where(d => !effective.ContainsKey(d.Key)).ToList();
682684
if ((effective.Count == 0 && found.Count == 0) || !interactive)
683685
{
684686
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
716718
if (found.Count > 0)
717719
Console.WriteLine(Ui.T("Found - type the name as people say it (e.g. UAT (sbx03)), '+' takes the hint, Enter skips:",
718720
"Znalezione - wpisz nazwę, jakiej używacie (np. UAT (sbx03)), '+' przyjmuje podpowiedź, Enter pomija:"));
719-
foreach (var (key, hint) in found)
721+
foreach (var (key, hint, from) in found)
720722
{
723+
// Where it comes from: two "Main" or "Release" are told apart by their path or release.
724+
if (from.Length > 0) Console.WriteLine($" ({from})");
721725
var label = Prompt($" {key}{(hint is null ? "" : Ui.T($" (looks like {hint})", $" (wygląda na {hint})"))}: ")?.Trim();
722726
if (label == "+" && hint is not null) map[key] = hint;
723727
else if (!string.IsNullOrEmpty(label) && label != "+" && label != "-") map[key] = label;

‎src/AdoLens/CommandLine.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public sealed partial class CommandLine
1313
{
1414
["mcp"] = [],
1515
["upgrade"] = ["install", "pre"],
16-
["setup"] = ["mode=", "connection=", "detect", "out=", "env=", "scope=", "wizard", "window"],
16+
["setup"] = ["mode=", "connection=", "detect", "out=", "env=", "scope=", "wizard", "window", "sources"],
1717
["login"] = ["connection=", "org=", "project=", "team=", "pat-stdin", "pat-env=", "default"],
1818
["tests"] = ["connection=", "team=", "detail="],
1919
["test-create"] = ["connection=", "file=", "plan=", "suite="],

‎src/AdoLens/EnvironmentFlow.cs‎

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,29 @@ public static class EnvironmentFlow
2020
/// Release stages and long-lived branches of the project with the code (the connection's code project, when the
2121
/// code lives elsewhere than the work items) and of <paramref name="project"/> itself.
2222
/// </summary>
23-
public static async Task<List<(string Key, string? Guess)>> DetectAsync(Session session, string project, CancellationToken ct)
23+
public static async Task<List<(string Key, string? Guess)>> DetectAsync(Session session, string project, CancellationToken ct) =>
24+
(await DetectWithSourcesAsync(session, project, ct)).Select(d => (d.Key, d.Guess)).ToList();
25+
26+
/// <summary>
27+
/// As <see cref="DetectAsync"/>, with where each one comes from ("$/Project/Trunk/Main, last changeset 2026-10-20";
28+
/// "release Deploy, stage 1, deployed 2026-10-24 from …"), so two of the same name can be told apart.
29+
/// </summary>
30+
public static async Task<List<(string Key, string? Guess, string Source)>> DetectWithSourcesAsync(Session session, string project, CancellationToken ct)
2431
{
32+
var sources = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
33+
void From(string key, string source)
34+
{
35+
if (!sources.TryGetValue(key, out var list)) sources[key] = list = [];
36+
if (!list.Contains(source)) list.Add(source);
37+
}
38+
static string Day(DateTime? d) => d?.ToLocalTime().ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture) ?? "?";
2539
var projects = new[] { session.Config.CodeProject, project }.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p!)
2640
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
2741
var keys = new List<string>();
2842
// Long-lived branches of the repositories in use (a commit in the last half year); feature and work branches are
2943
// filtered by ProposeFlow. With several repositories each branch is named with its repository ("D365FO:staging"),
3044
// since the same branch name can mean different environments - or none - in different repositories.
31-
var active = new List<(string Id, string Name)>();
45+
var active = new List<(string Id, string Name, string Project, DateTime Last)>();
3246
foreach (var p in projects)
3347
{
3448
try
@@ -40,19 +54,24 @@ public static class EnvironmentFlow
4054
var last = await session.Client.GetListAsync($"_apis/git/repositories/{id}/commits?searchCriteria.$top=1", ct);
4155
if (last.Count > 0 && DateTime.TryParse(Json.Str(Json.Obj(last[0], "committer"), "date"), System.Globalization.CultureInfo.InvariantCulture,
4256
System.Globalization.DateTimeStyles.AdjustToUniversal, out var when) && when > DateTime.UtcNow.AddDays(-183))
43-
active.Add((id, Json.Str(repo, "name") ?? id));
57+
active.Add((id, Json.Str(repo, "name") ?? id, p, when));
4458
}
4559
}
4660
catch (AdoApiException) { } // no Git, or no Code (Read) scope
4761
}
4862
var several = active.Count > 1;
49-
foreach (var (id, name) in active)
63+
foreach (var (id, name, inProject, last) in active)
5064
{
5165
try
5266
{
5367
var refs = await session.Client.GetListAsync($"_apis/git/repositories/{id}/refs?filter=heads/&$top=200", ct);
54-
keys.AddRange(refs.Select(r => Code.Short(Json.Str(r, "name")) ?? "").Where(n => n.Length > 0 && !n.Contains('/'))
55-
.Select(n => several ? $"{name}:{n}" : n));
68+
foreach (var n in refs.Select(r => Code.Short(Json.Str(r, "name")) ?? "").Where(n => n.Length > 0 && !n.Contains('/')))
69+
{
70+
var key = several ? $"{name}:{n}" : n;
71+
keys.Add(key);
72+
From(key, Ui.T($"Git: branch {n} of repository {name} ({inProject}; last commit in the repository {Day(last)})",
73+
$"Git: gałąź {n} repozytorium {name} ({inProject}; ostatni commit w repozytorium {Day(last)})"));
74+
}
5675
}
5776
catch (AdoApiException) { }
5877
}
@@ -67,11 +86,13 @@ public static class EnvironmentFlow
6786
{
6887
var path = Json.Str(b, "path") ?? "";
6988
var name = path[(path.LastIndexOf('/') + 1)..];
70-
if (name.Length == 0 || tfvc.Any(t => t.Key.Equals(name, StringComparison.OrdinalIgnoreCase))) continue;
89+
if (name.Length == 0) continue;
7190
var last = await session.Client.GetListAsync($"_apis/tfvc/changesets?searchCriteria.itemPath={Uri.EscapeDataString(path)}&$top=1", ct);
7291
if (last.Count == 0 || !DateTime.TryParse(Json.Str(last[0], "createdDate"), System.Globalization.CultureInfo.InvariantCulture,
7392
System.Globalization.DateTimeStyles.AdjustToUniversal, out var when) || when < DateTime.UtcNow.AddDays(-183)) continue;
74-
tfvc.Add((name, TfvcGuess(name)));
93+
// Two branches with one name (Main in two projects) are one key: ado_trace knows a TFVC branch by its last part.
94+
From(name, Ui.T($"TFVC: {path} (last changeset {Day(when)})", $"TFVC: {path} (ostatni changeset {Day(when)})"));
95+
if (!tfvc.Any(t => t.Key.Equals(name, StringComparison.OrdinalIgnoreCase))) tfvc.Add((name, TfvcGuess(name)));
7596
}
7697
}
7798
catch (AdoApiException) { } // no TFVC, or no Code (Read) scope
@@ -85,13 +106,18 @@ public static class EnvironmentFlow
85106
if (d.When is not { } when || when < DateTime.UtcNow.AddDays(-183)) continue;
86107
var branch = Code.Short(d.Branch);
87108
var repo = active.FirstOrDefault(a => string.Equals(a.Id, d.Repository, StringComparison.OrdinalIgnoreCase)).Name;
88-
keys.Add(d.Branch?.StartsWith("$/", StringComparison.Ordinal) == true || branch is null ? $"{d.Definition}/{d.Stage}"
89-
: several && repo is not null ? $"{repo}:{branch}" : branch);
109+
var key = d.Branch?.StartsWith("$/", StringComparison.Ordinal) == true || branch is null ? $"{d.Definition}/{d.Stage}"
110+
: several && repo is not null ? $"{repo}:{branch}" : branch;
111+
keys.Add(key);
112+
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)}")
113+
+ (d.Branch is { } from ? Ui.T($" from {from}", $" z {from}") : "") + $" ({p})");
90114
}
91115
var git = TraceQueries.ProposeFlow(keys.Distinct(StringComparer.OrdinalIgnoreCase));
92116
var order = new[] { "DEV", "UAT", "PROD" };
93117
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();
118+
.Concat(git.Where(g => !tfvc.Any(t => t.Key.Equals(g.Key, StringComparison.OrdinalIgnoreCase))))
119+
.Select(k => (k.Key, k.Guess, string.Join("; ", sources.GetValueOrDefault(k.Key) ?? [])))
120+
.DistinctBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToList();
95121
}
96122

97123
/// <summary>A TFVC branch and the branches under it.</summary>

‎src/AdoLens/SettingsWindow.cs‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,13 @@ TabPage EnvironmentsPage()
283283
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 });
284284
envGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "hint", HeaderText = Ui.T("Looks like", "Wygląda na"), FillWeight = 12, ReadOnly = true,
285285
DefaultCellStyle = new DataGridViewCellStyle { ForeColor = SystemColors.GrayText } });
286+
// Where a row comes from: two "Main" or "Release" rows are told apart by their path, repository or release.
287+
envGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "source", HeaderText = Ui.T("Where from", "Skąd"), FillWeight = 45, ReadOnly = true,
288+
DefaultCellStyle = new DataGridViewCellStyle { ForeColor = SystemColors.GrayText } });
289+
envGrid.CellToolTipTextNeeded += (_, e) =>
290+
{
291+
if (e.RowIndex >= 0 && envGrid.Rows[e.RowIndex].Cells[4].Value is string { Length: > 0 } source) e.ToolTipText = source.Replace("; ", "\n");
292+
};
286293
envConnection.SelectedIndexChanged += async (_, _) => await LoadEnvironmentsAsync(detect: false);
287294

288295
var top = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, WrapContents = false };
@@ -346,9 +353,13 @@ void MoveRow(int by)
346353
envGrid.CurrentCell = row.Cells[0];
347354
}
348355

356+
/// <summary>Each load gets a number; only the last one fills the table (two loads at once doubled every row).</summary>
357+
int envLoad;
358+
349359
async Task LoadEnvironmentsAsync(bool detect)
350360
{
351361
if (envConnection.SelectedItem is not string name) return;
362+
var load = ++envLoad;
352363
UseWaitCursor = true;
353364
envInfo.Text = detect ? Ui.T("Looking for release stages and branches...", "Szukam etapów release'ów i gałęzi...") : "";
354365
try
@@ -357,13 +368,15 @@ async Task LoadEnvironmentsAsync(bool detect)
357368
var session = lens.For(name);
358369
var project = session.Project(null);
359370
var (map, source) = await EnvironmentFlow.EffectiveAsync(session, project, default);
371+
// Found rows get no name yet, only the hint: with many repositories, saving would otherwise take them all.
372+
var detected = detect || map.Count == 0 ? await EnvironmentFlow.DetectWithSourcesAsync(session, project, default) : [];
373+
if (load != envLoad) return; // a newer load is on its way
360374
envGrid.Rows.Clear();
361-
foreach (var (key, label) in map) AddRow(key, label, null);
375+
foreach (var (key, label) in map)
376+
AddRow(key, label, null, detected.FirstOrDefault(d => d.Key.Equals(key, StringComparison.OrdinalIgnoreCase)).Source);
362377
var found = 0;
363-
// Found rows get no name yet, only the hint: with many repositories, saving would otherwise take them all.
364-
if (detect || map.Count == 0)
365-
foreach (var (key, guess) in await EnvironmentFlow.DetectAsync(session, project, default))
366-
if (!map.ContainsKey(key)) { AddRow(key, "", guess); found++; }
378+
foreach (var (key, guess, from) in detected)
379+
if (!map.ContainsKey(key)) { AddRow(key, "", guess, from); found++; }
367380
FillRepositories();
368381
var code = session.Config.CodeProject is { } cp && !cp.Equals(project, StringComparison.OrdinalIgnoreCase) ? Ui.T($" (code in {cp})", $" (kod w {cp})") : "";
369382
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)
378391
finally { UseWaitCursor = false; }
379392
}
380393

381-
void AddRow(string key, string label, string? hint) =>
382-
envGrid.Rows.Add(EnvMap.RepoOf(key) ?? "", EnvMap.BranchOf(key), label, hint ?? "");
394+
void AddRow(string key, string label, string? hint, string? source = null) =>
395+
envGrid.Rows.Add(EnvMap.RepoOf(key) ?? "", EnvMap.BranchOf(key), label, hint ?? "", source ?? "");
383396

384397
const int AllRepositories = 0, NoRepository = 1;
385398

0 commit comments

Comments
 (0)