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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.11.2 — 2026-10-27

- Fixed: finding the environments (settings window, `adolens setup`, `ado_settings`, the SETUP hint of `ado_trace`)
looked only in the project of the work items, not in the project with the code set with `--code-project`. It now
looks in both: the repositories, branches and release stages of the code project are offered too. The settings
window says which project the code is in.

## 0.11.1 — 2026-10-27

- Fixed: the program did not start ("Failed to determine default extraction location") when TEMP pointed to a folder
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.1</Version>
<Version>0.11.2</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
54 changes: 37 additions & 17 deletions src/AdoLens/EnvironmentFlow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,37 +16,57 @@ public static class EnvironmentFlow
return (new Dictionary<string, string>(), "none");
}

/// <summary>
/// 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)
{
var projects = new[] { session.Config.CodeProject, project }.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p!)
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var keys = new List<string>();
foreach (var d in await session.Code.DeploymentsAsync(project, ct))
{
if (d.When is not { } when || when < DateTime.UtcNow.AddDays(-183)) continue;
keys.Add(d.Branch?.StartsWith("$/", StringComparison.Ordinal) == true ? $"{d.Definition}/{d.Stage}" : Code.Short(d.Branch) ?? $"{d.Definition}/{d.Stage}");
}
// 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.
try
var active = new List<(string Id, string Name)>();
foreach (var p in projects)
{
var repos = await session.Client.GetListAsync($"{AdoClient.Scope(project)}_apis/git/repositories", ct);
var active = new List<(string Id, string Name)>();
foreach (var repo in repos.Where(r => !Json.Bool(r, "isDisabled") && r.TryGetProperty("size", out var s) && s.GetInt64() > 0).Take(30))
try
{
var id = Json.Str(repo, "id") ?? "";
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));
var repos = await session.Client.GetListAsync($"{AdoClient.Scope(p)}_apis/git/repositories", ct);
foreach (var repo in repos.Where(r => !Json.Bool(r, "isDisabled") && r.TryGetProperty("size", out var s) && s.GetInt64() > 0).Take(30))
{
var id = Json.Str(repo, "id") ?? "";
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));
}
}
foreach (var (id, name) in active)
catch (AdoApiException) { } // no Git, or no Code (Read) scope
}
var several = active.Count > 1;
foreach (var (id, name) 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 => active.Count > 1 ? $"{name}:{n}" : n));
.Select(n => several ? $"{name}:{n}" : n));
}
catch (AdoApiException) { }
}
catch (AdoApiException) { } // no Git, 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)
foreach (var d in await session.Code.DeploymentsAsync(p, ct))
{
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);
}
return TraceQueries.ProposeFlow(keys.Distinct(StringComparer.OrdinalIgnoreCase));
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/AdoLens/SettingsWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,8 @@ async Task LoadEnvironmentsAsync(bool detect)
if (detect || map.Count == 0)
foreach (var (key, guess) in await EnvironmentFlow.DetectAsync(session, project, default))
if (!map.ContainsKey(key)) { AddRow(key, guess ?? ""); found++; }
envInfo.Text = $"{project}: " + (map.Count > 0 ? Ui.T($"in use: {source}", $"w użyciu: {source}") : Ui.T("no map yet", "brak mapy"))
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)") : "");
}
catch (Exception ex) when (ex is InvalidOperationException or AdoApiException or HttpRequestException)
Expand Down
14 changes: 14 additions & 0 deletions tests/AdoLens.Tests/SetupFlowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ public TeamSettingsTests()
{"value":[{"releaseDefinition":{"name":"Shop"},"releaseEnvironment":{"name":"lcs"},"completedOn":"2099-01-01T00:00:00Z",
"release":{"artifacts":[{"definitionReference":{"branch":{"id":"refs/heads/staging"},"sourceVersion":{"id":"x"}}}]}}]}
""")
.On("/contoso/ERP/_apis/git/repositories", """{"value":[{"id":"r2","name":"erp","size":100}]}""")
.On("/contoso/_apis/git/repositories/r2/commits", () => "{\"value\":[{\"commitId\":\"c2\",\"committer\":{\"date\":\"" + DateTime.UtcNow.AddDays(-1).ToString("o") + "\"}}]}")
.On("/contoso/_apis/git/repositories/r2/refs", """{"value":[{"name":"refs/heads/staging"},{"name":"refs/heads/123456_LoginForm_AB"}]}""")
.On("/contoso/Demo/_apis/git/repositories", """{"value":[{"id":"r1","name":"portal","size":100}]}""")
.On("/contoso/_apis/git/repositories/r1/commits", () => "{\"value\":[{\"commitId\":\"c1\",\"committer\":{\"date\":\"" + DateTime.UtcNow.AddDays(-3).ToString("o") + "\"}}]}")
.On("/contoso/_apis/git/repositories/r1/refs", """{"value":[{"name":"refs/heads/dev"},{"name":"refs/heads/master"},{"name":"refs/heads/feature/x"}]}""");
Expand All @@ -87,6 +90,17 @@ public void Dispose()
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}

[Fact]
public async Task With_the_code_in_another_project_its_branches_are_found_too()
{
settings.Connections["contoso"].CodeProject = "ERP";
var keys = (await EnvironmentFlow.DetectAsync(lens.For(null), "Demo", default)).Select(d => d.Key).ToList();
// Two repositories, one per project: each branch with its repository; work branches left out.
Assert.Contains("erp:staging", keys);
Assert.Contains("portal:dev", keys);
Assert.DoesNotContain(keys, k => k.Contains("LoginForm"));
}

[Fact]
public void The_wiki_table_round_trips_in_order()
{
Expand Down