diff --git a/CHANGELOG.md b/CHANGELOG.md index ba05e24..dcf11c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index 1a131a9..9d28833 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.2 + 0.9.3 diff --git a/src/AdoLens/Code.cs b/src/AdoLens/Code.cs index fb48a44..4f37bda 100644 --- a/src/AdoLens/Code.cs +++ b/src/AdoLens/Code.cs @@ -162,8 +162,23 @@ public async Task PullRequestAsync(string? repoId, int id, bool with } /// Whether is in the history of (a commit or branch). - public async Task ContainsAsync(string repoId, string commit, string target, bool targetIsBranch, CancellationToken ct) + public async Task 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" + diff --git a/src/AdoLens/Trace.cs b/src/AdoLens/Trace.cs index b9db80e..e943865 100644 --- a/src/AdoLens/Trace.cs +++ b/src/AdoLens/Trace.cs @@ -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>(); using var gate = new SemaphoreSlim(ParallelChecks); - Task Contains(string repo, string commit, string target, bool isBranch) => - containsCache.GetOrAdd((commit, target, isBranch), _ => Gated(() => code.ContainsAsync(repo, commit, target, isBranch, dt))); + Task 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 Gated(Func> check) { await gate.WaitAsync(dt); try { return await check(); } finally { gate.Release(); } } - async Task<(List In, List NotYet, bool Known)> GitStatus(string repo, string target, bool isBranch, DateTime? deployedAt) + async Task<(List In, List 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 @@ -308,8 +312,9 @@ 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 @@ -317,8 +322,10 @@ string Label(string? branch, string def, string stage) => 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 list) => @@ -351,7 +358,7 @@ static string Names(IEnumerable 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)}" : "")