From b93b163728226d7a8e39abfe34f72ff436eccca9 Mon Sep 17 00:00:00 2001 From: jericho Date: Thu, 2 Nov 2023 12:18:01 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=EF=BB=BF(#89)=20Include=20contributors=20i?= =?UTF-8?q?n=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds the ability to query for, and add, information about the contributors for linked issues and PR's into the generated release notes. This is made possible via a new `include-contributors` option in the create section of the GitReleaseManager.yaml file. This is false by default. In addition, a new scriban template has been created, so allow complete segregation between release notes that have contributors, and those that don't. This was done mainly to allow better maintainability going forward, and to reduce the complexity of the default template. This has been implemented for both GitHug and GitLab. For GitHub, it was necessary to use GraphQL to get the necessary information, where as with GitLab, the required information could be returned via the REST API. --- src/Directory.Packages.props | 2 + src/GitReleaseManager.Cli/Program.cs | 8 ++ .../Configuration/Config.cs | 1 + .../Configuration/CreateConfig.cs | 3 + .../Extensions/JsonExtensions.cs | 84 +++++++++++++ .../GitReleaseManager.Core.csproj | 2 + .../MappingProfiles/GitHubProfile.cs | 28 ++++- src/GitReleaseManager.Core/Model/Issue.cs | 4 + .../Model/IssueComment.cs | 5 + src/GitReleaseManager.Core/Model/User.cs | 11 ++ .../Provider/GitHubProvider.cs | 114 ++++++++++++++++++ .../Provider/GitLabProvider.cs | 19 +++ .../Provider/IVcsProvider.cs | 4 +- .../ReleaseNotes/ReleaseNotesBuilder.cs | 39 ++++++ .../contributors/contributor-details.sbn | 1 + .../Templates/contributors/contributors.sbn | 9 ++ .../Templates/contributors/create/footer.sbn | 10 ++ .../Templates/contributors/index.sbn | 13 ++ .../Templates/contributors/issue-details.sbn | 5 + .../Templates/contributors/issue-note.sbn | 45 +++++++ .../Templates/contributors/issues.sbn | 3 + .../Templates/contributors/milestone.sbn | 2 + .../Templates/contributors/release-info.sbn | 10 ++ src/GitReleaseManager.Core/VcsService.cs | 4 +- .../GitHubProviderIntegrationTests.cs | 41 ++++++- .../ReleaseNotesBuilderIntegrationTests.cs | 42 +++++-- 26 files changed, 496 insertions(+), 13 deletions(-) create mode 100644 src/GitReleaseManager.Core/Extensions/JsonExtensions.cs create mode 100644 src/GitReleaseManager.Core/Model/User.cs create mode 100644 src/GitReleaseManager.Core/Templates/contributors/contributor-details.sbn create mode 100644 src/GitReleaseManager.Core/Templates/contributors/contributors.sbn create mode 100644 src/GitReleaseManager.Core/Templates/contributors/create/footer.sbn create mode 100644 src/GitReleaseManager.Core/Templates/contributors/index.sbn create mode 100644 src/GitReleaseManager.Core/Templates/contributors/issue-details.sbn create mode 100644 src/GitReleaseManager.Core/Templates/contributors/issue-note.sbn create mode 100644 src/GitReleaseManager.Core/Templates/contributors/issues.sbn create mode 100644 src/GitReleaseManager.Core/Templates/contributors/milestone.sbn create mode 100644 src/GitReleaseManager.Core/Templates/contributors/release-info.sbn diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index ae2c6696..82a68959 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -10,6 +10,8 @@ + + diff --git a/src/GitReleaseManager.Cli/Program.cs b/src/GitReleaseManager.Cli/Program.cs index dc44e89c..96280e1c 100644 --- a/src/GitReleaseManager.Cli/Program.cs +++ b/src/GitReleaseManager.Cli/Program.cs @@ -13,6 +13,8 @@ using GitReleaseManager.Core.Provider; using GitReleaseManager.Core.ReleaseNotes; using GitReleaseManager.Core.Templates; +using GraphQL.Client.Http; +using GraphQL.Client.Serializer.SystemTextJson; using Microsoft.Extensions.DependencyInjection; using NGitLab; using Octokit; @@ -211,6 +213,12 @@ private static void RegisterVcsProvider(BaseVcsOptions vcsOptions, IServiceColle // default to Github serviceCollection .AddSingleton((_) => new GitHubClient(new ProductHeaderValue("GitReleaseManager")) { Credentials = new Credentials(vcsOptions.Token) }) + .AddSingleton(_ => + { + var client = new GraphQLHttpClient(new GraphQLHttpClientOptions { EndPoint = new Uri("https://api.github.com/graphql") }, new SystemTextJsonSerializer()); + client.HttpClient.DefaultRequestHeaders.Add("Authorization", $"bearer {vcsOptions.Token}"); + return client; + }) .AddSingleton(); } } diff --git a/src/GitReleaseManager.Core/Configuration/Config.cs b/src/GitReleaseManager.Core/Configuration/Config.cs index 149e7fe6..262c12bc 100644 --- a/src/GitReleaseManager.Core/Configuration/Config.cs +++ b/src/GitReleaseManager.Core/Configuration/Config.cs @@ -27,6 +27,7 @@ public Config() ShaSectionHeading = "SHA256 Hashes of the release artifacts", ShaSectionLineFormat = "- `{1}\t{0}`", AllowUpdateToPublishedRelease = false, + IncludeContributors = false, }; Export = new ExportConfig diff --git a/src/GitReleaseManager.Core/Configuration/CreateConfig.cs b/src/GitReleaseManager.Core/Configuration/CreateConfig.cs index 512e4a28..00baed7d 100644 --- a/src/GitReleaseManager.Core/Configuration/CreateConfig.cs +++ b/src/GitReleaseManager.Core/Configuration/CreateConfig.cs @@ -34,5 +34,8 @@ public class CreateConfig [YamlMember(Alias = "allow-update-to-published")] public bool AllowUpdateToPublishedRelease { get; set; } + + [YamlMember(Alias = "include-contributors")] + public bool IncludeContributors { get; set; } } } \ No newline at end of file diff --git a/src/GitReleaseManager.Core/Extensions/JsonExtensions.cs b/src/GitReleaseManager.Core/Extensions/JsonExtensions.cs new file mode 100644 index 00000000..d8920c65 --- /dev/null +++ b/src/GitReleaseManager.Core/Extensions/JsonExtensions.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +namespace GitReleaseManager.Core.Extensions +{ + internal static class JsonExtensions + { + /// + /// Get a JsonElement from a path. Each level in the path is seperated by a dot. + /// + /// The parent Json element. + /// The path of the desired child element. + /// The child element. + public static JsonElement GetJsonElement(this JsonElement jsonElement, string path) + { + if (jsonElement.ValueKind is JsonValueKind.Null || jsonElement.ValueKind is JsonValueKind.Undefined) + { + return default(JsonElement); + } + + string[] segments = path.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var segment in segments) + { + if (int.TryParse(segment, out var index) && jsonElement.ValueKind == JsonValueKind.Array) + { + jsonElement = jsonElement.EnumerateArray().ElementAtOrDefault(index); + if (jsonElement.ValueKind is JsonValueKind.Null || jsonElement.ValueKind is JsonValueKind.Undefined) + { + return default(JsonElement); + } + + continue; + } + + jsonElement = jsonElement.TryGetProperty(segment, out var value) ? value : default; + + if (jsonElement.ValueKind is JsonValueKind.Null || jsonElement.ValueKind is JsonValueKind.Undefined) + { + return default(JsonElement); + } + } + + return jsonElement; + } + + /// + /// Get the first JsonElement matching a path from the provided list of paths. + /// + /// The parent Json element. + /// The path of the desired child element. + /// The child element. + public static JsonElement GetFirstJsonElement(this JsonElement jsonElement, IEnumerable paths) + { + if (jsonElement.ValueKind is JsonValueKind.Null || jsonElement.ValueKind is JsonValueKind.Undefined) + { + return default(JsonElement); + } + + var element = default(JsonElement); + + foreach (var path in paths) + { + element = jsonElement.GetJsonElement(path); + + if (element.ValueKind is JsonValueKind.Null || element.ValueKind is JsonValueKind.Undefined) + { + continue; + } + + break; + } + + return element; + } + + public static string GetJsonElementValue(this JsonElement jsonElement) => jsonElement.ValueKind != JsonValueKind.Null && + jsonElement.ValueKind != JsonValueKind.Undefined + ? jsonElement.ToString() + : default; + } +} diff --git a/src/GitReleaseManager.Core/GitReleaseManager.Core.csproj b/src/GitReleaseManager.Core/GitReleaseManager.Core.csproj index 350a0959..7e614e0c 100644 --- a/src/GitReleaseManager.Core/GitReleaseManager.Core.csproj +++ b/src/GitReleaseManager.Core/GitReleaseManager.Core.csproj @@ -19,6 +19,8 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/GitReleaseManager.Core/MappingProfiles/GitHubProfile.cs b/src/GitReleaseManager.Core/MappingProfiles/GitHubProfile.cs index dff999dc..11e90526 100644 --- a/src/GitReleaseManager.Core/MappingProfiles/GitHubProfile.cs +++ b/src/GitReleaseManager.Core/MappingProfiles/GitHubProfile.cs @@ -1,4 +1,5 @@ using System; +using System.Text.Json; using AutoMapper; using GitReleaseManager.Core.Extensions; @@ -8,10 +9,11 @@ public class GitHubProfile : Profile { public GitHubProfile() { + // These mappings convert the result of Octokit queries to model classes CreateMap() .ForMember(dest => dest.PublicNumber, act => act.MapFrom(src => src.Number)) .ForMember(dest => dest.InternalNumber, act => act.MapFrom(src => src.Id)) - .ForMember(dest => dest.IsPullRequest, act => act.MapFrom(src => src.HtmlUrl.IndexOf("/pull/", StringComparison.OrdinalIgnoreCase) >= 0)) + .ForMember(dest => dest.IsPullRequest, act => act.MapFrom(src => src.HtmlUrl.Contains("/pull/", StringComparison.OrdinalIgnoreCase))) .ReverseMap(); CreateMap().ReverseMap(); CreateMap().ReverseMap(); @@ -23,11 +25,35 @@ public GitHubProfile() CreateMap().ReverseMap(); CreateMap().ReverseMap(); CreateMap().ReverseMap(); + CreateMap().ReverseMap(); CreateMap(); CreateMap() .ForMember(dest => dest.PublicNumber, act => act.MapFrom(src => src.Number)) .ForMember(dest => dest.InternalNumber, act => act.MapFrom(src => src.Number)) .AfterMap((src, dest) => dest.Version = src.Version()); + + // These mappings convert the result of GraphQL queries to model classes + CreateMap() + .ForMember(dest => dest.PublicNumber, act => act.MapFrom(src => src.GetProperty("number").GetInt32())) + .ForMember(dest => dest.InternalNumber, act => act.MapFrom(src => -1)) // Not available in graphQL (there's a "id" property but it contains a string which represents the Node ID of the object). + .ForMember(dest => dest.Title, act => act.MapFrom(src => src.GetProperty("title").GetString())) + .ForMember(dest => dest.HtmlUrl, act => act.MapFrom(src => src.GetProperty("url").GetString())) + .ForMember(dest => dest.IsPullRequest, act => act.MapFrom(src => src.GetProperty("url").GetString().Contains("/pull/", StringComparison.OrdinalIgnoreCase))) + .ForMember(dest => dest.User, act => act.MapFrom(src => src.GetProperty("author"))) + .ForMember(dest => dest.Labels, act => act.MapFrom(src => src.GetJsonElement("labels.nodes").EnumerateArray())) + .ReverseMap(); + + CreateMap() + .ForMember(dest => dest.Name, act => act.MapFrom(src => src.GetProperty("name").GetString())) + .ForMember(dest => dest.Color, act => act.MapFrom(src => src.GetProperty("color").GetString())) + .ForMember(dest => dest.Description, act => act.MapFrom(src => src.GetProperty("description").GetString())) + .ReverseMap(); + + CreateMap() + .ForMember(dest => dest.Login, act => act.MapFrom(src => src.GetProperty("login").GetString())) + .ForMember(dest => dest.HtmlUrl, act => act.MapFrom(src => $"https://github.com{src.GetProperty("resourcePath").GetString()}")) // The resourcePath contains a value similar to "/jericho". That's why we must manually prepend "https://github.com + .ForMember(dest => dest.AvatarUrl, act => act.MapFrom(src => src.GetProperty("avatarUrl").GetString())) + .ReverseMap(); } } } \ No newline at end of file diff --git a/src/GitReleaseManager.Core/Model/Issue.cs b/src/GitReleaseManager.Core/Model/Issue.cs index 222d8eb9..5ad73c7c 100644 --- a/src/GitReleaseManager.Core/Model/Issue.cs +++ b/src/GitReleaseManager.Core/Model/Issue.cs @@ -15,5 +15,9 @@ public sealed class Issue public IReadOnlyList