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)); }