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