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/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/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. 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/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/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/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/GitHubApiClient.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs new file mode 100644 index 00000000..0e68fac6 --- /dev/null +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubApiClient.cs @@ -0,0 +1,428 @@ +// 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); + 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, ApiUrl(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, + ApiUrl(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, + ApiUrl(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, + ApiUrl(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, ApiUrl(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) + { + 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(HttpMethod.Get, url, 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, + ApiUrl(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, 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( + HttpMethod method, + Uri url, + 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(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 + // anything else, and then the status code is the whole report. + private static async Task ReadErrorMessageAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + GitHubErrorBody? body; + try + { + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + body = await JsonSerializer.DeserializeAsync(stream, GitHubJsonContext.Default.GitHubErrorBody, cancellationToken) + .ConfigureAwait(false); + } + } + 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. + private static async Task ReadAsync( + HttpMethod method, + Uri url, + 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(method, url)} with something bv cannot read: {exception.Message}", + exception); + } + + return result ?? throw new BuildFailedException($"The GitHub API answered {Describe(method, url)} with null."); + } + + 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, + Uri url, + HttpContent? content, + JsonTypeInfo typeInfo, + CancellationToken cancellationToken) + where T : class + { + using var request = new HttpRequestMessage(method, url); + request.Content = content; + 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, + Uri url, + HttpContent? content, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, url); + request.Content = content; + 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 + { + return await _http.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException 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.Method, url)} 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/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs index 5a1c2208..cc0939d7 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,28 +16,28 @@ 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; private readonly VersionService _version; - private readonly GitService _git; + private readonly IGitService _git; private readonly GitHubRepositoryUrls _urls; private readonly string _token; + private readonly GitHubApiClient _client; 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}'."); @@ -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 }); } /// @@ -63,7 +66,7 @@ private GitHubServerAdapter(IServiceProvider services) public override Uri RepositoryUrl => _urls.Repository; /// - /// Always . + /// Always . public override bool IsCloudBuild => true; /// @@ -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); 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) { 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..5a5a1ce2 --- /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 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].Method).IsEqualTo(HttpMethod.Get); + 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() + { + 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); + 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("asset contents"u8.ToArray())); +} 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);