Skip to content

Commit b93b163

Browse files
Jerichogep13
authored andcommitted
(GitTools#89) Include contributors in release notes
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.
1 parent e8006e2 commit b93b163

26 files changed

+496
-13
lines changed

src/Directory.Packages.props

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
1111
<PackageVersion Include="coverlet.msbuild" Version="6.0.4" />
1212
<PackageVersion Include="Destructurama.Attributed" Version="5.1.0" />
13+
<PackageVersion Include="GraphQL.Client" Version="6.0.1" />
14+
<PackageVersion Include="GraphQL.Client.Serializer.SystemTextJson" Version="6.0.1" />
1315
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
1416
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" />
1517
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />

src/GitReleaseManager.Cli/Program.cs

+8
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
using GitReleaseManager.Core.Provider;
1414
using GitReleaseManager.Core.ReleaseNotes;
1515
using GitReleaseManager.Core.Templates;
16+
using GraphQL.Client.Http;
17+
using GraphQL.Client.Serializer.SystemTextJson;
1618
using Microsoft.Extensions.DependencyInjection;
1719
using NGitLab;
1820
using Octokit;
@@ -211,6 +213,12 @@ private static void RegisterVcsProvider(BaseVcsOptions vcsOptions, IServiceColle
211213
// default to Github
212214
serviceCollection
213215
.AddSingleton<IGitHubClient>((_) => new GitHubClient(new ProductHeaderValue("GitReleaseManager")) { Credentials = new Credentials(vcsOptions.Token) })
216+
.AddSingleton<GraphQL.Client.Abstractions.IGraphQLClient>(_ =>
217+
{
218+
var client = new GraphQLHttpClient(new GraphQLHttpClientOptions { EndPoint = new Uri("https://api.github.com/graphql") }, new SystemTextJsonSerializer());
219+
client.HttpClient.DefaultRequestHeaders.Add("Authorization", $"bearer {vcsOptions.Token}");
220+
return client;
221+
})
214222
.AddSingleton<IVcsProvider, GitHubProvider>();
215223
}
216224
}

src/GitReleaseManager.Core/Configuration/Config.cs

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public Config()
2727
ShaSectionHeading = "SHA256 Hashes of the release artifacts",
2828
ShaSectionLineFormat = "- `{1}\t{0}`",
2929
AllowUpdateToPublishedRelease = false,
30+
IncludeContributors = false,
3031
};
3132

3233
Export = new ExportConfig

src/GitReleaseManager.Core/Configuration/CreateConfig.cs

+3
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,8 @@ public class CreateConfig
3434

