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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`)
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.2</Version>
<Version>0.11.3</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
48 changes: 47 additions & 1 deletion src/AdoLens/EnvironmentFlow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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();
}

/// <summary>A TFVC branch and the branches under it.</summary>
static IEnumerable<JsonElement> Flatten(IEnumerable<JsonElement> branches)
{
foreach (var b in branches)
{
yield return b;
foreach (var c in Flatten(Json.Arr(b, "children"))) yield return c;
}
}

/// <summary>
/// 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.
/// </summary>
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,
};
}

/// <summary>
Expand Down
80 changes: 71 additions & 9 deletions src/AdoLens/SettingsWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) };

Expand Down Expand Up @@ -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 };
Expand All @@ -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));
Expand All @@ -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);
}

Expand Down Expand Up @@ -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;

/// <summary>The repository filter: everything, the rows without a repository (TFVC branches, release stages), each repository.</summary>
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<DataGridViewRow>().Where(r => !r.IsNewRow).Select(r => r.Cells[0].Value as string)
.Where(v => !string.IsNullOrWhiteSpace(v)).Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase).Cast<object>().ToArray());
envRepo.SelectedIndex = keep is not null && envRepo.Items.IndexOf(keep) is var i and >= 0 ? i : AllRepositories;
ApplyFilter();
}

/// <summary>Hides the rows that do not match; hidden rows keep their names and are saved like the others.</summary>
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<DataGridViewCell>().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));
}
}

/// <summary>The rows as a map in their order; a row without a name is left out.</summary>
internal static Dictionary<string, string> MapOf(IEnumerable<(string? Repo, string? Key, string? Name)> rows)
Expand Down
9 changes: 9 additions & 0 deletions tests/AdoLens.Tests/ImprovementTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down