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) {