3535
[YamlMember(Alias = "allow-update-to-published")]
3636
public bool AllowUpdateToPublishedRelease { get; set; }
37+
38+
[YamlMember(Alias = "include-contributors")]
39+
public bool IncludeContributors { get; set; }
3740
}
3841
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text.Json;
5+
6+
namespace GitReleaseManager.Core.Extensions
7+
{
8+
internal static class JsonExtensions
9+
{
10+
/// <summary>
11+
/// Get a JsonElement from a path. Each level in the path is seperated by a dot.
12+
/// </summary>
13+
/// <param name="jsonElement">The parent Json element.</param>
14+
/// <param name="path">The path of the desired child element.</param>
15+
/// <returns>The child element.</returns>
16+
public static JsonElement GetJsonElement(this JsonElement jsonElement, string path)
17+
{
18+
if (jsonElement.ValueKind is JsonValueKind.Null || jsonElement.ValueKind is JsonValueKind.Undefined)
19+
{
20+
return default(JsonElement);
21+
}
22+
23+
string[] segments = path.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries);
24+
25+
foreach (var segment in segments)
26+
{
27+
if (int.TryParse(segment, out var index) && jsonElement.ValueKind == JsonValueKind.Array)
28+
{
29+
jsonElement = jsonElement.EnumerateArray().ElementAtOrDefault(index);
30+
if (jsonElement.ValueKind is JsonValueKind.Null || jsonElement.ValueKind is JsonValueKind.Undefined)
31+
{
32+
return default(JsonElement);
33+
}
34+
35+
continue;
36+
}
37+
38+
jsonElement = jsonElement.TryGetProperty(segment, out var value) ? value : default;
39+
40+
if (jsonElement.ValueKind is JsonValueKind.Null || jsonElement.ValueKind is JsonValueKind.Undefined)
41+
{
42+
return default(JsonElement);
43+
}
44+
}
45+
46+
return jsonElement;
47+
}
48+
49+
/// <summary>
50+
/// Get the first JsonElement matching a path from the provided list of paths.
51+
/// </summary>
52+
/// <param name="jsonElement">The parent Json element.</param>
53+
/// <param name="paths">The path of the desired child element.</param>
54+
/// <returns>The child element.</returns>
55+
public static JsonElement GetFirstJsonElement(this JsonElement jsonElement, IEnumerable<string> paths)
56+
{
57+
if (jsonElement.ValueKind is JsonValueKind.Null || jsonElement.ValueKind is JsonValueKind.Undefined)
58+
{
59+
return default(JsonElement);
60+
}
61+
62+
var element = default(JsonElement);
63+
64+
foreach (var path in paths)
65+
{
66+
element = jsonElement.GetJsonElement(path);
67+
68+
if (element.ValueKind is JsonValueKind.Null || element.ValueKind is JsonValueKind.Undefined)
69+
{
70+
continue;
71+
}
72+
73+
break;
74+
}
75+
76+
return element;
77+
}
78+
79+
public static string GetJsonElementValue(this JsonElement jsonElement) => jsonElement.ValueKind != JsonValueKind.Null &&
80+
jsonElement.ValueKind != JsonValueKind.Undefined
81+
? jsonElement.ToString()
82+
: default;
83+
}
84+
}

src/GitReleaseManager.Core/GitReleaseManager.Core.csproj

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
<ItemGroup>
2020
<PackageReference Include="CommandLineParser" />
2121
<PackageReference Include="Destructurama.Attributed" />
22+
<PackageReference Include="GraphQL.Client" />
23+
<PackageReference Include="GraphQL.Client.Serializer.SystemTextJson" />
2224
<PackageReference Include="Microsoft.SourceLink.GitHub">
2325
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
2426
<PrivateAssets>all</PrivateAssets>

src/GitReleaseManager.Core/MappingProfiles/GitHubProfile.cs

+27-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Text.Json;
23
using AutoMapper;
34
using GitReleaseManager.Core.Extensions;
45

