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.9.3 — 2026-10-19

- `ado_trace`: "is the change in this branch / deployment" asks the history of the target around the day the change
was merged (one cheap call) instead of a diff between the commits, which took 20-30 seconds each in a large
repository; the diff stays as the fallback when the date is unknown.
- Code moves forward through the environments of the map (dev -> staging -> master): a change merged into a later
one is not looked for in an earlier one.
- "not yet" leaves out a change whose work is already there through another PR from the same source branch (the same
work merged into each environment by its own PR).

## 0.9.2 — 2026-10-19

Fixes and additions from testing 0.9 on a real project.
Expand Down
2 changes: 1 addition & 1 deletion src/AdoLens/AdoLens.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,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.9.2</Version>
<Version>0.9.3</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
17 changes: 16 additions & 1 deletion src/AdoLens/Code.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,23 @@ public async Task<CodeChange> PullRequestAsync(string? repoId, int id, bool with
}

/// <summary>Whether <paramref name="commit"/> is in the history of <paramref name="target"/> (a commit or branch).</summary>
public async Task<bool?> ContainsAsync(string repoId, string commit, string target, bool targetIsBranch, CancellationToken ct)
public async Task<bool?> ContainsAsync(string repoId, string commit, string target, bool targetIsBranch, CancellationToken ct, DateTime? committed = null)
{
// With the commit's date: the history of the target around that day holds the commit exactly when the target
// contains it. One cheap call, where a diff between far-apart commits takes seconds in a large repository.
if (committed is { } when)
{
try
{
var near = await client.GetListAsync($"_apis/git/repositories/{repoId}/commits?searchCriteria.itemVersion.version={Uri.EscapeDataString(target)}" +
$"&searchCriteria.itemVersion.versionType={(targetIsBranch ? "branch" : "commit")}" +
$"&searchCriteria.fromDate={Uri.EscapeDataString(when.AddDays(-1).ToString("o", CultureInfo.InvariantCulture))}" +
$"&searchCriteria.toDate={Uri.EscapeDataString(when.AddDays(1).ToString("o", CultureInfo.InvariantCulture))}&searchCriteria.$top=1000", ct);
if (near.Any(c => string.Equals(Json.Str(c, "commitId"), commit, StringComparison.OrdinalIgnoreCase))) return true;
if (near.Count < 1000) return false;
}
catch (AdoApiException) { }
}
try
{
var diff = await client.GetAsync($"_apis/git/repositories/{repoId}/diffs/commits?baseVersion={commit}&baseVersionType=commit" +
Expand Down
19 changes: 13 additions & 6 deletions src/AdoLens/Trace.cs
Original file line number Diff line number Diff line change
Expand Up @@ -287,15 +287,19 @@ string Label(string? branch, string def, string stage) =>
// with several PRs and many release stages).
var containsCache = new System.Collections.Concurrent.ConcurrentDictionary<(string, string, bool), Task<bool?>>();
using var gate = new SemaphoreSlim(ParallelChecks);
Task<bool?> Contains(string repo, string commit, string target, bool isBranch) =>
containsCache.GetOrAdd((commit, target, isBranch), _ => Gated(() => code.ContainsAsync(repo, commit, target, isBranch, dt)));
Task<bool?> Contains(string repo, string commit, string target, bool isBranch, DateTime? committed) =>
containsCache.GetOrAdd((commit, target, isBranch), _ => Gated(() => code.ContainsAsync(repo, commit, target, isBranch, dt, committed)));
// Code moves forward through the environments (dev -> staging -> master): a change merged into a later one is
// not in an earlier one, so that is not asked.
bool Later(string? changeTarget, string? branch) =>
changeTarget is not null && branch is not null && envOrder.TryGetValue(changeTarget, out var a) && envOrder.TryGetValue(branch, out var b) && a > b;
async Task<bool?> Gated(Func<Task<bool?>> check)
{
await gate.WaitAsync(dt);
try { return await check(); }
finally { gate.Release(); }
}
async Task<(List<CodeChange> In, List<CodeChange> NotYet, bool Known)> GitStatus(string repo, string target, bool isBranch, DateTime? deployedAt)
async Task<(List<CodeChange> In, List<CodeChange> NotYet, bool Known)> GitStatus(string repo, string target, bool isBranch, DateTime? deployedAt, string? targetBranch = null)
{
var git = changes.Select(x => x.Change).Where(x => x.Kind != "changeset" && string.Equals(x.RepoId, repo, StringComparison.OrdinalIgnoreCase)).ToList();
// Only the newest change of each source -> target pair is asked about: the older ones of the pair are in
Expand All @@ -308,17 +312,20 @@ string Label(string? branch, string def, string stage) =>
: isBranch && g.Kind == "pr" && string.Equals(g.TargetBranch, target, StringComparison.OrdinalIgnoreCase) ? true
// A deployment that finished before a change was merged cannot contain it: no need to ask.
: deployedAt is { } at && g.Date is { } merged && at < merged ? false
: Later(g.TargetBranch, isBranch ? target : targetBranch) ? false
: !newest.Contains(g) ? (bool?)null
: await Contains(repo, commit, target, isBranch)));
: await Contains(repo, commit, target, isBranch, g.Date)));
// The older changes of a pair follow the answer for its newest one.
for (var i = 0; i < git.Count; i++)
if (answers[i] is null && git[i].LandedCommit is not null
&& git.Select((g, k) => (g, k)).FirstOrDefault(x => newest.Contains(x.g) && x.g.Branch == git[i].Branch && x.g.TargetBranch == git[i].TargetBranch && x.g.Kind == git[i].Kind) is { g: not null } n)
answers[i] = answers[n.k];
var inside = git.Where((g, i) => answers[i] == true).ToList();
var known = answers.Any(a => a is not null);
// Not yet: unless a change from the same source branch is in and no older than a day before it (the same work
// merged into each environment by its own PR).
var notYet = git.Where(g => !inside.Contains(g) && !IsPromotion(g)
&& !inside.Any(i => i.Branch is not null && i.Branch == g.Branch && i.Date >= g.Date)).ToList();
&& !inside.Any(i => i.Branch is not null && i.Branch == g.Branch && i.Date >= g.Date?.AddDays(-1))).ToList();
return (inside, notYet, known);
}
static string Names(IEnumerable<CodeChange> list) =>
Expand Down Expand Up @@ -351,7 +358,7 @@ static string Names(IEnumerable<CodeChange> list) =>
{
stageTasks.Add(Task.Run(async () =>
{
var (inside, notYet, _) = await GitStatus(repo, deployedCommit, false, d.When);
var (inside, notYet, _) = await GitStatus(repo, deployedCommit, false, d.When, Code.Short(d.Branch));
if (inside.Count > 0) anyIn = true;
return (string?)(label + (inside.Count > 0
? $"YES - deployed {deployed}: {Names(inside.Where(x => !IsPromotion(x)).DefaultIfEmpty(inside[0]))}" + (notYet.Count > 0 ? $"; not yet: {Names(notYet)}" : "")
Expand Down