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.9.4 — 2026-10-26

- `ado_trace`: a branch earlier in the environment flow than the one a change was merged into is not reported as
"NOT merged yet" (a change merged straight into master of a library repository says nothing about its dev branch).
- Before "not yet", one question per source branch: completed PRs from the same source branch into that branch that
are not linked to the item ("merged by PRs not linked to the item: PR 31002 (2021-09-01, staging <- 243623_…)").

## 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
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.3</Version>
<Version>0.9.4</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
13 changes: 13 additions & 0 deletions src/AdoLens/Code.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,19 @@ public async Task<CodeChange> PullRequestAsync(string? repoId, int id, bool with
catch (AdoApiException) { return null; }
}

/// <summary>Completed PRs from <paramref name="source"/> into <paramref name="target"/>: "PR 123 (09-25, staging <- feature/x)".</summary>
public async Task<List<string>> CompletedPullRequestsAsync(string repoId, string source, string target, CancellationToken ct)
{
try
{
var list = await client.GetListAsync($"_apis/git/repositories/{repoId}/pullrequests?searchCriteria.status=completed" +
$"&searchCriteria.sourceRefName={Uri.EscapeDataString("refs/heads/" + source)}&searchCriteria.targetRefName={Uri.EscapeDataString("refs/heads/" + target)}&$top=10", ct);
return list.Select(p => $"PR {(p.TryGetProperty("pullRequestId", out var id) ? id.GetInt32() : 0)} " +
$"({Date(Json.Str(p, "closedDate"))?.ToLocalTime():yyyy-MM-dd}, {target} <- {source})").ToList();
}
catch (AdoApiException) { return []; }
}

/// <summary>A build, or null when it is gone (builds are deleted by retention).</summary>
public async Task<JsonElement?> BuildAsync(string project, int id, CancellationToken ct)
{
Expand Down
17 changes: 13 additions & 4 deletions src/AdoLens/Trace.cs
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ bool Later(string? changeTarget, string? branch) =>
: 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
: Later(g.TargetBranch, isBranch ? target : targetBranch) ? (bool?)null
: !newest.Contains(g) ? (bool?)null
: await Contains(repo, commit, target, isBranch, g.Date)));
// The older changes of a pair follow the answer for its newest one.
Expand All @@ -324,7 +324,7 @@ bool Later(string? changeTarget, string? branch) =>
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)
var notYet = git.Where(g => !inside.Contains(g) && !IsPromotion(g) && !Later(g.TargetBranch, isBranch ? target : targetBranch)
&& !inside.Any(i => i.Branch is not null && i.Branch == g.Branch && i.Date >= g.Date?.AddDays(-1))).ToList();
return (inside, notYet, known);
}
Expand Down Expand Up @@ -376,9 +376,18 @@ static string Names(IEnumerable<CodeChange> list) =>
{
var (inside, notYet, known) = await GitStatus(group.Key, branch, true, null);
if (!known) return null;
if (inside.Count > 0) anyIn = true;
// Work often reaches a branch by its own PR from the same source branch that nobody linked to the
// item: one cheap question per source branch before saying "not yet".
var unlinked = new List<string>();
foreach (var source in notYet.Select(n => n.Branch).Where(b => b is not null).Distinct(StringComparer.OrdinalIgnoreCase))
unlinked.AddRange(await code.CompletedPullRequestsAsync(group.Key, source!, branch, dt));
if (unlinked.Count > 0)
notYet = notYet.Where(n => !unlinked.Any(u => u.EndsWith($"<- {n.Branch})", StringComparison.OrdinalIgnoreCase))).ToList();
if (inside.Count > 0 || unlinked.Count > 0) anyIn = true;
var also = unlinked.Count > 0 ? $"merged by PRs not linked to the item: {string.Join(", ", unlinked.Distinct())}" : null;
return (string?)($"- {envLabel} = branch {branch} ({repoName}): " + (inside.Count > 0
? $"merged: {Names(inside.Where(x => !IsPromotion(x)).DefaultIfEmpty(inside[0]))}" + (notYet.Count > 0 ? $"; not yet: {Names(notYet)}" : "")
? $"merged: {Names(inside.Where(x => !IsPromotion(x)).DefaultIfEmpty(inside[0]))}" + (also is null ? "" : "; " + also) + (notYet.Count > 0 ? $"; not yet: {Names(notYet)}" : "")
: also is not null ? also + (notYet.Count > 0 ? $"; not yet: {Names(notYet)}" : "")
: "NOT merged yet"));
}, dt));
}
Expand Down