From e8126c3ed0388360326735c94cd49502436e85bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:26:41 +0000 Subject: [PATCH] fix: keep the Azure DevOps project-not-found error instead of clearing it [patch] MakeAzureDevOpsRequestAsync called ClearStatus() after every action that returned normally, so the error UpdateRepositoriesAsync sets when an owner matches no project was wiped straight away. The wrapper now clears the status only when the action did not set one itself, and a later successful request still clears it. Fixes ktsu-dev/BuildMonitor#304 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B6YhFsbKVvV6YZVxUJzexE --- BuildMonitor.Test/AzureDevOpsStatusTests.cs | 56 +++++++++++++++++++++ BuildMonitor/BuildProvider.cs | 10 ++++ BuildMonitor/Providers/AzureDevOps.cs | 13 ++++- 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 BuildMonitor.Test/AzureDevOpsStatusTests.cs diff --git a/BuildMonitor.Test/AzureDevOpsStatusTests.cs b/BuildMonitor.Test/AzureDevOpsStatusTests.cs new file mode 100644 index 0000000..4b1fe08 --- /dev/null +++ b/BuildMonitor.Test/AzureDevOpsStatusTests.cs @@ -0,0 +1,56 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.BuildMonitor.Test; + +using ktsu.Semantics.Strings; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Covers the provider status after an Azure DevOps request. The request wrapper cleared the status +/// after every action that returned normally, so an error the action reported itself, such as an +/// owner that matches no project, was wiped straight away and never reached the status bar +/// (ktsu-dev/BuildMonitor#304). +/// +[TestClass] +public sealed class AzureDevOpsStatusTests +{ + private static readonly OwnerName MissingProject = "Missing".As(); + + [TestMethod] + public async Task AProjectNotFoundErrorSurvivesTheRequestThatReportedIt() + { + // Arrange + AzureDevOps provider = new(); + + // Act + await provider.MakeAzureDevOpsRequestAsync("test/missing", () => + { + provider.ReportProjectNotFound(MissingProject); + return Task.CompletedTask; + }).ConfigureAwait(false); + + // Assert + Assert.AreEqual(ProviderStatus.Error, provider.Status, "The not-found error should still be showing once the request returns"); + Assert.Contains("'Missing' not found", provider.StatusMessage); + } + + [TestMethod] + public async Task ALaterSuccessfulRequestClearsTheError() + { + // Arrange + AzureDevOps provider = new(); + await provider.MakeAzureDevOpsRequestAsync("test/missing", () => + { + provider.ReportProjectNotFound(MissingProject); + return Task.CompletedTask; + }).ConfigureAwait(false); + + // Act + await provider.MakeAzureDevOpsRequestAsync("test/ok", () => Task.CompletedTask).ConfigureAwait(false); + + // Assert + Assert.AreEqual(ProviderStatus.OK, provider.Status); + Assert.AreEqual(string.Empty, provider.StatusMessage); + } +} diff --git a/BuildMonitor/BuildProvider.cs b/BuildMonitor/BuildProvider.cs index b922fdc..dc652b6 100644 --- a/BuildMonitor/BuildProvider.cs +++ b/BuildMonitor/BuildProvider.cs @@ -110,6 +110,15 @@ protected BuildProviderToken Token [JsonIgnore] internal string StatusMessage { get; private set; } = string.Empty; + /// + /// Counts calls to , so a request wrapper can tell whether the work it ran + /// reported a status of its own that a blanket would overwrite. + /// + [JsonIgnore] + internal int StatusSetCount => statusSetCount; + + private int statusSetCount; + /// /// When rate limited, the time when the rate limit resets. /// @@ -482,6 +491,7 @@ protected void SetStatus(ProviderStatus status, string message) Status = status; StatusMessage = message; StatusTimestamp = DateTimeOffset.UtcNow; + _ = Interlocked.Increment(ref statusSetCount); } /// diff --git a/BuildMonitor/Providers/AzureDevOps.cs b/BuildMonitor/Providers/AzureDevOps.cs index 8fbbb95..b302803 100644 --- a/BuildMonitor/Providers/AzureDevOps.cs +++ b/BuildMonitor/Providers/AzureDevOps.cs @@ -238,7 +238,7 @@ await MakeAzureDevOpsRequestAsync($"{Name}/{owner.Name}", async () => if (!foundProject) { Log.Warning($"{Name}: Project '{owner.Name}' not found among {projectCount} projects in organization '{AccountId}'"); - SetStatus(ProviderStatus.Error, $"Project '{owner.Name}' not found in organization '{AccountId}'"); + ReportProjectNotFound(owner.Name); } }).ConfigureAwait(false); } @@ -398,6 +398,9 @@ private static void UpdateRunFromBuild(Run run, Microsoft.TeamFoundation.Build.W BuildMonitor.QueueSaveAppData(); } + internal void ReportProjectNotFound(OwnerName ownerName) => + SetStatus(ProviderStatus.Error, $"Project '{ownerName}' not found in organization '{AccountId}'"); + internal async Task MakeAzureDevOpsRequestAsync(string name, Func action) { await RequestSemaphore.WaitAsync().ConfigureAwait(false); @@ -412,8 +415,14 @@ internal async Task MakeAzureDevOpsRequestAsync(string name, Func action) try { + // Clear only when the action reported no status of its own. A project that matched no + // owner used to be reported and then cleared straight away (ktsu-dev/BuildMonitor#304). + int statusSetCountBefore = StatusSetCount; await BuildMonitor.MakeRequestAsync(name, action).ConfigureAwait(false); - ClearStatus(); + if (StatusSetCount == statusSetCountBefore) + { + ClearStatus(); + } } catch (VssServiceResponseException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.Unauthorized) {