Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/AdoLens/AdoLens.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<AssemblyTitle>ADO Lens for Azure DevOps</AssemblyTitle>
<Description>Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP</Description>
<Copyright>Copyright © 2026 WillowDev</Copyright>
<Version>0.11.4</Version>
<Version>0.11.5</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
8 changes: 6 additions & 2 deletions src/AdoLens/Cli.cs
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,8 @@ async Task<int> SetupUnattendedAsync()
var lines = new List<string>();
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)
{
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/AdoLens/CommandLine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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="],
Expand Down
48 changes: 37 additions & 11 deletions src/AdoLens/EnvironmentFlow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <paramref name="project"/> itself.
/// </summary>
public static async Task<List<(string Key, string? Guess)>> DetectAsync(Session session, string project, CancellationToken ct)
public static async Task<List<(string Key, string? Guess)>> DetectAsync(Session session, string project, CancellationToken ct) =>
(await DetectWithSourcesAsync(session, project, ct)).Select(d => (d.Key, d.Guess)).ToList();

/// <summary>
/// As <see cref="DetectAsync"/>, 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.
/// </summary>
public static async Task<List<(string Key, string? Guess, string Source)>> DetectWithSourcesAsync(Session session, string project, CancellationToken ct)
{
var sources = new Dictionary<string, List<string>>(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<string>();
// 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
Expand All @@ -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) { }
}
Expand All @@ -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
Expand All @@ -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();
}

/// <summary>A TFVC branch and the branches under it.</summary>
Expand Down
27 changes: 20 additions & 7 deletions src/AdoLens/SettingsWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -346,9 +353,13 @@ void MoveRow(int by)
envGrid.CurrentCell = row.Cells[0];
}

/// <summary>Each load gets a number; only the last one fills the table (two loads at once doubled every row).</summary>
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
Expand All @@ -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"))
Expand All @@ -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;

Expand Down