From 2311bfeefaa2313582230cee619497e3eae5dbfb Mon Sep 17 00:00:00 2001
From: WillowDev <216433317+willowdev-code@users.noreply.github.com>
Date: Mon, 26 Oct 2026 11:15:24 +0100
Subject: [PATCH] ADO Lens 0.9.4
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- `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_…)").
---
CHANGELOG.md | 7 +++++++
src/AdoLens/AdoLens.csproj | 2 +-
src/AdoLens/Code.cs | 13 +++++++++++++
src/AdoLens/Trace.cs | 17 +++++++++++++----
4 files changed, 34 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dcf11c8..d23f614 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj
index 9d28833..fecfc88 100644
--- a/src/AdoLens/AdoLens.csproj
+++ b/src/AdoLens/AdoLens.csproj
@@ -23,7 +23,7 @@
ADO Lens for Azure DevOps
Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP
Copyright © 2026 WillowDev
- 0.9.3
+ 0.9.4
diff --git a/src/AdoLens/Code.cs b/src/AdoLens/Code.cs
index 4f37bda..f9212bc 100644
--- a/src/AdoLens/Code.cs
+++ b/src/AdoLens/Code.cs
@@ -189,6 +189,19 @@ public async Task PullRequestAsync(string? repoId, int id, bool with
catch (AdoApiException) { return null; }
}
+ /// Completed PRs from into : "PR 123 (09-25, staging <- feature/x)".
+ public async Task> 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 []; }
+ }
+
/// A build, or null when it is gone (builds are deleted by retention).
public async Task BuildAsync(string project, int id, CancellationToken ct)
{
diff --git a/src/AdoLens/Trace.cs b/src/AdoLens/Trace.cs
index e943865..8f09475 100644
--- a/src/AdoLens/Trace.cs
+++ b/src/AdoLens/Trace.cs
@@ -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.
@@ -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);
}
@@ -376,9 +376,18 @@ static string Names(IEnumerable 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();
+ 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));
}