@@ -8,10 +9,11 @@ public class GitHubProfile : Profile
89
{
910
public GitHubProfile()
1011
{
12+
// These mappings convert the result of Octokit queries to model classes
1113
CreateMap<Octokit.Issue, Model.Issue>()
1214
.ForMember(dest => dest.PublicNumber, act => act.MapFrom(src => src.Number))
1315
.ForMember(dest => dest.InternalNumber, act => act.MapFrom(src => src.Id))
14-
.ForMember(dest => dest.IsPullRequest, act => act.MapFrom(src => src.HtmlUrl.IndexOf("/pull/", StringComparison.OrdinalIgnoreCase) >= 0))
16+
.ForMember(dest => dest.IsPullRequest, act => act.MapFrom(src => src.HtmlUrl.Contains("/pull/", StringComparison.OrdinalIgnoreCase)))
1517
.ReverseMap();
1618
CreateMap<Model.IssueComment, Octokit.IssueComment>().ReverseMap();
1719
CreateMap<Model.ItemState, Octokit.ItemState>().ReverseMap();
@@ -23,11 +25,35 @@ public GitHubProfile()
2325
CreateMap<Model.ReleaseAssetUpload, Octokit.ReleaseAssetUpload>().ReverseMap();
2426
CreateMap<Model.Label, Octokit.Label>().ReverseMap();
2527
CreateMap<Model.Label, Octokit.NewLabel>().ReverseMap();
28+
CreateMap<Model.User, Octokit.User>().ReverseMap();
2629
CreateMap<Model.Milestone, Octokit.Milestone>();
2730
CreateMap<Octokit.Milestone, Model.Milestone>()
2831
.ForMember(dest => dest.PublicNumber, act => act.MapFrom(src => src.Number))
2932
.ForMember(dest => dest.InternalNumber, act => act.MapFrom(src => src.Number))
3033
.AfterMap((src, dest) => dest.Version = src.Version());
34+
35+
// These mappings convert the result of GraphQL queries to model classes
36+
CreateMap<JsonElement, Model.Issue>()
37+
.ForMember(dest => dest.PublicNumber, act => act.MapFrom(src => src.GetProperty("number").GetInt32()))
38+
.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).
39+
.ForMember(dest => dest.Title, act => act.MapFrom(src => src.GetProperty("title").GetString()))
40+
.ForMember(dest => dest.HtmlUrl, act => act.MapFrom(src => src.GetProperty("url").GetString()))
41+
.ForMember(dest => dest.IsPullRequest, act => act.MapFrom(src => src.GetProperty("url").GetString().Contains("/pull/", StringComparison.OrdinalIgnoreCase)))
42+
.ForMember(dest => dest.User, act => act.MapFrom(src => src.GetProperty("author")))
43+
.ForMember(dest => dest.Labels, act => act.MapFrom(src => src.GetJsonElement("labels.nodes").EnumerateArray()))
44+
.ReverseMap();
45+
46+
CreateMap<JsonElement, Model.Label>()
47+
.ForMember(dest => dest.Name, act => act.MapFrom(src => src.GetProperty("name").GetString()))
48+
.ForMember(dest => dest.Color, act => act.MapFrom(src => src.GetProperty("color").GetString()))
49+
.ForMember(dest => dest.Description, act => act.MapFrom(src => src.GetProperty("description").GetString()))
50+
.ReverseMap();
51+
52+
CreateMap<JsonElement, Model.User>()
53+
.ForMember(dest => dest.Login, act => act.MapFrom(src => src.GetProperty("login").GetString()))
54+
.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
55+
.ForMember(dest => dest.AvatarUrl, act => act.MapFrom(src => src.GetProperty("avatarUrl").GetString()))
56+
.ReverseMap();
3157
}
3258
}
3359
}

src/GitReleaseManager.Core/Model/Issue.cs

+4
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,9 @@ public sealed class Issue
1515
public IReadOnlyList<Label> Labels { get; set; }
1616

1717
public bool IsPullRequest { get; set; }
18+
19+
public User User { get; set; }
20+
21+
public IReadOnlyList<Issue> LinkedIssues { get; set; }
1822
}
1923
}

src/GitReleaseManager.Core/Model/IssueComment.cs

+5
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,10 @@ public class IssueComment
1111
/// Gets or sets details about the issue comment.
1212
/// </summary>
1313
public string Body { get; set; }
14+
15+
/// <summary>
16+
/// Gets or sets information about the user who made the comment.
17+
/// </summary>
18+
public User User { get; set; }
1419
}
1520
}
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace GitReleaseManager.Core.Model
2+
{
3+
public sealed class User
4+
{
5+
public string Login { get; set; }
6+
7+
public string HtmlUrl { get; set; }
8+
9+
public string AvatarUrl { get; set; }
10+
}
11+
}

src/GitReleaseManager.Core/Provider/GitHubProvider.cs

+114
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22
using System.Collections.Generic;
33
using System.Globalization;
44
using System.Linq;
5+
using System.Text.Json;
56
using System.Threading.Tasks;
67
using AutoMapper;
8+
using GitReleaseManager.Core.Extensions;
9+
using GraphQL.Client.Abstractions;
10+
using GraphQL.Client.Http;
711
using Octokit;
812
using ApiException = GitReleaseManager.Core.Exceptions.ApiException;
913
using ForbiddenException = GitReleaseManager.Core.Exceptions.ForbiddenException;
@@ -26,15 +30,92 @@ public class GitHubProvider : IVcsProvider
2630
private const int PAGE_SIZE = 100;
2731
private const string NOT_FOUND_MESSGAE = "NotFound";
2832

