Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion KtsuBuild.Tests/Profile/GitHubApiClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,31 @@
{
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<InvalidOperationException>(() => client.ListOrganizationRepositoriesAsync("ktsu-dev"))

Check warning on line 282 in KtsuBuild.Tests/Profile/GitHubApiClientTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaEb9eCq146S70C71hwC&open=AaEb9eCq146S70C71hwC&pullRequest=234
.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<InvalidOperationException>(() => _client.ListOrganizationRepositoriesAsync("ktsu-dev"))

Check warning on line 293 in KtsuBuild.Tests/Profile/GitHubApiClientTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaEb9eCq146S70C71hwD&open=AaEb9eCq146S70C71hwD&pullRequest=234
.ConfigureAwait(false);

Assert.Contains("page 2", error.Message);
}

[TestMethod]
Expand Down
29 changes: 24 additions & 5 deletions KtsuBuild.Tests/Profile/ProfileGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,16 +204,35 @@
}

[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<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<GitHubRepository>>([]));
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);

Check warning on line 214 in KtsuBuild.Tests/Profile/ProfileGeneratorTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaEb9eIZ146S70C71hwE&open=AaEb9eIZ146S70C71hwE&pullRequest=234

await _generator.GenerateAsync(Options, template, output).ConfigureAwait(false);
await Assert
.ThrowsExactlyAsync<InvalidOperationException>(() => _generator.GenerateAsync(Options, template, output))

Check warning on line 217 in KtsuBuild.Tests/Profile/ProfileGeneratorTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaEb9eIZ146S70C71hwF&open=AaEb9eIZ146S70C71hwF&pullRequest=234
.ConfigureAwait(false);

Assert.IsTrue(File.Exists(output));
Assert.AreEqual("previous README", await File.ReadAllTextAsync(output).ConfigureAwait(false));

Check warning on line 220 in KtsuBuild.Tests/Profile/ProfileGeneratorTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaEb9eIZ146S70C71hwG&open=AaEb9eIZ146S70C71hwG&pullRequest=234
}

[TestMethod]
public async Task GenerateAsync_WithAFailedListing_FailsAndLeavesTheReadmeUntouched()
{
_gitHub.ListOrganizationRepositoriesAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<IReadOnlyList<GitHubRepository>>(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);

Check warning on line 230 in KtsuBuild.Tests/Profile/ProfileGeneratorTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaEb9eIZ146S70C71hwH&open=AaEb9eIZ146S70C71hwH&pullRequest=234

await Assert
.ThrowsExactlyAsync<InvalidOperationException>(() => _generator.GenerateAsync(Options, template, output))

Check warning on line 233 in KtsuBuild.Tests/Profile/ProfileGeneratorTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaEb9eIZ146S70C71hwI&open=AaEb9eIZ146S70C71hwI&pullRequest=234
.ConfigureAwait(false);

Assert.AreEqual("previous README", await File.ReadAllTextAsync(output).ConfigureAwait(false));

Check warning on line 236 in KtsuBuild.Tests/Profile/ProfileGeneratorTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaEb9eIZ146S70C71hwJ&open=AaEb9eIZ146S70C71hwJ&pullRequest=234
}
}
2 changes: 2 additions & 0 deletions KtsuBuild/Abstractions/IGitHubApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public interface IGitHubApiClient
/// <param name="organization">The organization login.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>Every public repository, including archived ones.</returns>
/// <exception cref="InvalidOperationException">Any page of the listing failed. A partial listing is
/// never returned, because it cannot be told apart from a complete one.</exception>
public Task<IReadOnlyList<GitHubRepository>> ListOrganizationRepositoriesAsync(string organization, CancellationToken cancellationToken = default);

/// <summary>
Expand Down
6 changes: 5 additions & 1 deletion KtsuBuild/Profile/GitHubApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,13 @@ public async Task<IReadOnlyList<GitHubRepository>> 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()];
Expand Down
15 changes: 10 additions & 5 deletions KtsuBuild/Profile/ProfileGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ public class ProfileGenerator(OrgProfileService service, IGitHubApiClient gitHub
/// <returns>The repositories that were listed.</returns>
/// <exception cref="FileNotFoundException">The template does not exist. Writing a README without
/// it would silently discard everything the profile page says about the organization.</exception>
/// <exception cref="InvalidOperationException">The template links a repository that is archived or
/// no longer public. Publishing a page that promotes retired work is worse than failing loudly.</exception>
/// <exception cref="InvalidOperationException">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.</exception>
public async Task<IReadOnlyList<RepoFacts>> GenerateAsync(
ProfileOptions options,
string templatePath,
Expand Down Expand Up @@ -76,7 +78,8 @@ public async Task<IReadOnlyList<RepoFacts>> GenerateAsync(
/// <param name="template">The template content.</param>
/// <param name="options">The gathering settings.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="InvalidOperationException">A linked repository is archived or no longer public.</exception>
/// <exception cref="InvalidOperationException">The organization listed no public repositories, or a
/// linked repository is archived or no longer public.</exception>
/// <remarks>
/// 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
Expand All @@ -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<string> retired = TemplateLinks.FindRetired(template, options.Organization, repositories);
Expand Down
Loading