From 9e6bae1381edbea6a5960b0b0a83c5bb41b30b0b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:32:51 +0000 Subject: [PATCH] Fail profile readme instead of writing it without a table when the org listing fails [patch] A failed /orgs/{org}/repos page used to end the listing early, so a transient 5xx or rate limit on page 1 produced an empty list, and the generator wrote and the daily job committed a profile README with no repository table. A failure on a later page produced a truncated one. ListOrganizationRepositoriesAsync now throws when any page fails, and GenerateAsync refuses an empty listing before writing anything, so the command exits 1 and the existing README stays as it was. Fixes #178 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012hoj76iUqCE6J1Wp5Dgmbm --- .../Profile/GitHubApiClientTests.cs | 26 ++++++++++++++++- .../Profile/ProfileGeneratorTests.cs | 29 +++++++++++++++---- KtsuBuild/Abstractions/IGitHubApiClient.cs | 2 ++ KtsuBuild/Profile/GitHubApiClient.cs | 6 +++- KtsuBuild/Profile/ProfileGenerator.cs | 15 ++++++---- 5 files changed, 66 insertions(+), 12 deletions(-) diff --git a/KtsuBuild.Tests/Profile/GitHubApiClientTests.cs b/KtsuBuild.Tests/Profile/GitHubApiClientTests.cs index 1c3b707..c48e752 100644 --- a/KtsuBuild.Tests/Profile/GitHubApiClientTests.cs +++ b/KtsuBuild.Tests/Profile/GitHubApiClientTests.cs @@ -269,7 +269,31 @@ public async Task GetJson_WithUnparseableResponse_DegradesToEmpty() { RespondWith("not json at all"); - Assert.IsEmpty(await _client.ListOrganizationRepositoriesAsync("ktsu-dev").ConfigureAwait(false)); + Assert.IsEmpty(await _client.ListReleasesAsync("ktsu-dev", "Extensions").ConfigureAwait(false)); + } + + [TestMethod] + public async Task ListOrganizationRepositoriesAsync_WithAFailedFirstPage_Throws() + { + // An empty listing would publish the profile README with no table, so it must not degrade. + (GitHubApiClient client, _) = ClientAnswering(Failure("gh: Server Error (HTTP 502)")); + + await Assert + .ThrowsExactlyAsync(() => client.ListOrganizationRepositoriesAsync("ktsu-dev")) + .ConfigureAwait(false); + } + + [TestMethod] + public async Task ListOrganizationRepositoriesAsync_WithAFailedLaterPage_ThrowsRatherThanReturningAPartialList() + { + string fullPage = $"[{string.Join(",", Enumerable.Range(0, 100).Select(static i => $"{{\"name\":\"Repo{i.ToString(CultureInfo.InvariantCulture)}\"}}"))}]"; + RespondByPage(page => page == 1 ? fullPage : "not json at all"); + + InvalidOperationException error = await Assert + .ThrowsExactlyAsync(() => _client.ListOrganizationRepositoriesAsync("ktsu-dev")) + .ConfigureAwait(false); + + Assert.Contains("page 2", error.Message); } [TestMethod] diff --git a/KtsuBuild.Tests/Profile/ProfileGeneratorTests.cs b/KtsuBuild.Tests/Profile/ProfileGeneratorTests.cs index 63ebdf4..fc07fe1 100644 --- a/KtsuBuild.Tests/Profile/ProfileGeneratorTests.cs +++ b/KtsuBuild.Tests/Profile/ProfileGeneratorTests.cs @@ -204,16 +204,35 @@ public async Task GenerateAsync_WithHealthyLinks_Writes() } [TestMethod] - public async Task GenerateAsync_WithAnEmptyListing_WritesRatherThanBlockingOnAFailedLookup() + public async Task GenerateAsync_WithAnEmptyListing_FailsAndLeavesTheReadmeUntouched() { - // A listing that could not be read is a transient failure, not evidence of a bad template. + // Writing it would publish the profile page with no repository table at all. _gitHub.ListOrganizationRepositoriesAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult>([])); - string template = WriteTemplate("- [Whatever](https://github.com/ktsu-dev/Whatever)\n## Project Status\n"); + string template = WriteTemplate("## Project Status\n"); string output = Path.Combine(_tempDir, "README.md"); + await File.WriteAllTextAsync(output, "previous README").ConfigureAwait(false); - await _generator.GenerateAsync(Options, template, output).ConfigureAwait(false); + await Assert + .ThrowsExactlyAsync(() => _generator.GenerateAsync(Options, template, output)) + .ConfigureAwait(false); - Assert.IsTrue(File.Exists(output)); + Assert.AreEqual("previous README", await File.ReadAllTextAsync(output).ConfigureAwait(false)); + } + + [TestMethod] + public async Task GenerateAsync_WithAFailedListing_FailsAndLeavesTheReadmeUntouched() + { + _gitHub.ListOrganizationRepositoriesAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromException>(new InvalidOperationException("listing failed"))); + string template = WriteTemplate("## Project Status\n"); + string output = Path.Combine(_tempDir, "README.md"); + await File.WriteAllTextAsync(output, "previous README").ConfigureAwait(false); + + await Assert + .ThrowsExactlyAsync(() => _generator.GenerateAsync(Options, template, output)) + .ConfigureAwait(false); + + Assert.AreEqual("previous README", await File.ReadAllTextAsync(output).ConfigureAwait(false)); } } diff --git a/KtsuBuild/Abstractions/IGitHubApiClient.cs b/KtsuBuild/Abstractions/IGitHubApiClient.cs index b293ca7..afc2c33 100644 --- a/KtsuBuild/Abstractions/IGitHubApiClient.cs +++ b/KtsuBuild/Abstractions/IGitHubApiClient.cs @@ -17,6 +17,8 @@ public interface IGitHubApiClient /// The organization login. /// A cancellation token. /// Every public repository, including archived ones. + /// Any page of the listing failed. A partial listing is + /// never returned, because it cannot be told apart from a complete one. public Task> ListOrganizationRepositoriesAsync(string organization, CancellationToken cancellationToken = default); /// diff --git a/KtsuBuild/Profile/GitHubApiClient.cs b/KtsuBuild/Profile/GitHubApiClient.cs index 672ca69..d4ddf0b 100644 --- a/KtsuBuild/Profile/GitHubApiClient.cs +++ b/KtsuBuild/Profile/GitHubApiClient.cs @@ -37,9 +37,13 @@ public async Task> ListOrganizationRepositoriesA { string endpoint = $"/orgs/{organization}/repos?type=public&sort=full_name&direction=asc&page={page.ToString(CultureInfo.InvariantCulture)}&per_page={PageSize.ToString(CultureInfo.InvariantCulture)}"; JsonElement? response = await GetJsonAsync(endpoint, cancellationToken).ConfigureAwait(false); + + // Every other call here degrades to no data, but this one cannot: a partial or empty listing + // would publish a profile README with repositories missing, or with no table at all. if (response is not { ValueKind: JsonValueKind.Array }) { - break; + throw new InvalidOperationException( + $"Could not list the repositories in {organization}: page {page.ToString(CultureInfo.InvariantCulture)} of the listing failed or was unreadable."); } JsonElement[] items = [.. response.Value.EnumerateArray()]; diff --git a/KtsuBuild/Profile/ProfileGenerator.cs b/KtsuBuild/Profile/ProfileGenerator.cs index ba1c646..0d590a7 100644 --- a/KtsuBuild/Profile/ProfileGenerator.cs +++ b/KtsuBuild/Profile/ProfileGenerator.cs @@ -31,8 +31,10 @@ public class ProfileGenerator(OrgProfileService service, IGitHubApiClient gitHub /// The repositories that were listed. /// The template does not exist. Writing a README without /// it would silently discard everything the profile page says about the organization. - /// The template links a repository that is archived or - /// no longer public. Publishing a page that promotes retired work is worse than failing loudly. + /// The organization could not be listed, or listed no + /// public repositories, so the README would be written without its table. Or the template links a + /// repository that is archived or no longer public. Publishing a page that promotes retired work is + /// worse than failing loudly. public async Task> GenerateAsync( ProfileOptions options, string templatePath, @@ -76,7 +78,8 @@ public async Task> GenerateAsync( /// The template content. /// The gathering settings. /// A cancellation token. - /// A linked repository is archived or no longer public. + /// The organization listed no public repositories, or a + /// linked repository is archived or no longer public. /// /// This costs one listing request that the gather then makes again. That duplication buys a clean /// split, where the generator owns the template and the service owns the facts, for about half a @@ -88,10 +91,12 @@ private async Task CheckTemplateLinksAsync(string template, ProfileOptions optio .ListOrganizationRepositoriesAsync(options.Organization, cancellationToken) .ConfigureAwait(false); + // An organization with no public repositories has no profile table to show, so an empty listing + // is far more likely an outage than the truth. Writing it would wipe the table from the page. if (repositories.Count == 0) { - logger.WriteWarning(" Could not list the organization, so the template's links went unchecked"); - return; + throw new InvalidOperationException( + $"{options.Organization} listed no public repositories, so the README was not written."); } IReadOnlyList retired = TemplateLinks.FindRetired(template, options.Organization, repositories);