33+
// This query fragment will be executed for issues and pull requests
34+
// because we don't know whether issueNumber refers to an issue or a PR
35+
private const string CLOSING_ISSUES_AND_PULLREQUESTS_GRAPHQL_QUERY = @"
36+
query ClosingIssuesAndPullRequests($repoName: String!, $repoOwner: String!, $issueNumber: Int!, $pageSize: Int!) {
37+
repository(name: $repoName, owner: $repoOwner) {
38+
issue(number: $issueNumber) {
39+
title
40+
id
41+
number
42+
url
43+
labels(first: 100) {
44+
nodes {
45+
name
46+
color
47+
description
48+
}
49+
}
50+
author {
51+
login
52+
avatarUrl
53+
resourcePath
54+
}
55+
closedByPullRequestsReferences(userLinkedOnly: false, includeClosedPrs: true, first: $pageSize) {
56+
nodes {
57+
title
58+
id
59+
number
60+
url
61+
labels(first: 100) {
62+
nodes {
63+
name
64+
color
65+
description
66+
}
67+
}
68+
author {
69+
login
70+
avatarUrl
71+
resourcePath
72+
}
73+
}
74+
}
75+
}
76+
pullRequest(number: $issueNumber) {
77+
number
78+
title
79+
closingIssuesReferences(userLinkedOnly: false, first: $pageSize) {
80+
nodes {
81+
title
82+
id
83+
number
84+
url
85+
labels(first: 100) {
86+
nodes {
87+
name
88+
color
89+
description
90+
}
91+
}
92+
author {
93+
login
94+
avatarUrl
95+
resourcePath
96+
}
97+
}
98+
}
99+
}
100+
}
101+
}";
102+
29103
private readonly IGitHubClient _gitHubClient;
30104
private readonly IMapper _mapper;
105+
private readonly IGraphQLClient _graphQLClient;
31106

32107
public GitHubProvider(IGitHubClient gitHubClient, IMapper mapper)
33108
{
34109
_gitHubClient = gitHubClient;
35110
_mapper = mapper;
36111
}
37112

113+
public GitHubProvider(IGitHubClient gitHubClient, IMapper mapper, IGraphQLClient graphQLClient)
114+
: this(gitHubClient, mapper)
115+
{
116+
_graphQLClient = graphQLClient;
117+
}
118+
38119
public Task DeleteAssetAsync(string owner, string repository, ReleaseAsset asset)
39120
{
40121
return GitHubProvider.ExecuteAsync(async () =>
@@ -356,6 +437,39 @@ public string GetIssueType(Issue issue)
356437
return issue.IsPullRequest ? "Pull Request" : "Issue";
357438
}
358439

440+
public async Task<Issue[]> GetLinkedIssuesAsync(string owner, string repository, Issue issue)
441+
{
442+
ArgumentNullException.ThrowIfNull(_graphQLClient, nameof(_graphQLClient));
443+
ArgumentNullException.ThrowIfNull(issue, nameof(issue));
444+
445+
var request = new GraphQLHttpRequest
446+
{
447+
Query = CLOSING_ISSUES_AND_PULLREQUESTS_GRAPHQL_QUERY.Replace("\r\n", string.Empty, StringComparison.OrdinalIgnoreCase),
448+
Variables = new
449+
{
450+
pageSize = PAGE_SIZE,
451+
repoName = repository,
452+
repoOwner = owner,
453+
issueNumber = issue.PublicNumber,
454+
},
455+
};
456+
457+
var graphQLResponse = await _graphQLClient.SendQueryAsync<dynamic>(request).ConfigureAwait(false);
458+
459+
var nodes = ((JsonElement)graphQLResponse.Data).GetFirstJsonElement(new[]
460+
{
461+
"repository.issue.closedByPullRequestsReferences.nodes", // If issue.PublicNumber represents an issue, retrieve the linked PRs
462+
"repository.pullRequest.closingIssuesReferences.nodes", // If issue.PublicNumber represents a PR, retrieve the linked issues
463+
});
464+
465+
using var enumerator = nodes.EnumerateArray();
466+
var linkedIssues = enumerator
467+
.Select(element => _mapper.Map<Issue>(element))
468+
.ToArray();
469+
470+
return linkedIssues;
471+
}
472+
359473
private static async Task ExecuteAsync(Func<Task> action)
360474
{
361475
try

0 commit comments

Comments
 (0)