From 4adefa07f32a918a7097b6579435bf807b614e38 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Wed, 21 Oct 2026 11:23:06 +0200 Subject: [PATCH 1/8] Add GitHubApiClient, a GitHub REST client over HttpClient GitHubServerAdapter reaches the GitHub REST API through Octokit, which fixes the API at api.github.com, so a release on GitHub Enterprise Server cannot reach its API. GitHubApiClient takes the API base URL from the host name of the repository, and sends every request through an HttpMessageHandler that a test can replace. An asset upload goes to the upload_url of its release, as in Octokit. It carries its label in the query string, which saves a second request. When no tag matches a name exactly, git/refs/tags/ answers with every tag whose name starts with it, so TagExistsAsync asks for git/ref/tags/. StubHttpMessageHandler answers by method and URL, and records the headers and the body of each request, so that GitHubApiClientTests checks what GitHubApiClient sends. Co-Authored-By: Claude Opus 5.5 --- .../Internal/GitHub/GitHubApiClient.cs | 410 ++++++++++++++++++ .../Internal/GitHub/GitHubErrorBody.cs | 13 + .../Internal/GitHub/GitHubJsonContext.cs | 25 ++ .../Internal/GitHub/GitHubNewRelease.cs | 25 ++ .../Internal/GitHub/GitHubRelease.cs | 19 + .../Internal/GitHub/GitHubReleaseNotes.cs | 13 + .../GitHub/GitHubReleaseNotesRequest.cs | 16 + .../Internal/GitHub/GitHubReleaseUpdate.cs | 28 ++ .../Internal/GitHub/GitHubRepository.cs | 13 + .../DotNetReleaseIndexTests.cs | 9 +- .../GitHubApiClientTests.cs | 341 +++++++++++++++ tests/Buildvana.Tool.Tests/StubHttpAnswer.cs | 22 + .../StubHttpMessageHandler.cs | 89 +++- tests/Buildvana.Tool.Tests/StubHttpRequest.cs | 16 + 14 files changed, 1023 insertions(+), 16 deletions(-) create mode 100644 src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs create mode 100644 src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubErrorBody.cs create mode 100644 src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubJsonContext.cs create mode 100644 src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubNewRelease.cs create mode 100644 src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRelease.cs create mode 100644 src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseNotes.cs create mode 100644 src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseNotesRequest.cs create mode 100644 src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseUpdate.cs create mode 100644 src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRepository.cs create mode 100644 tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs create mode 100644 tests/Buildvana.Tool.Tests/StubHttpAnswer.cs create mode 100644 tests/Buildvana.Tool.Tests/StubHttpRequest.cs diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs new file mode 100644 index 00000000..e4eaa3ee --- /dev/null +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs @@ -0,0 +1,410 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Buildvana.Core; +using Buildvana.Tool.Utilities; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; + +/// +/// Performs the operations of the GitHub REST API that bv needs. +/// +/// +/// The API of github.com is at https://api.github.com. Any other host is taken for a +/// GitHub Enterprise Server, whose API is at https://<host>/api/v3. +/// An asset upload goes to the address that the release states in its upload_url member, so this +/// type derives no upload host. +/// An answer outside the 2xx range fails the build, with the status code and the message of the answer. +/// The one exception is a tag lookup, where a 404 means that the tag does not exist. +/// +internal sealed class GitHubApiClient : IDisposable +{ + /// + /// The version of the REST API that every request asks for. + /// + /// + /// GitHub Enterprise Server 3.19 and 3.20 support this version alone, and answer a request for a newer + /// one with 410 Gone. Version 2026-03-10 changes none of the members or endpoints that this type uses. + /// Move to it once GitHub Enterprise Server 3.20 closes down on 2027-03-17, and before this version + /// closes down on 2028-03-10. + /// + public const string ApiVersion = "2022-11-28"; + + private const string GitHubComHostName = "github.com"; + private const string GitHubMediaType = "application/vnd.github+json"; + + private readonly HttpClient _http; + + /// + /// Initializes a new instance of the class. + /// + /// The host name of the repository, such as github.com. + /// The token that authenticates every request. + /// The handler that sends the requests. It is disposed together with this + /// instance. + public GitHubApiClient(string hostName, string token, HttpMessageHandler handler) + { + Guard.IsNotNullOrEmpty(hostName); + Guard.IsNotNullOrEmpty(token); + Guard.IsNotNull(handler); + + BaseUrl = GetBaseUrl(hostName); + _http = new HttpClient(handler) { BaseAddress = BaseUrl }; + var headers = _http.DefaultRequestHeaders; + headers.Accept.Add(new MediaTypeWithQualityHeaderValue(GitHubMediaType)); + headers.Add("X-GitHub-Api-Version", ApiVersion); + headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + headers.UserAgent.Add(new ProductInfoHeaderValue("bv", OwnVersion.Value.ToNormalizedString())); + } + + /// + /// Gets the base URL of the API, with a trailing slash. + /// + public Uri BaseUrl { get; } + + /// + /// Asynchronously gets a repository. + /// + /// The owner of the repository. + /// The name of the repository. + /// A token that cancels the operation. + /// A whose result is the repository. + public Task GetRepositoryAsync( + string owner, + string name, + CancellationToken cancellationToken = default) + => SendAsync(HttpMethod.Get, RepositoryPath(owner, name), null, GitHubJsonContext.Default.GitHubRepository, cancellationToken); + + /// + /// Asynchronously creates a release. + /// + /// The owner of the repository. + /// The name of the repository. + /// The members of the new release. + /// A token that cancels the operation. + /// A whose result is the created release. + public Task CreateReleaseAsync( + string owner, + string name, + GitHubNewRelease release, + CancellationToken cancellationToken = default) + { + Guard.IsNotNull(release); + return SendAsync( + HttpMethod.Post, + RepositoryPath(owner, name) + "/releases", + JsonBody(release, GitHubJsonContext.Default.GitHubNewRelease), + GitHubJsonContext.Default.GitHubRelease, + cancellationToken); + } + + /// + /// Asynchronously generates the notes of a release. + /// + /// The owner of the repository. + /// The name of the repository. + /// The members of the request. + /// A token that cancels the operation. + /// A whose result is the generated notes. + public Task GenerateReleaseNotesAsync( + string owner, + string name, + GitHubReleaseNotesRequest request, + CancellationToken cancellationToken = default) + { + Guard.IsNotNull(request); + return SendAsync( + HttpMethod.Post, + RepositoryPath(owner, name) + "/releases/generate-notes", + JsonBody(request, GitHubJsonContext.Default.GitHubReleaseNotesRequest), + GitHubJsonContext.Default.GitHubReleaseNotes, + cancellationToken); + } + + /// + /// Asynchronously edits a release. + /// + /// The owner of the repository. + /// The name of the repository. + /// The identifier of the release. + /// The new members of the release. + /// A token that cancels the operation. + /// A representing the ongoing operation. + public Task UpdateReleaseAsync( + string owner, + string name, + long releaseId, + GitHubReleaseUpdate update, + CancellationToken cancellationToken = default) + { + Guard.IsNotNull(update); + return SendAsync( + HttpMethod.Patch, + ReleasePath(owner, name, releaseId), + JsonBody(update, GitHubJsonContext.Default.GitHubReleaseUpdate), + cancellationToken); + } + + /// + /// Asynchronously deletes a release. + /// + /// The owner of the repository. + /// The name of the repository. + /// The identifier of the release. + /// A token that cancels the operation. + /// A representing the ongoing operation. + public Task DeleteReleaseAsync( + string owner, + string name, + long releaseId, + CancellationToken cancellationToken = default) + => SendAsync(HttpMethod.Delete, ReleasePath(owner, name, releaseId), null, cancellationToken); + + /// + /// Asynchronously gets a value indicating whether a tag exists. + /// + /// The owner of the repository. + /// The name of the repository. + /// The name of the tag. + /// A token that cancels the operation. + /// A whose result is when the tag exists, and + /// when it does not. + /// + /// The lookup asks for git/ref/tags/<tag>, which matches the tag name exactly. The + /// git/refs/tags/<tag> form would answer with every tag whose name starts with + /// when no tag matches exactly. + /// + public async Task TagExistsAsync( + string owner, + string name, + string tagName, + CancellationToken cancellationToken = default) + { + using var request = new HttpRequestMessage(HttpMethod.Get, RepositoryPath(owner, name) + "/git/ref/tags/" + EscapeRefName(tagName)); + using var response = await SendCoreAsync(request, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + await EnsureSuccessAsync(request, response, cancellationToken).ConfigureAwait(false); + return true; + } + + /// + /// Asynchronously deletes a tag. + /// + /// The owner of the repository. + /// The name of the repository. + /// The name of the tag. + /// A token that cancels the operation. + /// A representing the ongoing operation. + public Task DeleteTagAsync(string owner, string name, string tagName, CancellationToken cancellationToken = default) + => SendAsync(HttpMethod.Delete, RepositoryPath(owner, name) + "/git/refs/tags/" + EscapeRefName(tagName), null, cancellationToken); + + /// + /// Asynchronously uploads an asset of a release. + /// + /// The release. + /// The file name of the asset. + /// The MIME type of the asset. + /// The label of the asset, or for an asset without a label. + /// The contents of the asset. The stream is disposed together with the request. + /// A token that cancels the operation. + /// A representing the ongoing operation. + public Task UploadReleaseAssetAsync( + GitHubRelease release, + string fileName, + string contentType, + string? label, + Stream contents, + CancellationToken cancellationToken = default) + { + Guard.IsNotNull(release); + Guard.IsNotNullOrEmpty(fileName); + Guard.IsNotNullOrEmpty(contentType); + Guard.IsNotNull(contents); + + var url = GetUploadUrl(release, fileName, label); + BuildFailedException.ThrowIfNot( + MediaTypeHeaderValue.TryParse(contentType, out var mediaType), + $"'{contentType}' is not a valid MIME type for the release asset {fileName}."); + + var content = new StreamContent(contents); + content.Headers.ContentType = mediaType; + return SendAsync(HttpMethod.Post, url.AbsoluteUri, content, cancellationToken); + } + + /// + public void Dispose() => _http.Dispose(); + + private static Uri GetBaseUrl(string hostName) + => string.Equals(hostName, GitHubComHostName, StringComparison.OrdinalIgnoreCase) + ? new Uri("https://api.github.com/") + : new Uri($"https://{hostName}/api/v3/"); + + private static string ReleasePath(string owner, string name, long releaseId) + => RepositoryPath(owner, name) + "/releases/" + releaseId.ToString(CultureInfo.InvariantCulture); + + private static string RepositoryPath(string owner, string name) + { + Guard.IsNotNullOrEmpty(owner); + Guard.IsNotNullOrEmpty(name); + return $"repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(name)}"; + } + + // A tag name may hold slashes, and the API expects them as path separators. Every other character is + // escaped within its segment. + private static string EscapeRefName(string tagName) + { + Guard.IsNotNullOrEmpty(tagName); + return string.Join('/', tagName.Split('/').Select(Uri.EscapeDataString)); + } + + // The upload_url member is a URI template, such as ".../releases/1/assets{?name,label}". The template + // expression is dropped, and the query is written here. + private static Uri GetUploadUrl(GitHubRelease release, string fileName, string? label) + { + var template = release.UploadUrl; + var expressionStart = template.IndexOf('{', StringComparison.Ordinal); + var address = expressionStart < 0 ? template : template[..expressionStart]; + var query = "?name=" + Uri.EscapeDataString(fileName); + if (!string.IsNullOrEmpty(label)) + { + query += "&label=" + Uri.EscapeDataString(label); + } + + BuildFailedException.ThrowIfNot( + Uri.TryCreate(address + query, UriKind.Absolute, out var url), + $"The upload URL of release {release.Id}, '{template}', is not an absolute URL."); + + return url; + } + + private static StringContent JsonBody(T value, JsonTypeInfo typeInfo) + => new(JsonSerializer.Serialize(value, typeInfo), Encoding.UTF8, "application/json"); + + private static async Task EnsureSuccessAsync( + HttpRequestMessage request, + HttpResponseMessage response, + CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + { + return; + } + + var message = await ReadErrorMessageAsync(response, cancellationToken).ConfigureAwait(false); + var status = string.Create(CultureInfo.InvariantCulture, $"{(int)response.StatusCode} {response.ReasonPhrase}"); + throw new BuildFailedException(message is null + ? $"The GitHub API answered {Describe(request)} with {status}." + : $"The GitHub API answered {Describe(request)} with {status}: {message}"); + } + + // A GitHub error answer is JSON with a "message" member. A proxy in front of a server may answer with + // anything else, and then the status code is the whole report. + private static async Task ReadErrorMessageAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + try + { + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + var body = await JsonSerializer.DeserializeAsync(stream, GitHubJsonContext.Default.GitHubErrorBody, cancellationToken) + .ConfigureAwait(false); + return body?.Message is { Length: > 0 } message ? message : null; + } + } + catch (JsonException) + { + return null; + } + } + + // The client buffers every answer before SendAsync returns, so reading the body here meets no network. + private static async Task ReadAsync( + HttpRequestMessage request, + HttpResponseMessage response, + JsonTypeInfo typeInfo, + CancellationToken cancellationToken) + where T : class + { + T? result; + try + { + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + result = await JsonSerializer.DeserializeAsync(stream, typeInfo, cancellationToken).ConfigureAwait(false); + } + } + catch (JsonException exception) + { + throw new BuildFailedException( + $"The GitHub API answered {Describe(request)} with something bv cannot read: {exception.Message}", + exception); + } + + return result ?? throw new BuildFailedException($"The GitHub API answered {Describe(request)} with null."); + } + + private static string Describe(HttpRequestMessage request) => $"{request.Method} {request.RequestUri?.AbsoluteUri}"; + + private async Task SendAsync( + HttpMethod method, + string url, + HttpContent? content, + JsonTypeInfo typeInfo, + CancellationToken cancellationToken) + where T : class + { + using var request = new HttpRequestMessage(method, url) { Content = content }; + using var response = await SendCoreAsync(request, cancellationToken).ConfigureAwait(false); + await EnsureSuccessAsync(request, response, cancellationToken).ConfigureAwait(false); + return await ReadAsync(request, response, typeInfo, cancellationToken).ConfigureAwait(false); + } + + private async Task SendAsync( + HttpMethod method, + string url, + HttpContent? content, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, url) { Content = content }; + using var response = await SendCoreAsync(request, cancellationToken).ConfigureAwait(false); + await EnsureSuccessAsync(request, response, cancellationToken).ConfigureAwait(false); + } + + private async Task SendCoreAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + try + { + return await _http.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException exception) + { + throw new BuildFailedException($"The GitHub API could not be reached for {Describe(request)}: {exception.Message}", exception); + } + catch (TaskCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new BuildFailedException($"The GitHub API did not answer {Describe(request)} in time.", exception); + } + } +} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubErrorBody.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubErrorBody.cs new file mode 100644 index 00000000..aeaa8138 --- /dev/null +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubErrorBody.cs @@ -0,0 +1,13 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; + +/// +/// The members of a GitHub API error answer that bv reads. +/// +internal sealed record GitHubErrorBody +{ + /// Gets the error message, or when the answer states none. + public string? Message { get; init; } +} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubJsonContext.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubJsonContext.cs new file mode 100644 index 00000000..33fefddb --- /dev/null +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubJsonContext.cs @@ -0,0 +1,25 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Text.Json.Serialization; + +namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; + +/// +/// The source-generated serializer context for the requests and answers of the GitHub REST API. +/// +/// +/// Reading ignores unknown members, because every GitHub answer carries many more members than +/// bv reads. A required member that is missing fails the read. +/// +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + RespectNullableAnnotations = true)] +[JsonSerializable(typeof(GitHubRepository))] +[JsonSerializable(typeof(GitHubRelease))] +[JsonSerializable(typeof(GitHubNewRelease))] +[JsonSerializable(typeof(GitHubReleaseUpdate))] +[JsonSerializable(typeof(GitHubReleaseNotesRequest))] +[JsonSerializable(typeof(GitHubReleaseNotes))] +[JsonSerializable(typeof(GitHubErrorBody))] +internal sealed partial class GitHubJsonContext : JsonSerializerContext; diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubNewRelease.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubNewRelease.cs new file mode 100644 index 00000000..d1f77342 --- /dev/null +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubNewRelease.cs @@ -0,0 +1,25 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; + +/// +/// The members bv sends to create a GitHub release. +/// +internal sealed record GitHubNewRelease +{ + /// Gets the name of the tag of the release. + public required string TagName { get; init; } + + /// Gets the name of the release. + public required string Name { get; init; } + + /// Gets the branch or commit SHA that the tag is created from, when the tag does not exist. + public required string TargetCommitish { get; init; } + + /// Gets a value indicating whether the release is a prerelease. + public required bool Prerelease { get; init; } + + /// Gets a value indicating whether the release is a draft. + public required bool Draft { get; init; } +} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRelease.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRelease.cs new file mode 100644 index 00000000..c7756e03 --- /dev/null +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRelease.cs @@ -0,0 +1,19 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; + +/// +/// The members of a GitHub release that bv reads. +/// +internal sealed record GitHubRelease +{ + /// Gets the identifier of the release. + public required long Id { get; init; } + + /// + /// Gets the URI template that asset uploads go to, such as + /// https://uploads.github.com/repos/o/r/releases/1/assets{?name,label}. + /// + public required string UploadUrl { get; init; } +} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseNotes.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseNotes.cs new file mode 100644 index 00000000..6df2e7df --- /dev/null +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseNotes.cs @@ -0,0 +1,13 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; + +/// +/// The members of the release notes GitHub generates that bv reads. +/// +internal sealed record GitHubReleaseNotes +{ + /// Gets the generated notes, in Markdown. + public required string Body { get; init; } +} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseNotesRequest.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseNotesRequest.cs new file mode 100644 index 00000000..7703b339 --- /dev/null +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseNotesRequest.cs @@ -0,0 +1,16 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; + +/// +/// The members bv sends to have GitHub generate the notes of a release. +/// +internal sealed record GitHubReleaseNotesRequest +{ + /// Gets the name of the tag of the release. + public required string TagName { get; init; } + + /// Gets the branch or commit SHA that the tag is created from, when the tag does not exist. + public required string TargetCommitish { get; init; } +} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseUpdate.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseUpdate.cs new file mode 100644 index 00000000..2cc85fe0 --- /dev/null +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubReleaseUpdate.cs @@ -0,0 +1,28 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; + +/// +/// The members bv sends to edit a GitHub release. +/// +internal sealed record GitHubReleaseUpdate +{ + /// Gets the name of the tag of the release. + public required string TagName { get; init; } + + /// Gets the name of the release. + public required string Name { get; init; } + + /// Gets the branch or commit SHA that the tag is created from, when the tag does not exist. + public required string TargetCommitish { get; init; } + + /// Gets the description of the release, in Markdown. + public required string Body { get; init; } + + /// Gets a value indicating whether the release is a prerelease. + public required bool Prerelease { get; init; } + + /// Gets a value indicating whether the release is a draft. + public required bool Draft { get; init; } +} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRepository.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRepository.cs new file mode 100644 index 00000000..fb2f038b --- /dev/null +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubRepository.cs @@ -0,0 +1,13 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; + +/// +/// The members of a GitHub repository that bv reads. +/// +internal sealed record GitHubRepository +{ + /// Gets a value indicating whether the repository is private. + public required bool Private { get; init; } +} diff --git a/tests/Buildvana.Tool.Tests/DotNetReleaseIndexTests.cs b/tests/Buildvana.Tool.Tests/DotNetReleaseIndexTests.cs index cb2d3eea..b3f6c2e4 100644 --- a/tests/Buildvana.Tool.Tests/DotNetReleaseIndexTests.cs +++ b/tests/Buildvana.Tool.Tests/DotNetReleaseIndexTests.cs @@ -85,7 +85,7 @@ public async Task GetReleasesAsync_AsksTheOfficialIndexFirst() var handler = NewHandler(); using var index = new DotNetReleaseIndex(handler); _ = await index.GetReleasesAsync(NuGetVersion.Parse("10.0.100")).ConfigureAwait(false); - await Assert.That(handler.Requests[0]).IsEqualTo(DotNetReleaseIndex.IndexUrl); + await Assert.That(handler.Requests[0].Url).IsEqualTo(DotNetReleaseIndex.IndexUrl); } [Test] @@ -94,9 +94,10 @@ public async Task GetReleasesAsync_ReadsTheChannelOfThePinAndEveryNewerOne() var handler = NewHandler(); using var index = new DotNetReleaseIndex(handler); _ = await index.GetReleasesAsync(NuGetVersion.Parse("10.0.100")).ConfigureAwait(false); - await Assert.That(handler.Requests).Contains(ChannelUrl10); - await Assert.That(handler.Requests).Contains(ChannelUrl11); - await Assert.That(handler.Requests).DoesNotContain(ChannelUrl9); + var urls = handler.Requests.Select(static request => request.Url).ToList(); + await Assert.That(urls).Contains(ChannelUrl10); + await Assert.That(urls).Contains(ChannelUrl11); + await Assert.That(urls).DoesNotContain(ChannelUrl9); } [Test] diff --git a/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs b/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs new file mode 100644 index 00000000..3c8a4733 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs @@ -0,0 +1,341 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Net; +using System.Text; +using Buildvana.Core; +using Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; +using Buildvana.Tool.Utilities; + +// The answers are canned here: what the client is judged on is what it sends and what it makes of the shape +// GitHub documents, not whether the network is up. +internal sealed class GitHubApiClientTests +{ + private const string Owner = "Tenacom"; + private const string Repo = "Buildvana"; + private const string Token = "secret-token"; + private const string ApiRepositoryUrl = "https://api.github.com/repos/Tenacom/Buildvana"; + private const string UploadUrl = "https://uploads.github.com/repos/Tenacom/Buildvana/releases/42/assets"; + + private static readonly GitHubRelease Release = new() { Id = 42, UploadUrl = UploadUrl + "{?name,label}" }; + + private static readonly GitHubNewRelease NewRelease = new() { + TagName = "1.0.0", + Name = "1.0.0 [provisional]", + TargetCommitish = "main", + Prerelease = true, + Draft = true, + }; + + private static readonly GitHubReleaseUpdate ReleaseUpdate = new() { + TagName = "1.0.0", + Name = "1.0.0", + TargetCommitish = "abc123", + Body = "Text", + Prerelease = true, + Draft = false, + }; + + private static readonly GitHubReleaseNotesRequest NotesRequest = new() { TagName = "1.0.0", TargetCommitish = "abc123" }; + + [Test] + public async Task BaseUrl_ForGitHubCom_IsApiGitHubCom() + { + using var client = new GitHubApiClient("github.com", Token, Answering()); + await Assert.That(client.BaseUrl.AbsoluteUri).IsEqualTo("https://api.github.com/"); + } + + [Test] + public async Task BaseUrl_ForAnotherHost_IsApiV3OfTheHost() + { + var handler = Answering((HttpMethod.Get, "https://github.example.com/api/v3/repos/Tenacom/Buildvana"), """{"private":false}"""); + using var client = new GitHubApiClient("github.example.com", Token, handler); + _ = await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false); + await Assert.That(client.BaseUrl.AbsoluteUri).IsEqualTo("https://github.example.com/api/v3/"); + await Assert.That(handler.Requests[0].Url).IsEqualTo("https://github.example.com/api/v3/repos/Tenacom/Buildvana"); + } + + [Test] + [Arguments("", Token)] + [Arguments("github.com", "")] + public async Task Constructor_RejectsEmptyArguments(string hostName, string token) + { + await Assert.That(() => new GitHubApiClient(hostName, token, Answering())).Throws(); + } + + [Test] + public async Task EveryRequest_CarriesTheApiHeaders() + { + var handler = Answering((HttpMethod.Get, ApiRepositoryUrl), """{"private":false}"""); + using var client = NewClient(handler); + _ = await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false); + var headers = handler.Requests[0].Headers; + await Assert.That(headers["Accept"]).IsEqualTo("application/vnd.github+json"); + await Assert.That(headers["X-GitHub-Api-Version"]).IsEqualTo("2022-11-28"); + await Assert.That(headers["Authorization"]).IsEqualTo("Bearer secret-token"); + await Assert.That(headers["User-Agent"]).IsEqualTo($"bv/{OwnVersion.Value.ToNormalizedString()}"); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task GetRepositoryAsync_ReadsWhetherTheRepositoryIsPrivate(bool isPrivate) + { + var handler = Answering((HttpMethod.Get, ApiRepositoryUrl), isPrivate ? """{"private":true}""" : """{"private":false}"""); + using var client = NewClient(handler); + var repository = await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false); + await Assert.That(repository.Private).IsEqualTo(isPrivate); + await Assert.That(handler.Requests[0].Body).IsNull(); + } + + [Test] + public async Task CreateReleaseAsync_PostsTheNewRelease_AndReadsTheRelease() + { + var handler = Answering( + (HttpMethod.Post, ApiRepositoryUrl + "/releases"), + """{"id":42,"upload_url":"https://uploads.github.com/repos/Tenacom/Buildvana/releases/42/assets{?name,label}","draft":true}""", + HttpStatusCode.Created); + using var client = NewClient(handler); + var release = await client.CreateReleaseAsync(Owner, Repo, NewRelease).ConfigureAwait(false); + await Assert.That(release).IsEqualTo(Release); + var request = handler.Requests[0]; + await Assert.That(request.Headers["Content-Type"]).IsEqualTo("application/json; charset=utf-8"); + await Assert.That(request.Body) + .IsEqualTo("""{"tag_name":"1.0.0","name":"1.0.0 [provisional]","target_commitish":"main","prerelease":true,"draft":true}"""); + } + + [Test] + public async Task CreateReleaseAsync_WhenTheAnswerLacksTheUploadUrl_Fails() + { + var handler = Answering((HttpMethod.Post, ApiRepositoryUrl + "/releases"), """{"id":42}""", HttpStatusCode.Created); + using var client = NewClient(handler); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + await Assert.That(async () => await client.CreateReleaseAsync(Owner, Repo, NewRelease).ConfigureAwait(false)) + .Throws(); + } + + [Test] + public async Task GenerateReleaseNotesAsync_PostsTheRequest_AndReadsTheNotes() + { + var handler = Answering( + (HttpMethod.Post, ApiRepositoryUrl + "/releases/generate-notes"), + """{"name":"1.0.0","body":"## What's Changed"}"""); + using var client = NewClient(handler); + var notes = await client.GenerateReleaseNotesAsync(Owner, Repo, NotesRequest).ConfigureAwait(false); + await Assert.That(notes.Body).IsEqualTo("## What's Changed"); + await Assert.That(handler.Requests[0].Body).IsEqualTo("""{"tag_name":"1.0.0","target_commitish":"abc123"}"""); + } + + [Test] + public async Task UpdateReleaseAsync_PatchesTheRelease() + { + var handler = Answering((HttpMethod.Patch, ApiRepositoryUrl + "/releases/42"), """{"id":42}"""); + using var client = NewClient(handler); + await client.UpdateReleaseAsync(Owner, Repo, 42, ReleaseUpdate).ConfigureAwait(false); + await Assert.That(handler.Requests[0].Body) + .IsEqualTo("""{"tag_name":"1.0.0","name":"1.0.0","target_commitish":"abc123","body":"Text","prerelease":true,"draft":false}"""); + } + + [Test] + public async Task DeleteReleaseAsync_DeletesTheRelease() + { + var handler = Answering((HttpMethod.Delete, ApiRepositoryUrl + "/releases/42"), null, HttpStatusCode.NoContent); + using var client = NewClient(handler); + await client.DeleteReleaseAsync(Owner, Repo, 42).ConfigureAwait(false); + await Assert.That(handler.Requests).Count().IsEqualTo(1); + } + + [Test] + public async Task TagExistsAsync_WhenTheTagExists_ReturnsTrue() + { + var handler = Answering((HttpMethod.Get, ApiRepositoryUrl + "/git/ref/tags/1.0.0"), """{"ref":"refs/tags/1.0.0"}"""); + using var client = NewClient(handler); + await Assert.That(await client.TagExistsAsync(Owner, Repo, "1.0.0").ConfigureAwait(false)).IsTrue(); + } + + [Test] + public async Task TagExistsAsync_WhenTheApiAnswersNotFound_ReturnsFalse() + { + using var client = NewClient(Answering()); + await Assert.That(await client.TagExistsAsync(Owner, Repo, "1.0.0").ConfigureAwait(false)).IsFalse(); + } + + [Test] + public async Task TagExistsAsync_KeepsTheSlashesOfATagName() + { + var handler = Answering(); + using var client = NewClient(handler); + _ = await client.TagExistsAsync(Owner, Repo, "release/1.0 beta").ConfigureAwait(false); + await Assert.That(handler.Requests[0].Url).IsEqualTo(ApiRepositoryUrl + "/git/ref/tags/release/1.0%20beta"); + } + + [Test] + public async Task DeleteTagAsync_DeletesTheReference() + { + var handler = Answering((HttpMethod.Delete, ApiRepositoryUrl + "/git/refs/tags/1.0.0"), null, HttpStatusCode.NoContent); + using var client = NewClient(handler); + await client.DeleteTagAsync(Owner, Repo, "1.0.0").ConfigureAwait(false); + await Assert.That(handler.Requests).Count().IsEqualTo(1); + } + + [Test] + public async Task UploadReleaseAssetAsync_PostsTheContentsToTheUploadUrl() + { + var url = UploadUrl + "?name=Foo.1.0.0.nupkg&label=Foo%20package"; + var handler = Answering((HttpMethod.Post, url), """{"id":7}""", HttpStatusCode.Created); + using var client = NewClient(handler); + await UploadAsync(client, Release, "application/zip", "Foo package").ConfigureAwait(false); + var request = handler.Requests[0]; + await Assert.That(request.Headers["Content-Type"]).IsEqualTo("application/zip"); + await Assert.That(request.Body).IsEqualTo("asset contents"); + } + + [Test] + public async Task UploadReleaseAssetAsync_WithoutALabel_SendsTheNameAlone() + { + var handler = Answering((HttpMethod.Post, UploadUrl + "?name=Foo.1.0.0.nupkg"), """{"id":7}""", HttpStatusCode.Created); + using var client = NewClient(handler); + await UploadAsync(client, Release, "application/zip", null).ConfigureAwait(false); + await Assert.That(handler.Requests).Count().IsEqualTo(1); + } + + [Test] + public async Task UploadReleaseAssetAsync_WhenTheMimeTypeIsInvalid_Fails() + { + var handler = Answering(); + using var client = NewClient(handler); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + await Assert.That(async () => await UploadAsync(client, Release, "not a MIME type", null).ConfigureAwait(false)) + .Throws(); + await Assert.That(handler.Requests).IsEmpty(); + } + + [Test] + public async Task UploadReleaseAssetAsync_WhenTheUploadUrlIsNotAbsolute_Fails() + { + var handler = Answering(); + using var client = NewClient(handler); + var release = Release with { UploadUrl = "assets" }; + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + await Assert.That(async () => await UploadAsync(client, release, "application/zip", null).ConfigureAwait(false)) + .Throws(); + await Assert.That(handler.Requests).IsEmpty(); + } + + [Test] + [Arguments("GetRepository")] + [Arguments("CreateRelease")] + [Arguments("GenerateReleaseNotes")] + [Arguments("UpdateRelease")] + [Arguments("DeleteRelease")] + [Arguments("TagExists")] + [Arguments("DeleteTag")] + [Arguments("UploadReleaseAsset")] + public async Task Operation_WhenTheApiRefuses_FailsWithTheStatusAndTheMessage(string operation) + { + using var client = NewClient(AnsweringEverything( + new StubHttpAnswer(HttpStatusCode.UnprocessableEntity, """{"message":"Validation Failed"}"""))); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + var exception = await Assert.That(async () => await InvokeAsync(client, operation).ConfigureAwait(false)) + .Throws(); + await Assert.That(exception!.Message).Contains("422 Unprocessable Entity: Validation Failed"); + } + + [Test] + [Arguments("Bad gateway")] + [Arguments("{}")] + [Arguments("null")] + public async Task Operation_WhenTheErrorAnswerStatesNoMessage_FailsWithTheStatus(string body) + { + using var client = NewClient(AnsweringEverything(new StubHttpAnswer(HttpStatusCode.BadGateway, body))); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + var exception = await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) + .Throws(); + await Assert.That(exception!.Message).EndsWith("with 502 Bad Gateway."); + } + + [Test] + [Arguments("not json")] + [Arguments("null")] + public async Task GetRepositoryAsync_WhenTheAnswerCannotBeRead_Fails(string body) + { + using var client = NewClient(Answering((HttpMethod.Get, ApiRepositoryUrl), body)); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) + .Throws(); + } + + [Test] + public async Task GetRepositoryAsync_WhenTheHostCannotBeReached_Fails() + { + using var client = NewClient(AnsweringEverything(StubHttpAnswer.Throwing(new HttpRequestException("No such host is known.")))); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) + .Throws(); + } + + [Test] + public async Task GetRepositoryAsync_WhenTheRequestTimesOut_Fails() + { + using var client = NewClient(AnsweringEverything(StubHttpAnswer.Throwing(new TaskCanceledException("The request timed out.")))); + + // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) + .Throws(); + } + + [Test] + public async Task GetRepositoryAsync_WhenCancelled_IsCancelled() + { + using var client = NewClient(Answering((HttpMethod.Get, ApiRepositoryUrl), """{"private":false}""")); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync().ConfigureAwait(false); + + // ReSharper disable AccessToDisposedClosure // the assertion invokes the delegate before returning + await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo, cancellation.Token).ConfigureAwait(false)) + .Throws(); + + // ReSharper restore AccessToDisposedClosure + } + + private static GitHubApiClient NewClient(StubHttpMessageHandler handler) => new("github.com", Token, handler); + + private static StubHttpMessageHandler Answering() => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>()); + + private static StubHttpMessageHandler AnsweringEverything(StubHttpAnswer answer) + => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>(), answer); + + private static StubHttpMessageHandler Answering( + (HttpMethod Method, string Url) request, + string? body, + HttpStatusCode status = HttpStatusCode.OK) + => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer> { [request] = new(status, body) }); + + private static Task InvokeAsync(GitHubApiClient client, string operation) + => operation switch { + "GetRepository" => client.GetRepositoryAsync(Owner, Repo), + "CreateRelease" => client.CreateReleaseAsync(Owner, Repo, NewRelease), + "GenerateReleaseNotes" => client.GenerateReleaseNotesAsync(Owner, Repo, NotesRequest), + "UpdateRelease" => client.UpdateReleaseAsync(Owner, Repo, 42, ReleaseUpdate), + "DeleteRelease" => client.DeleteReleaseAsync(Owner, Repo, 42), + "TagExists" => client.TagExistsAsync(Owner, Repo, "1.0.0"), + "DeleteTag" => client.DeleteTagAsync(Owner, Repo, "1.0.0"), + "UploadReleaseAsset" => UploadAsync(client, Release, "application/zip", null), + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, "Unknown operation."), + }; + + private static Task UploadAsync(GitHubApiClient client, GitHubRelease release, string contentType, string? label) + => client.UploadReleaseAssetAsync( + release, + "Foo.1.0.0.nupkg", + contentType, + label, + new MemoryStream(Encoding.ASCII.GetBytes("asset contents"))); +} diff --git a/tests/Buildvana.Tool.Tests/StubHttpAnswer.cs b/tests/Buildvana.Tool.Tests/StubHttpAnswer.cs new file mode 100644 index 00000000..8ec9be85 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/StubHttpAnswer.cs @@ -0,0 +1,22 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Net; + +/// +/// An answer of : a status code and a body, or an exception that the +/// request throws. +/// +/// The status code of the answer. +/// The body of the answer, or for an empty body. +/// The exception that the request throws in place of an answer, or +/// for a request that gets an answer. +internal sealed record StubHttpAnswer(HttpStatusCode Status, string? Body = null, Exception? Failure = null) +{ + /// + /// Creates an answer that makes the request throw an exception. + /// + /// The exception. + /// The answer. + public static StubHttpAnswer Throwing(Exception failure) => new(default, null, failure); +} diff --git a/tests/Buildvana.Tool.Tests/StubHttpMessageHandler.cs b/tests/Buildvana.Tool.Tests/StubHttpMessageHandler.cs index d68fa638..35b68103 100644 --- a/tests/Buildvana.Tool.Tests/StubHttpMessageHandler.cs +++ b/tests/Buildvana.Tool.Tests/StubHttpMessageHandler.cs @@ -2,27 +2,92 @@ // See the LICENSE file in the project root for full license information. using System.Net; +using System.Net.Http.Headers; /// -/// Answers requests from a table of canned bodies, and records what was asked. +/// Answers requests from a table of canned answers, and records what was asked. /// /// -/// An address the table does not name is answered as not found, which is what a test of a reader's -/// failure path needs. +/// A request the table does not name gets the fallback answer, which is 404 by default. A not-found +/// answer is what a test of a reader's failure path needs. /// -internal sealed class StubHttpMessageHandler(IReadOnlyDictionary answers) : HttpMessageHandler +internal sealed class StubHttpMessageHandler : HttpMessageHandler { - /// Gets the addresses that were requested, in request order. - public List Requests { get; } = []; + private readonly IReadOnlyDictionary<(HttpMethod Method, string Url), StubHttpAnswer> _answers; + private readonly StubHttpAnswer _fallback; + + /// + /// Initializes a new instance of the class, answering requests by + /// method and URL. + /// + /// The answers, by method and absolute URL. + /// The answer to a request that does not name, or + /// for 404. + public StubHttpMessageHandler( + IReadOnlyDictionary<(HttpMethod Method, string Url), StubHttpAnswer> answers, + StubHttpAnswer? fallback = null) + { + _answers = answers; + _fallback = fallback ?? new StubHttpAnswer(HttpStatusCode.NotFound); + } + + /// + /// Initializes a new instance of the class, answering GET requests + /// with 200 and a body. + /// + /// The bodies of the answers, by absolute URL. + public StubHttpMessageHandler(IReadOnlyDictionary bodies) + : this(bodies.ToDictionary( + static pair => (HttpMethod.Get, pair.Key), + static pair => new StubHttpAnswer(HttpStatusCode.OK, pair.Value))) + { + } + + /// Gets the requests that were received, in request order. + public List Requests { get; } = []; /// - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); var url = request.RequestUri!.AbsoluteUri; - Requests.Add(url); - var response = answers.TryGetValue(url, out var body) - ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) } - : new HttpResponseMessage(HttpStatusCode.NotFound); - return Task.FromResult(response); + var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + Requests.Add(new StubHttpRequest(request.Method, url, CollectHeaders(request), body)); + + var answer = _answers.GetValueOrDefault((request.Method, url), _fallback); + if (answer.Failure is not null) + { + throw answer.Failure; + } + + var response = new HttpResponseMessage(answer.Status); + if (answer.Body is not null) + { + response.Content = new StringContent(answer.Body); + } + + return response; + } + + private static Dictionary CollectHeaders(HttpRequestMessage request) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + Add(request.Headers); + if (request.Content is not null) + { + Add(request.Content.Headers); + } + + return headers; + + void Add(HttpHeaders source) + { + foreach (var (name, values) in source) + { + headers[name] = string.Join(", ", values); + } + } } } diff --git a/tests/Buildvana.Tool.Tests/StubHttpRequest.cs b/tests/Buildvana.Tool.Tests/StubHttpRequest.cs new file mode 100644 index 00000000..94eee4d2 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/StubHttpRequest.cs @@ -0,0 +1,16 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +/// +/// A request that received. +/// +/// The method of the request. +/// The absolute URL of the request. +/// The headers of the request and of its content, by case-insensitive name. The values +/// of a header are joined with ", ". +/// The body of the request, or for a request without content. +internal sealed record StubHttpRequest( + HttpMethod Method, + string Url, + IReadOnlyDictionary Headers, + string? Body); From 10cad90af6da9d871b29a30411e590de3e5f89c1 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Wed, 21 Oct 2026 23:14:35 +0200 Subject: [PATCH 2/8] Inject IGitService, and stop disposing it in ServerRelease ServerRelease.DisposeAsync disposed GitService, which the service provider owns and disposes again at the end of the run. Every consumer held a GitService, whose type exposes Dispose, so nothing told a borrowed service from an owned one. IGitService does not extend IDisposable, and the service provider registers GitService under it. A consumer that is itself disposable then holds no disposable field, and CA2213 has nothing to report. Co-Authored-By: Claude Opus 5.5 --- .../ServiceCollectionExtensions.cs | 2 +- src/Buildvana.Tool/Services/Git/GitService.cs | 96 +++---------- .../Services/Git/IGitService.cs | 127 ++++++++++++++++++ .../Internal/GitHub/GitHubServerAdapter.cs | 4 +- .../Services/ServerAdapters/ServerRelease.cs | 5 +- .../Services/Versioning/VersionService.cs | 2 +- .../Subcommands/ReleaseCommand.cs | 2 +- .../Subcommands/VersionShowCommand.cs | 2 +- 8 files changed, 151 insertions(+), 89 deletions(-) create mode 100644 src/Buildvana.Tool/Services/Git/IGitService.cs diff --git a/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index ca8b3f2d..998713b8 100644 --- a/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -77,7 +77,7 @@ public IServiceCollection AddBvServices() .AddSingleton() .AddSingleton() .AddSingleton(static sp => sp.GetRequiredService().Create()) - .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton(ServerAdapter.Create) .AddSingleton() diff --git a/src/Buildvana.Tool/Services/Git/GitService.cs b/src/Buildvana.Tool/Services/Git/GitService.cs index 76a9befd..40b173cf 100644 --- a/src/Buildvana.Tool/Services/Git/GitService.cs +++ b/src/Buildvana.Tool/Services/Git/GitService.cs @@ -20,10 +20,10 @@ namespace Buildvana.Tool.Services.Git; /// -/// Provides shortcut methods to use Git. +/// Implements over LibGit2Sharp. /// [PublicAPI] -internal sealed class GitService : IDisposable +internal sealed class GitService : IGitService, IDisposable { private readonly IReporter _reporter; private readonly IHomeDirectoryProvider _home; @@ -47,25 +47,16 @@ public GitService(IReporter reporter, IHomeDirectoryProvider home) CurrentBranch = headName.StartsWith("refs/heads/", StringComparison.Ordinal) ? _repository.Head.FriendlyName : string.Empty; } - /// - /// Gets the name of the origin remote, i.e. either "origin" if such remote exists, or the name of the only remote if there is only one. - /// + /// public string Origin { get; } - /// - /// Gets the fetch URL of the origin remote. - /// + /// public Uri OriginUrl { get; } - /// - /// Gets the name of the current Git branch. - /// - /// If HEAD is on a branch, the name of the branch; otherwise, the empty string. + /// public string CurrentBranch { get; } - /// - /// Gets or sets the identity of the Git committer. - /// + /// [DisallowNull] public GitIdentity? CommitterIdentity { @@ -84,45 +75,23 @@ public GitIdentity? CommitterIdentity } } - /// - /// Gets or sets the credentials used for pushing to the Git repository if ambient credentials are not sufficient. - /// - /// - /// Set this property when ambient mechanisms (`http.extraheader` written by CI checkout actions, - /// URL-embedded credentials, OS credential helpers) are absent or insufficient. - /// The provided credentials are tried only after the server returns a 401 challenge to the initial push request. - /// + /// public GitCredentials? PushCredentialsFallback { get; set; } - /// - /// Gets the SHA of the current HEAD commit. - /// + /// public string HeadSha => _repository.Head.Tip.Sha; /// public void Dispose() => _repository.Dispose(); - /// - /// Tells whether a tag exists in the local Git repository. - /// - /// The tag to check for. - /// If a tag whose name is equal to exists in the repository, ; - /// otherwise, . + /// public bool TagExists(string tag) { Guard.IsNotNullOrEmpty(tag); return _repository.Tags.Any(x => string.Equals(x.FriendlyName, tag, StringComparison.Ordinal)); } - /// - /// Gets the latest version and the latest stable version in commit history. - /// - /// A tuple of the latest version and the latest stable version. - /// - /// If no version tag is found in commit history, this method returns a tuple of two s. - /// If no stable version tag is found in commit history, this method returns a tuple of the latest - /// version and . - /// + /// public (SemanticVersion? Latest, SemanticVersion? LatestStable) GetLatestVersions() { var versions = _repository.Tags @@ -154,15 +123,7 @@ public bool TagExists(string tag) return (latest, latestStable); } - /// - /// Runs an operation and detects the files it modified, by comparing working-tree snapshots - /// (as per ) taken before and after the operation. - /// - /// The type of the operation's result. - /// The operation to run. - /// A representing the ongoing operation, whose result is a tuple - /// of the operation's result and the absolute paths of the files the operation modified. Files that - /// were already dirty before the operation are not reported, even if the operation modified them again. + /// public async Task<(T Result, IReadOnlyList ChangedFiles)> TrackChangesAsync(Func> operation) { Guard.IsNotNull(operation); @@ -172,13 +133,7 @@ public bool TagExists(string tag) return (result, changedFiles); } - /// - /// Gets the files that are new, modified, or otherwise different between the working directory - /// (including the index) and the current HEAD commit. Ignored files are not considered; - /// neither are files under bv's scratch directory (), - /// whether or not the repository ignores it. - /// - /// The absolute paths of the differing files. + /// public IReadOnlyList GetDirtyFiles() { var homeDirectory = _home.HomeDirectory; @@ -195,10 +150,7 @@ .. _repository.RetrieveStatus(options) ]; } - /// - /// Adds one or more files to the Git index. - /// - /// The paths of the files to add. + /// public void Stage(params string[] paths) { Guard.IsNotNull(paths); @@ -233,12 +185,7 @@ public void Stage(params string[] paths) }); } - /// - /// Commits staged changes, or amends last commit. - /// - /// The commit message. - /// If , amends last commit instead of creating a new commit. - /// If , allows creating (or amending into) an empty commit. + /// public void Commit(string message, bool amend = false, bool allowEmpty = false) { var signature = _repository.Config.BuildSignature(DateTimeOffset.Now); @@ -247,16 +194,7 @@ public void Commit(string message, bool amend = false, bool allowEmpty = false) _ = _repository.Commit(message, signature, signature, options); } - /// - /// Undoes the most recent commit. - /// - /// - /// This method's purpose is to undo a commit that was just generated by code and is not a merge commit. - /// If the current HEAD has multiple parents, the behavior of this method is undefined. - /// If the repository has no commits, or the current HEAD has no parents, this method will fail. - /// Undoing a commit is not an outcome in itself: callers undo a whole set of commits at once, and only - /// the caller knows what that set was, so the record of the undoing belongs to it. This method only narrates. - /// + /// public void UndoLastCommit() { _reporter.Info("Undoing last commit..."); @@ -265,9 +203,7 @@ public void UndoLastCommit() _repository.Reset(ResetMode.Hard, previousCommit); } - /// - /// Pushes changes made to HEAD to the tracked remote. Fails if HEAD is not tracking any remote. - /// + /// public void Push(bool force = false) { var head = _repository.Head; diff --git a/src/Buildvana.Tool/Services/Git/IGitService.cs b/src/Buildvana.Tool/Services/Git/IGitService.cs new file mode 100644 index 00000000..a5bd2961 --- /dev/null +++ b/src/Buildvana.Tool/Services/Git/IGitService.cs @@ -0,0 +1,127 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Buildvana.Tool.Infrastructure; +using JetBrains.Annotations; +using NuGet.Versioning; + +namespace Buildvana.Tool.Services.Git; + +/// +/// Provides shortcut methods to use the Git repository of the home directory. +/// +[PublicAPI] +internal interface IGitService +{ + /// + /// Gets the name of the origin remote, i.e. either "origin" if such remote exists, or the name of the only remote if there is only one. + /// + string Origin { get; } + + /// + /// Gets the fetch URL of the origin remote. + /// + Uri OriginUrl { get; } + + /// + /// Gets the name of the current Git branch. + /// + /// If HEAD is on a branch, the name of the branch; otherwise, the empty string. + string CurrentBranch { get; } + + /// + /// Gets or sets the identity of the Git committer. + /// + [DisallowNull] + GitIdentity? CommitterIdentity { get; set; } + + /// + /// Gets or sets the credentials used for pushing to the Git repository if ambient credentials are not sufficient. + /// + /// + /// Set this property when ambient mechanisms (`http.extraheader` written by CI checkout actions, + /// URL-embedded credentials, OS credential helpers) are absent or insufficient. + /// The provided credentials are tried only after the server returns a 401 challenge to the initial push request. + /// + GitCredentials? PushCredentialsFallback { get; set; } + + /// + /// Gets the SHA of the current HEAD commit. + /// + string HeadSha { get; } + + /// + /// Tells whether a tag exists in the local Git repository. + /// + /// The tag to check for. + /// If a tag whose name is equal to exists in the repository, ; + /// otherwise, . + bool TagExists(string tag); + + /// + /// Gets the latest version and the latest stable version in commit history. + /// + /// A tuple of the latest version and the latest stable version. + /// + /// If no version tag is found in commit history, this method returns a tuple of two s. + /// If no stable version tag is found in commit history, this method returns a tuple of the latest + /// version and . + /// + (SemanticVersion? Latest, SemanticVersion? LatestStable) GetLatestVersions(); + + /// + /// Runs an operation and detects the files it modified, by comparing working-tree snapshots + /// (as per ) taken before and after the operation. + /// + /// The type of the operation's result. + /// The operation to run. + /// A representing the ongoing operation, whose result is a tuple + /// of the operation's result and the absolute paths of the files the operation modified. Files that + /// were already dirty before the operation are not reported, even if the operation modified them again. + Task<(T Result, IReadOnlyList ChangedFiles)> TrackChangesAsync(Func> operation); + + /// + /// Gets the files that are new, modified, or otherwise different between the working directory + /// (including the index) and the current HEAD commit. Ignored files are not considered; + /// neither are files under bv's scratch directory (), + /// whether or not the repository ignores it. + /// + /// The absolute paths of the differing files. + IReadOnlyList GetDirtyFiles(); + + /// + /// Adds one or more files to the Git index. + /// + /// The paths of the files to add. + void Stage(params string[] paths); + + /// + /// Commits staged changes, or amends last commit. + /// + /// The commit message. + /// If , amends last commit instead of creating a new commit. + /// If , allows creating (or amending into) an empty commit. + void Commit(string message, bool amend = false, bool allowEmpty = false); + + /// + /// Undoes the most recent commit. + /// + /// + /// This method's purpose is to undo a commit that was just generated by code and is not a merge commit. + /// If the current HEAD has multiple parents, the behavior of this method is undefined. + /// If the repository has no commits, or the current HEAD has no parents, this method will fail. + /// Undoing a commit is not an outcome in itself: callers undo a whole set of commits at once, and only + /// the caller knows what that set was, so the record of the undoing belongs to it. This method only narrates. + /// + void UndoLastCommit(); + + /// + /// Pushes changes made to HEAD to the tracked remote. Fails if HEAD is not tracking any remote. + /// + /// If , force-pushes HEAD to the tracked remote. + void Push(bool force = false); +} diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs index 5a1c2208..a2ae2a29 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs @@ -26,7 +26,7 @@ internal sealed class GitHubServerAdapter : ServerAdapter private readonly IServiceProvider _services; private readonly IReporter _reporter; private readonly VersionService _version; - private readonly GitService _git; + private readonly IGitService _git; private readonly GitHubRepositoryUrls _urls; private readonly string _token; @@ -35,7 +35,7 @@ private GitHubServerAdapter(IServiceProvider services) _services = services; _reporter = services.GetRequiredService(); _version = services.GetRequiredService(); - _git = services.GetRequiredService(); + _git = services.GetRequiredService(); BuildFailedException.ThrowIfNot( GitUrlInfo.TryCreate(_git.OriginUrl, out var originInfo), $"Couldn't get information from origin URL '{_git.OriginUrl}'."); diff --git a/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs b/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs index 2adbfaa3..d5f17dfb 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs @@ -25,7 +25,7 @@ internal abstract partial class ServerRelease : IAsyncDisposable private const string ProvisionalMessage = "Prepare release [skip ci]"; private readonly IReporter _reporter; - private readonly GitService _git; + private readonly IGitService _git; private readonly VersionService _version; private readonly Stack> _rollbackActions = new(); private readonly List _assets = []; @@ -40,7 +40,7 @@ private protected ServerRelease(IServiceProvider services) Guard.IsNotNull(services); _reporter = services.GetRequiredService(); - _git = services.GetRequiredService(); + _git = services.GetRequiredService(); _version = services.GetRequiredService(); } @@ -271,7 +271,6 @@ public async ValueTask DisposeAsync() } _rollbackActions.Clear(); - _git.Dispose(); } protected abstract Task DoPublishAsync(IReadOnlyList assets); diff --git a/src/Buildvana.Tool/Services/Versioning/VersionService.cs b/src/Buildvana.Tool/Services/Versioning/VersionService.cs index 394a0c4c..072cfd39 100644 --- a/src/Buildvana.Tool/Services/Versioning/VersionService.cs +++ b/src/Buildvana.Tool/Services/Versioning/VersionService.cs @@ -32,7 +32,7 @@ public VersionService( IReporter reporter, IHomeDirectoryProvider home, VersioningSettings settings, - GitService git, + IGitService git, DeclaredApiFilesService declaredApiFiles) { Guard.IsNotNull(reporter); diff --git a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs index c74f92ad..ba9efd07 100644 --- a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs +++ b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs @@ -50,7 +50,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) var server = services.GetRequiredService(); var version = services.GetRequiredService(); var dotnet = services.GetRequiredService(); - var git = services.GetRequiredService(); + var git = services.GetRequiredService(); var changelog = services.GetRequiredService(); var declaredApiFiles = services.GetRequiredService(); var selfReferenceUpdater = services.GetRequiredService(); diff --git a/src/Buildvana.Tool/Subcommands/VersionShowCommand.cs b/src/Buildvana.Tool/Subcommands/VersionShowCommand.cs index 6174f827..10bfc2d9 100644 --- a/src/Buildvana.Tool/Subcommands/VersionShowCommand.cs +++ b/src/Buildvana.Tool/Subcommands/VersionShowCommand.cs @@ -13,7 +13,7 @@ namespace Buildvana.Tool.Subcommands; [ImplementsCommand("version show | version")] [Description("Show current and published version information.")] -internal sealed class VersionShowCommand(VersionService version, GitService git, IAnsiConsole console) : IBvCommand +internal sealed class VersionShowCommand(VersionService version, IGitService git, IAnsiConsole console) : IBvCommand { public Task ExecuteAsync(CancellationToken cancellationToken) { From dd7d9d897f50d823874da56f84917eb3bc1f4a82 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Wed, 21 Oct 2026 23:17:17 +0200 Subject: [PATCH 3/8] Replace Octokit with GitHubApiClient in GitHubServerAdapter GitHubServerAdapter reached the GitHub REST API through an Octokit GitHubClient, whose base address is always api.github.com. A release on GitHub Enterprise Server then went to the wrong API. GitHubServerAdapter now holds one GitHubApiClient for its whole life, over an HttpClientHandler with automatic decompression. The service provider disposes the adapter, and the adapter disposes the client. The upload carries the label of the asset, so UploadReleaseAssetAsync sends no second request to set it. DeleteReleaseAsync asks TagExistsAsync whether the tag exists, where it caught the NotFoundException of Octokit. Co-Authored-By: Claude Opus 5.5 --- Directory.Packages.props | 1 - src/Buildvana.Tool/Buildvana.Tool.csproj | 1 - .../Internal/GitHub/GitHubServerAdapter.cs | 108 +++++++----------- .../Internal/GitHub/GitHubServerRelease.cs | 7 +- 4 files changed, 45 insertions(+), 72 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index f62fba55..0d418cc7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -50,7 +50,6 @@ - diff --git a/src/Buildvana.Tool/Buildvana.Tool.csproj b/src/Buildvana.Tool/Buildvana.Tool.csproj index d2671864..f619837a 100644 --- a/src/Buildvana.Tool/Buildvana.Tool.csproj +++ b/src/Buildvana.Tool/Buildvana.Tool.csproj @@ -47,7 +47,6 @@ - diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs index a2ae2a29..c341c016 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs @@ -3,6 +3,8 @@ using System; using System.IO; +using System.Net; +using System.Net.Http; using System.Text; using System.Threading.Tasks; using Buildvana.Core; @@ -14,14 +16,13 @@ using Buildvana.Tool.Utilities; using CommunityToolkit.Diagnostics; using Microsoft.Extensions.DependencyInjection; -using Octokit; namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; /// /// Continuous Integration adapter for GitHub. /// -internal sealed class GitHubServerAdapter : ServerAdapter +internal sealed class GitHubServerAdapter : ServerAdapter, IDisposable { private readonly IServiceProvider _services; private readonly IReporter _reporter; @@ -29,6 +30,7 @@ internal sealed class GitHubServerAdapter : ServerAdapter private readonly IGitService _git; private readonly GitHubRepositoryUrls _urls; private readonly string _token; + private readonly GitHubApiClient _client; private GitHubServerAdapter(IServiceProvider services) { @@ -45,6 +47,7 @@ private GitHubServerAdapter(IServiceProvider services) RepositoryName = originInfo.PathSegments[1]; _urls = new GitHubRepositoryUrls(HostName, RepositoryOwner, RepositoryName); _token = RuntimeAccess.Translate(services.GetRequiredService().GitHub.GetToken); + _client = new GitHubApiClient(HostName, _token, new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }); } /// @@ -111,8 +114,7 @@ public static void SetActionsStepOutput(string outputFile, string name, string v public override async Task IsPrivateRepositoryAsync() { _reporter.Info("Fetching repository information..."); - var client = CreateGitHubClient(); - var repository = await client.Repository.Get(RepositoryOwner, RepositoryName).ConfigureAwait(false); + var repository = await _client.GetRepositoryAsync(RepositoryOwner, RepositoryName).ConfigureAwait(false); return repository.Private; } @@ -130,16 +132,16 @@ public override async Task CreateReleaseAsync() createGitHubReleaseAsync: () => { // Create the release as a draft first, so if the token has no permissions we can bail out early var tag = _version.CurrentStr; - var client = CreateGitHubClient(); _reporter.Info("Creating a provisional draft release..."); - var newRelease = new NewRelease(tag) { + var newRelease = new GitHubNewRelease { + TagName = tag, Name = $"{tag} [provisional]", TargetCommitish = _git.CurrentBranch, Prerelease = _version.IsPrerelease, Draft = true, }; - return client.Repository.Release.Create(RepositoryOwner, RepositoryName, newRelease); + return _client.CreateReleaseAsync(RepositoryOwner, RepositoryName, newRelease); }).ConfigureAwait(false); /// @@ -150,34 +152,35 @@ public override async Task CreateReleaseAsync() /// release commit so the tag is anchored to it regardless of any post-release commits that may /// have been pushed on top of the branch. /// A representing the ongoing operation. - public async Task PublishReleaseAsync(Release release, string targetCommitish) + public async Task PublishReleaseAsync(GitHubRelease release, string targetCommitish) { Guard.IsNotNull(release); Guard.IsNotNullOrEmpty(targetCommitish); var tag = _version.CurrentStr; - var client = CreateGitHubClient(); _reporter.Info($"Generating release notes for {tag}..."); - var releaseNotesRequest = new GenerateReleaseNotesRequest(tag) { + var releaseNotesRequest = new GitHubReleaseNotesRequest { + TagName = tag, TargetCommitish = targetCommitish, }; - var generateNotesResponse = await client.Repository.Release.GenerateReleaseNotes( + var generatedNotes = await _client.GenerateReleaseNotesAsync( RepositoryOwner, RepositoryName, releaseNotesRequest).ConfigureAwait(false); var body = $"We also have a [human-curated changelog]({GetFileUrl("CHANGELOG.md", _git.CurrentBranch)}).\n\n---\n\n" - + generateNotesResponse.Body; + + generatedNotes.Body; _reporter.Info($"Publishing the previously created release as {tag} (target {targetCommitish})..."); - var update = release.ToUpdate(); - update.TagName = tag; - update.Name = tag; - update.TargetCommitish = targetCommitish; - update.Body = body; - update.Prerelease = _version.IsPrerelease; - update.Draft = false; + var update = new GitHubReleaseUpdate { + TagName = tag, + Name = tag, + TargetCommitish = targetCommitish, + Body = body, + Prerelease = _version.IsPrerelease, + Draft = false, + }; - _ = await client.Repository.Release.Edit(RepositoryOwner, RepositoryName, release.Id, update).ConfigureAwait(false); + await _client.UpdateReleaseAsync(RepositoryOwner, RepositoryName, release.Id, update).ConfigureAwait(false); } /// @@ -187,17 +190,16 @@ public async Task PublishReleaseAsync(Release release, string targetCommitish) /// The tag name, or to not delete a tag. /// A representing the ongoing operation. /// - /// A deleted release is named after , never after - /// : the draft is created before the release commit exists, and that commit + /// A deleted release is named after , never after the tag name of + /// : the draft is created before the release commit exists, and that commit /// always moves the version, so the tag name the draft carries is never the one the release is published /// under. A draft, having no tag of its own, is reported as the draft it is. /// - public async Task DeleteReleaseAsync(Release release, string? tagName) + public async Task DeleteReleaseAsync(GitHubRelease release, string? tagName) { Guard.IsNotNull(release); _reporter.Info("Deleting the previously created release..."); - var client = CreateGitHubClient(); - await client.Repository.Release.Delete(RepositoryOwner, RepositoryName, release.Id).ConfigureAwait(false); + await _client.DeleteReleaseAsync(RepositoryOwner, RepositoryName, release.Id).ConfigureAwait(false); if (string.IsNullOrEmpty(tagName)) { _reporter.Notice("Deleted the provisional draft release."); @@ -206,20 +208,15 @@ public async Task DeleteReleaseAsync(Release release, string? tagName) _reporter.Notice($"Deleted release {tagName}."); - var reference = "refs/tags/" + tagName; - _reporter.Info($"Looking for reference '{reference}' in GitHub repository..."); - try - { - _ = await client.Git.Reference.Get(RepositoryOwner, RepositoryName, reference).ConfigureAwait(false); - } - catch (NotFoundException) + _reporter.Info($"Looking for tag {tagName} in GitHub repository..."); + if (!await _client.TagExistsAsync(RepositoryOwner, RepositoryName, tagName).ConfigureAwait(false)) { - _reporter.Info($"Reference '{reference}' not found in GitHub repository."); + _reporter.Info($"Tag {tagName} not found in GitHub repository."); return; } - _reporter.Info($"Deleting reference '{reference}' in GitHub repository..."); - await client.Git.Reference.Delete(RepositoryOwner, RepositoryName, reference).ConfigureAwait(false); + _reporter.Info($"Deleting tag {tagName} in GitHub repository..."); + await _client.DeleteTagAsync(RepositoryOwner, RepositoryName, tagName).ConfigureAwait(false); _reporter.Notice($"Deleted tag {tagName}."); } @@ -229,44 +226,23 @@ public async Task DeleteReleaseAsync(Release release, string? tagName) /// An object representing the release. /// The full path of the asset file. /// The MIME type of the asset. - /// A short textual description of the asset. + /// A short textual description of the asset, which becomes its label. /// A representing the ongoing operation. - public async Task UploadReleaseAssetAsync(Release release, string path, string mimeType, string description) + public async Task UploadReleaseAssetAsync(GitHubRelease release, string path, string mimeType, string description) { - var client = CreateGitHubClient(); _reporter.Detail($"Uploading asset {path}..."); - ReleaseAsset asset; var assetContents = UserFile.OpenRead(path); await using (assetContents.ConfigureAwait(false)) { - var upload = new ReleaseAssetUpload { - FileName = Path.GetFileName(path), - ContentType = mimeType, - RawData = assetContents, - }; - - asset = await client.Repository.Release.UploadAsset(release, upload).ConfigureAwait(false); - } - - if (!string.IsNullOrEmpty(description)) - { - _reporter.Detail("Updating asset label..."); - var update = asset.ToUpdate(); - update.Label = description; - _ = await client.Repository.Release.EditAsset(RepositoryOwner, RepositoryName, asset.Id, update).ConfigureAwait(false); - } - else - { - _reporter.Detail("Skipping label update: asset has no description."); + await _client.UploadReleaseAssetAsync( + release, + Path.GetFileName(path), + mimeType, + string.IsNullOrEmpty(description) ? null : description, + assetContents).ConfigureAwait(false); } } - private GitHubClient CreateGitHubClient() - { - var client = new GitHubClient(new ProductHeaderValue("Buildvana")) { - Credentials = new(_token), - }; - - return client; - } + /// + public void Dispose() => _client.Dispose(); } diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs index e6023f00..771575f9 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs @@ -11,7 +11,6 @@ using Buildvana.Tool.Utilities; using CommunityToolkit.Diagnostics; using Microsoft.Extensions.DependencyInjection; -using Octokit; namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; @@ -23,7 +22,7 @@ internal sealed class GitHubServerRelease : ServerRelease private readonly GitHubServerAdapter _server; private readonly IReporter _reporter; private readonly VersionService _version; - private readonly Release _gitHubRelease; + private readonly GitHubRelease _gitHubRelease; private readonly string _actionsStepOutputFile; private bool _gitHubReleaseDeleted; @@ -31,7 +30,7 @@ internal sealed class GitHubServerRelease : ServerRelease private GitHubServerRelease( GitHubServerAdapter server, IServiceProvider services, - Release gitHubRelease, + GitHubRelease gitHubRelease, string actionsStepOutputFile) : base(services) { @@ -57,7 +56,7 @@ private GitHubServerRelease( public static async Task CreateAsync( GitHubServerAdapter server, IServiceProvider services, - Func> createGitHubReleaseAsync) + Func> createGitHubReleaseAsync) { Guard.IsNotNull(server); Guard.IsNotNull(services); From f93d990e8ba24ede50c04bf20ba57be51ae4303c Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Wed, 21 Oct 2026 23:28:11 +0200 Subject: [PATCH 4/8] Fix five ReSharper findings in GitHubApiClient and its tests ReSharper reported five warnings in GitHubApiClient.cs, GitHubApiClientTests.cs, and StubHttpRequest.cs. GitHubApiClient.SendAsync set HttpRequestMessage.Content in the initializer of a using declaration. A throwing setter would then leak the request. No test read StubHttpRequest.Method. TagExistsAsync_KeepsTheSlashesOfATagName asserts it, because the fallback answer of StubHttpMessageHandler matches any method. Co-Authored-By: Claude Opus 5.5 --- .../ServerAdapters/Internal/GitHub/GitHubApiClient.cs | 6 ++++-- tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs index e4eaa3ee..a79c7984 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs @@ -373,7 +373,8 @@ private async Task SendAsync( CancellationToken cancellationToken) where T : class { - using var request = new HttpRequestMessage(method, url) { Content = content }; + using var request = new HttpRequestMessage(method, url); + request.Content = content; using var response = await SendCoreAsync(request, cancellationToken).ConfigureAwait(false); await EnsureSuccessAsync(request, response, cancellationToken).ConfigureAwait(false); return await ReadAsync(request, response, typeInfo, cancellationToken).ConfigureAwait(false); @@ -385,7 +386,8 @@ private async Task SendAsync( HttpContent? content, CancellationToken cancellationToken) { - using var request = new HttpRequestMessage(method, url) { Content = content }; + using var request = new HttpRequestMessage(method, url); + request.Content = content; using var response = await SendCoreAsync(request, cancellationToken).ConfigureAwait(false); await EnsureSuccessAsync(request, response, cancellationToken).ConfigureAwait(false); } diff --git a/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs b/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs index 3c8a4733..5a5a1ce2 100644 --- a/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs +++ b/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs @@ -2,7 +2,6 @@ // See the LICENSE file in the project root for full license information. using System.Net; -using System.Text; using Buildvana.Core; using Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; using Buildvana.Tool.Utilities; @@ -167,6 +166,7 @@ public async Task TagExistsAsync_KeepsTheSlashesOfATagName() var handler = Answering(); using var client = NewClient(handler); _ = await client.TagExistsAsync(Owner, Repo, "release/1.0 beta").ConfigureAwait(false); + await Assert.That(handler.Requests[0].Method).IsEqualTo(HttpMethod.Get); await Assert.That(handler.Requests[0].Url).IsEqualTo(ApiRepositoryUrl + "/git/ref/tags/release/1.0%20beta"); } @@ -182,7 +182,7 @@ public async Task DeleteTagAsync_DeletesTheReference() [Test] public async Task UploadReleaseAssetAsync_PostsTheContentsToTheUploadUrl() { - var url = UploadUrl + "?name=Foo.1.0.0.nupkg&label=Foo%20package"; + const string url = UploadUrl + "?name=Foo.1.0.0.nupkg&label=Foo%20package"; var handler = Answering((HttpMethod.Post, url), """{"id":7}""", HttpStatusCode.Created); using var client = NewClient(handler); await UploadAsync(client, Release, "application/zip", "Foo package").ConfigureAwait(false); @@ -337,5 +337,5 @@ private static Task UploadAsync(GitHubApiClient client, GitHubRelease release, s "Foo.1.0.0.nupkg", contentType, label, - new MemoryStream(Encoding.ASCII.GetBytes("asset contents"))); + new MemoryStream("asset contents"u8.ToArray())); } From 742b54cfb1ff00c109264df242d0083ad4726c2b Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Wed, 21 Oct 2026 23:37:16 +0200 Subject: [PATCH 5/8] Fix the GitHubServerAdapter.IsCloudBuild doc that said "Always false" The element of GitHubServerAdapter.IsCloudBuild said that the property always returns false. The property returns true, as on every CI adapter. Co-Authored-By: Claude Opus 5.5 --- .../ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs index c341c016..cc0939d7 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs @@ -66,7 +66,7 @@ private GitHubServerAdapter(IServiceProvider services) public override Uri RepositoryUrl => _urls.Repository; /// - /// Always . + /// Always . public override bool IsCloudBuild => true; /// From 5e4902355009e6a73eecc6f9793f2a2357f7b17c Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Wed, 21 Oct 2026 23:48:04 +0200 Subject: [PATCH 6/8] Document the GitHub API address of bv release in release.md `docs/tool-commands/release.md` did not say which GitHub API `bv release` reaches. `GitHubApiClient` derives the address from the host of the `origin` remote. Before a first release on a GitHub Enterprise Server, a user needs to know the derived address. The `CHANGELOG.md` bullet records the move from `api.github.com` alone to the API of the host. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/tool-commands/release.md | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c742a9..52da17ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ The patch number restarted from 1, below the 2.0.x versions already published. - `bv release` runs the [build pipeline](docs/tool-commands/release.md#the-steps) once, from `clean` through `pack`, after the release commit, and no longer runs `clean` through `test` before it. - [The pages under `docs/`](docs/README.md) moved to kebab-case names, `docs/modules/` to `docs/sdk-modules/`, and `docs/DependencyManagement.md` to `docs/tool-commands/dependencies.md`, so a link to an old name breaks. - The `StandardAnalyzers` module adds [`StyleCop.Analyzers` 1.3.133-beta](docs/sdk-modules/standard-analyzers.md#package-references), which supports C# 8 to 15 and adds several new rules. +- `bv release` reaches the [GitHub API of the host of the repository](docs/tool-commands/release.md#publishing), `api.github.com` or `/api/v3`, where it reached `api.github.com` alone. ### Bugs fixed in this release diff --git a/docs/tool-commands/release.md b/docs/tool-commands/release.md index 9268e76b..b52329bc 100644 --- a/docs/tool-commands/release.md +++ b/docs/tool-commands/release.md @@ -167,6 +167,11 @@ The release assets are collected from two sources: A line with the wrong shape, or one naming a file that does not exist, is a warning, and the asset is skipped. +`bv` reaches the GitHub API of the host of the `origin` remote. +For `github.com`, the API is at `https://api.github.com`. +`bv` takes any other host for a GitHub Enterprise Server, whose API is at `https:///api/v3`. +An asset upload goes to the address that GitHub states in the answer to the creation of the draft release. + `bv` then uploads the assets, and turns the draft into the release. The release is named after the version, its tag names the release commit, and the prerelease flag follows the version. Its description is a link to `CHANGELOG.md` on the release branch, followed by the notes GitHub generates. From cf6b61f0645946431d7d251e76425e129a3c559c Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Thu, 22 Oct 2026 01:12:29 +0200 Subject: [PATCH 7/8] Return after the try block in ReadErrorMessageAsync and ReadJsonAsync GitHubApiClient.ReadErrorMessageAsync and DotNetReleaseIndex.ReadJsonAsync returned from inside an await using block, within a try block. Control then never reached the closing brace of the try block, and the coverage report counted that brace as a line no test runs. Both methods now assign the result inside the await using block and return after the try block, as GitHubApiClient.ReadAsync does. A code review named ReadErrorMessageAsync alone, and a search of src/ found the same shape in ReadJsonAsync. Co-Authored-By: Claude Opus 5.5 --- .../Services/Dependencies/DotNetReleaseIndex.cs | 5 ++++- .../ServerAdapters/Internal/GitHub/GitHubApiClient.cs | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Buildvana.Tool/Services/Dependencies/DotNetReleaseIndex.cs b/src/Buildvana.Tool/Services/Dependencies/DotNetReleaseIndex.cs index 1b9026b2..bf35e2a9 100644 --- a/src/Buildvana.Tool/Services/Dependencies/DotNetReleaseIndex.cs +++ b/src/Buildvana.Tool/Services/Dependencies/DotNetReleaseIndex.cs @@ -182,6 +182,7 @@ private static bool IsAtOrAbove(string channelVersion, NuGetVersion pinnedVersio private async Task ReadJsonAsync(Uri url, CancellationToken cancellationToken) { + JsonDocument document; try { using var response = await _http.GetAsync(url, cancellationToken).ConfigureAwait(false); @@ -189,7 +190,7 @@ private async Task ReadJsonAsync(Uri url, CancellationToken cancel var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using (stream.ConfigureAwait(false)) { - return await JsonDocument.ParseAsync(stream, default, cancellationToken).ConfigureAwait(false); + document = await JsonDocument.ParseAsync(stream, default, cancellationToken).ConfigureAwait(false); } } catch (HttpRequestException exception) @@ -204,5 +205,7 @@ private async Task ReadJsonAsync(Uri url, CancellationToken cancel { throw new BuildFailedException($"The .NET release index at {url} states something bv cannot read: {exception.Message}", exception); } + + return document; } } diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs index a79c7984..86217f3f 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs @@ -320,20 +320,22 @@ private static async Task EnsureSuccessAsync( HttpResponseMessage response, CancellationToken cancellationToken) { + GitHubErrorBody? body; try { var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using (stream.ConfigureAwait(false)) { - var body = await JsonSerializer.DeserializeAsync(stream, GitHubJsonContext.Default.GitHubErrorBody, cancellationToken) + body = await JsonSerializer.DeserializeAsync(stream, GitHubJsonContext.Default.GitHubErrorBody, cancellationToken) .ConfigureAwait(false); - return body?.Message is { Length: > 0 } message ? message : null; } } catch (JsonException) { return null; } + + return body?.Message is { Length: > 0 } message ? message : null; } // The client buffers every answer before SendAsync returns, so reading the body here meets no network. From 04ca2d0b7dc27abd6d31f63c9427f5c84112ba1d Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Thu, 22 Oct 2026 01:17:48 +0200 Subject: [PATCH 8/8] Stop reading HttpRequestMessage.RequestUri in GitHubApiClient.Describe GitHubApiClient.Describe read the URL of a failed request from HttpRequestMessage.RequestUri, which is nullable. Every request has a URL, so the null branch of the ?. operator could never run, and the coverage report counted it as a partial line. GitHubApiClient.ApiUrl now builds the absolute URL of each request, and SendCoreAsync, EnsureSuccessAsync and ReadAsync receive it alongside the request. ApiUrl combines BaseUrl with the path as HttpClient combined BaseAddress with a relative request URL. The requests go to the same addresses, and HttpClient.BaseAddress is no longer set. Co-Authored-By: Claude Opus 5.5 --- .../Internal/GitHub/GitHubApiClient.cs | 68 +++++++++++-------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs index 86217f3f..0e68fac6 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs @@ -62,7 +62,7 @@ public GitHubApiClient(string hostName, string token, HttpMessageHandler handler Guard.IsNotNull(handler); BaseUrl = GetBaseUrl(hostName); - _http = new HttpClient(handler) { BaseAddress = BaseUrl }; + _http = new HttpClient(handler); var headers = _http.DefaultRequestHeaders; headers.Accept.Add(new MediaTypeWithQualityHeaderValue(GitHubMediaType)); headers.Add("X-GitHub-Api-Version", ApiVersion); @@ -86,7 +86,7 @@ public Task GetRepositoryAsync( string owner, string name, CancellationToken cancellationToken = default) - => SendAsync(HttpMethod.Get, RepositoryPath(owner, name), null, GitHubJsonContext.Default.GitHubRepository, cancellationToken); + => SendAsync(HttpMethod.Get, ApiUrl(RepositoryPath(owner, name)), null, GitHubJsonContext.Default.GitHubRepository, cancellationToken); /// /// Asynchronously creates a release. @@ -105,7 +105,7 @@ public Task CreateReleaseAsync( Guard.IsNotNull(release); return SendAsync( HttpMethod.Post, - RepositoryPath(owner, name) + "/releases", + ApiUrl(RepositoryPath(owner, name) + "/releases"), JsonBody(release, GitHubJsonContext.Default.GitHubNewRelease), GitHubJsonContext.Default.GitHubRelease, cancellationToken); @@ -128,7 +128,7 @@ public Task GenerateReleaseNotesAsync( Guard.IsNotNull(request); return SendAsync( HttpMethod.Post, - RepositoryPath(owner, name) + "/releases/generate-notes", + ApiUrl(RepositoryPath(owner, name) + "/releases/generate-notes"), JsonBody(request, GitHubJsonContext.Default.GitHubReleaseNotesRequest), GitHubJsonContext.Default.GitHubReleaseNotes, cancellationToken); @@ -153,7 +153,7 @@ public Task UpdateReleaseAsync( Guard.IsNotNull(update); return SendAsync( HttpMethod.Patch, - ReleasePath(owner, name, releaseId), + ApiUrl(ReleasePath(owner, name, releaseId)), JsonBody(update, GitHubJsonContext.Default.GitHubReleaseUpdate), cancellationToken); } @@ -171,7 +171,7 @@ public Task DeleteReleaseAsync( string name, long releaseId, CancellationToken cancellationToken = default) - => SendAsync(HttpMethod.Delete, ReleasePath(owner, name, releaseId), null, cancellationToken); + => SendAsync(HttpMethod.Delete, ApiUrl(ReleasePath(owner, name, releaseId)), null, cancellationToken); /// /// Asynchronously gets a value indicating whether a tag exists. @@ -193,14 +193,15 @@ public async Task TagExistsAsync( string tagName, CancellationToken cancellationToken = default) { - using var request = new HttpRequestMessage(HttpMethod.Get, RepositoryPath(owner, name) + "/git/ref/tags/" + EscapeRefName(tagName)); - using var response = await SendCoreAsync(request, cancellationToken).ConfigureAwait(false); + var url = ApiUrl(RepositoryPath(owner, name) + "/git/ref/tags/" + EscapeRefName(tagName)); + using var request = new HttpRequestMessage(HttpMethod.Get, url); + using var response = await SendCoreAsync(request, url, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { return false; } - await EnsureSuccessAsync(request, response, cancellationToken).ConfigureAwait(false); + await EnsureSuccessAsync(HttpMethod.Get, url, response, cancellationToken).ConfigureAwait(false); return true; } @@ -213,7 +214,11 @@ public async Task TagExistsAsync( /// A token that cancels the operation. /// A representing the ongoing operation. public Task DeleteTagAsync(string owner, string name, string tagName, CancellationToken cancellationToken = default) - => SendAsync(HttpMethod.Delete, RepositoryPath(owner, name) + "/git/refs/tags/" + EscapeRefName(tagName), null, cancellationToken); + => SendAsync( + HttpMethod.Delete, + ApiUrl(RepositoryPath(owner, name) + "/git/refs/tags/" + EscapeRefName(tagName)), + null, + cancellationToken); /// /// Asynchronously uploads an asset of a release. @@ -245,7 +250,7 @@ public Task UploadReleaseAssetAsync( var content = new StreamContent(contents); content.Headers.ContentType = mediaType; - return SendAsync(HttpMethod.Post, url.AbsoluteUri, content, cancellationToken); + return SendAsync(HttpMethod.Post, url, content, cancellationToken); } /// @@ -298,7 +303,8 @@ private static StringContent JsonBody(T value, JsonTypeInfo typeInfo) => new(JsonSerializer.Serialize(value, typeInfo), Encoding.UTF8, "application/json"); private static async Task EnsureSuccessAsync( - HttpRequestMessage request, + HttpMethod method, + Uri url, HttpResponseMessage response, CancellationToken cancellationToken) { @@ -310,8 +316,8 @@ private static async Task EnsureSuccessAsync( var message = await ReadErrorMessageAsync(response, cancellationToken).ConfigureAwait(false); var status = string.Create(CultureInfo.InvariantCulture, $"{(int)response.StatusCode} {response.ReasonPhrase}"); throw new BuildFailedException(message is null - ? $"The GitHub API answered {Describe(request)} with {status}." - : $"The GitHub API answered {Describe(request)} with {status}: {message}"); + ? $"The GitHub API answered {Describe(method, url)} with {status}." + : $"The GitHub API answered {Describe(method, url)} with {status}: {message}"); } // A GitHub error answer is JSON with a "message" member. A proxy in front of a server may answer with @@ -340,7 +346,8 @@ private static async Task EnsureSuccessAsync( // The client buffers every answer before SendAsync returns, so reading the body here meets no network. private static async Task ReadAsync( - HttpRequestMessage request, + HttpMethod method, + Uri url, HttpResponseMessage response, JsonTypeInfo typeInfo, CancellationToken cancellationToken) @@ -358,18 +365,22 @@ private static async Task ReadAsync( catch (JsonException exception) { throw new BuildFailedException( - $"The GitHub API answered {Describe(request)} with something bv cannot read: {exception.Message}", + $"The GitHub API answered {Describe(method, url)} with something bv cannot read: {exception.Message}", exception); } - return result ?? throw new BuildFailedException($"The GitHub API answered {Describe(request)} with null."); + return result ?? throw new BuildFailedException($"The GitHub API answered {Describe(method, url)} with null."); } - private static string Describe(HttpRequestMessage request) => $"{request.Method} {request.RequestUri?.AbsoluteUri}"; + private static string Describe(HttpMethod method, Uri url) => $"{method} {url.AbsoluteUri}"; + + // Every request goes to an absolute URL that this type holds and passes along with the request, so that a + // diagnostic names the URL without reading back the nullable HttpRequestMessage.RequestUri. + private Uri ApiUrl(string path) => new(BaseUrl, path); private async Task SendAsync( HttpMethod method, - string url, + Uri url, HttpContent? content, JsonTypeInfo typeInfo, CancellationToken cancellationToken) @@ -377,25 +388,26 @@ private async Task SendAsync( { using var request = new HttpRequestMessage(method, url); request.Content = content; - using var response = await SendCoreAsync(request, cancellationToken).ConfigureAwait(false); - await EnsureSuccessAsync(request, response, cancellationToken).ConfigureAwait(false); - return await ReadAsync(request, response, typeInfo, cancellationToken).ConfigureAwait(false); + using var response = await SendCoreAsync(request, url, cancellationToken).ConfigureAwait(false); + await EnsureSuccessAsync(method, url, response, cancellationToken).ConfigureAwait(false); + return await ReadAsync(method, url, response, typeInfo, cancellationToken).ConfigureAwait(false); } private async Task SendAsync( HttpMethod method, - string url, + Uri url, HttpContent? content, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(method, url); request.Content = content; - using var response = await SendCoreAsync(request, cancellationToken).ConfigureAwait(false); - await EnsureSuccessAsync(request, response, cancellationToken).ConfigureAwait(false); + using var response = await SendCoreAsync(request, url, cancellationToken).ConfigureAwait(false); + await EnsureSuccessAsync(method, url, response, cancellationToken).ConfigureAwait(false); } private async Task SendCoreAsync( HttpRequestMessage request, + Uri url, CancellationToken cancellationToken) { try @@ -404,11 +416,13 @@ private async Task SendCoreAsync( } catch (HttpRequestException exception) { - throw new BuildFailedException($"The GitHub API could not be reached for {Describe(request)}: {exception.Message}", exception); + throw new BuildFailedException( + $"The GitHub API could not be reached for {Describe(request.Method, url)}: {exception.Message}", + exception); } catch (TaskCanceledException exception) when (!cancellationToken.IsCancellationRequested) { - throw new BuildFailedException($"The GitHub API did not answer {Describe(request)} in time.", exception); + throw new BuildFailedException($"The GitHub API did not answer {Describe(request.Method, url)} in time.", exception); } } }