Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(#89) Add ability to populate release notes with information about contributors #541

Open
wants to merge 30 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e1d0eec
Add the 'User' to when fetching issues and display the name of the a…
Jericho Nov 2, 2023
09672e3
Get linked issue
Jericho Nov 5, 2023
15877ec
Contributors
Jericho Nov 5, 2023
0743437
Guard against null _gitHubClient (which happens during unit testing)
Jericho Nov 6, 2023
0db09e7
Include contributors section in release notes only if there is at lea…
Jericho Nov 6, 2023
90a9d67
Fixes due to recent rebase
Jericho Nov 12, 2023
1e41fe8
Account for the possibility of multiple linked issues/PRs
Jericho Nov 13, 2023
65ae437
Implement GetLinkedIssuesAsync in GitLab provider
Jericho Nov 13, 2023
73d4283
Get contributors from multiple linked issues/PRs
Jericho Nov 13, 2023
0e8ca36
Linq extension method no longer necessary since the switch to .NET 6
Jericho Nov 15, 2023
da5dee3
Fix the issue where contributors associated with filtered-out issues …
Jericho Nov 15, 2023
133afc8
Include linked issues in an issue's notes
Jericho Nov 15, 2023
04ccc8f
Fix issue in Scriban script that caused the number of linked issues t…
Jericho Nov 16, 2023
bba1b73
Improve the contributor-details template to make the contributors ava…
Jericho Dec 8, 2023
23b43c9
Improve issue-note template: "resolved in XXX by XYZ" rather than "re…
Jericho Dec 8, 2023
f1f80ab
Restore the space separator between collaborators
Jericho Dec 8, 2023
e712f24
Remove unnecessary line feed at the end of the contributor-details te…
Jericho Dec 8, 2023
a8f0cee
Merge branch 'develop' into collaborators
Jericho Jul 10, 2024
1c60283
Use NUnit's new assertion syntax
Jericho Jul 10, 2024
272bd03
Revert changes made to the default template and instead create a dist…
Jericho Jul 14, 2024
44d7c93
Make sure the SingleMilestone integration test is sensitive to the fa…
Jericho Jul 14, 2024
18ccbbd
Set IncludeContributors to false by default
Jericho Jul 14, 2024
a1f4508
Fix incorrect XML comment comments
Jericho Jul 17, 2024
e6a02c4
Convenient extension method to search for a node in a json doc that c…
Jericho Jul 19, 2024
e1889ba
Improve GraphQL query
Jericho Jul 19, 2024
8b0ed6c
Implement suggestion from the code quality analyzer: Calls to 'string…
Jericho Jul 19, 2024
016f5dc
Fix typo
Jericho Jul 19, 2024
677cecd
No need to check IncludeContributors to execute simple Linq query
Jericho Jul 19, 2024
3580754
Implement @gep13 suggestion when fetching related issues/PRs in the G…
Jericho Jul 19, 2024
cc33ed0
Injectable GraphQlClient
Jericho Jul 20, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/GitReleaseManager.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -211,6 +213,12 @@ private static void RegisterVcsProvider(BaseVcsOptions vcsOptions, IServiceColle
// default to Github
serviceCollection
.AddSingleton<IGitHubClient>((_) => new GitHubClient(new ProductHeaderValue("GitReleaseManager")) { Credentials = new Credentials(vcsOptions.Token) })
.AddSingleton<GraphQL.Client.Abstractions.IGraphQLClient>(_ =>
{
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<IVcsProvider, GitHubProvider>();
}
}
Expand Down
1 change: 1 addition & 0 deletions src/GitReleaseManager.Core/Configuration/Config.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public Config()
ShaSectionHeading = "SHA256 Hashes of the release artifacts",
ShaSectionLineFormat = "- `{1}\t{0}`",
AllowUpdateToPublishedRelease = false,
IncludeContributors = false,
};

Export = new ExportConfig
Expand Down
3 changes: 3 additions & 0 deletions src/GitReleaseManager.Core/Configuration/CreateConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
}
84 changes: 84 additions & 0 deletions src/GitReleaseManager.Core/Extensions/JsonExtensions.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Get a JsonElement from a path. Each level in the path is seperated by a dot.
/// </summary>
/// <param name="jsonElement">The parent Json element.</param>
/// <param name="path">The path of the desired child element.</param>
/// <returns>The child element.</returns>
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;
}

/// <summary>
/// Get the first JsonElement matching a path from the provided list of paths.
/// </summary>
/// <param name="jsonElement">The parent Json element.</param>
/// <param name="paths">The path of the desired child element.</param>
/// <returns>The child element.</returns>
public static JsonElement GetFirstJsonElement(this JsonElement jsonElement, IEnumerable<string> 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;
}
}
2 changes: 2 additions & 0 deletions src/GitReleaseManager.Core/GitReleaseManager.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Destructurama.Attributed" Version="4.0.0" />
<PackageReference Include="GraphQL.Client" Version="6.0.1" />
<PackageReference Include="GraphQL.Client.Serializer.SystemTextJson" Version="6.0.1" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
Expand Down
28 changes: 27 additions & 1 deletion src/GitReleaseManager.Core/MappingProfiles/GitHubProfile.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Text.Json;
using AutoMapper;
using GitReleaseManager.Core.Extensions;

Expand All @@ -8,10 +9,11 @@ public class GitHubProfile : Profile
{
public GitHubProfile()
{
// These mappings convert the result of Octokit queries to model classes
CreateMap<Octokit.Issue, Model.Issue>()
.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<Model.IssueComment, Octokit.IssueComment>().ReverseMap();
CreateMap<Model.ItemState, Octokit.ItemState>().ReverseMap();
Expand All @@ -23,11 +25,35 @@ public GitHubProfile()
CreateMap<Model.ReleaseAssetUpload, Octokit.ReleaseAssetUpload>().ReverseMap();
CreateMap<Model.Label, Octokit.Label>().ReverseMap();
CreateMap<Model.Label, Octokit.NewLabel>().ReverseMap();
CreateMap<Model.User, Octokit.User>().ReverseMap();
CreateMap<Model.Milestone, Octokit.Milestone>();
CreateMap<Octokit.Milestone, Model.Milestone>()
.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<JsonElement, Model.Issue>()
.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<JsonElement, Model.Label>()
.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<JsonElement, Model.User>()
.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();
}
}
}
4 changes: 4 additions & 0 deletions src/GitReleaseManager.Core/Model/Issue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@

public int PublicNumber { get; set; }

public string HtmlUrl { get; set; }

Check warning on line 13 in src/GitReleaseManager.Core/Model/Issue.cs

View workflow job for this annotation

GitHub Actions / build (windows-2022)

Change the type of property 'Issue.HtmlUrl' from 'string' to 'System.Uri' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1056)

Check warning on line 13 in src/GitReleaseManager.Core/Model/Issue.cs

View workflow job for this annotation

GitHub Actions / build (windows-2022)

Change the type of property 'Issue.HtmlUrl' from 'string' to 'System.Uri' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1056)

public IReadOnlyList<Label> Labels { get; set; }

public bool IsPullRequest { get; set; }

public User User { get; set; }

public IReadOnlyList<Issue> LinkedIssues { get; set; }
}
}
5 changes: 5 additions & 0 deletions src/GitReleaseManager.Core/Model/IssueComment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,10 @@ public class IssueComment
/// Gets or sets details about the issue comment.
/// </summary>
public string Body { get; set; }

/// <summary>
/// Gets or sets information about the user who made the comment.
/// </summary>
public User User { get; set; }
}
}
11 changes: 11 additions & 0 deletions src/GitReleaseManager.Core/Model/User.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace GitReleaseManager.Core.Model
{
public sealed class User
{
public string Login { get; set; }

public string HtmlUrl { get; set; }

public string AvatarUrl { get; set; }
}
}
114 changes: 114 additions & 0 deletions src/GitReleaseManager.Core/Provider/GitHubProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using AutoMapper;
using GitReleaseManager.Core.Extensions;
using GraphQL.Client.Abstractions;
using GraphQL.Client.Http;
using Octokit;
using ApiException = GitReleaseManager.Core.Exceptions.ApiException;
using ForbiddenException = GitReleaseManager.Core.Exceptions.ForbiddenException;
Expand All @@ -26,15 +30,92 @@ public class GitHubProvider : IVcsProvider
private const int PAGE_SIZE = 100;
private const string NOT_FOUND_MESSGAE = "NotFound";

// This query fragment will be executed for issues and pull requests
// because we don't know whether issueNumber refers to an issue or a PR
private const string CLOSING_ISSUES_AND_PULLREQUESTS_GRAPHQL_QUERY = @"
query ClosingIssuesAndPullRequests($repoName: String!, $repoOwner: String!, $issueNumber: Int!, $pageSize: Int!) {
repository(name: $repoName, owner: $repoOwner) {
issue(number: $issueNumber) {
title
id
number
url
labels(first: 100) {
nodes {
name
color
description
}
}
author {
login
avatarUrl
resourcePath
}
closedByPullRequestsReferences(userLinkedOnly: false, includeClosedPrs: true, first: $pageSize) {
nodes {
title
id
number
url
labels(first: 100) {
nodes {
name
color
description
}
}
author {
login
avatarUrl
resourcePath
}
}
}
}
pullRequest(number: $issueNumber) {
number
title
closingIssuesReferences(userLinkedOnly: false, first: $pageSize) {
nodes {
title
id
number
url
labels(first: 100) {
nodes {
name
color
description
}
}
author {
login
avatarUrl
resourcePath
}
}
}
}
}
}";

private readonly IGitHubClient _gitHubClient;
private readonly IMapper _mapper;
private readonly IGraphQLClient _graphQLClient;

public GitHubProvider(IGitHubClient gitHubClient, IMapper mapper)
{
_gitHubClient = gitHubClient;
_mapper = mapper;
}

public GitHubProvider(IGitHubClient gitHubClient, IMapper mapper, IGraphQLClient graphQLClient)
: this(gitHubClient, mapper)
{
_graphQLClient = graphQLClient;
}

public Task DeleteAssetAsync(string owner, string repository, ReleaseAsset asset)
{
return ExecuteAsync(async () =>
Expand Down Expand Up @@ -356,6 +437,39 @@ public string GetIssueType(Issue issue)
return issue.IsPullRequest ? "Pull Request" : "Issue";
}

public async Task<Issue[]> GetLinkedIssuesAsync(string owner, string repository, Issue issue)
{
ArgumentNullException.ThrowIfNull(_graphQLClient, nameof(_graphQLClient));
ArgumentNullException.ThrowIfNull(issue, nameof(issue));

var request = new GraphQLHttpRequest
{
Query = CLOSING_ISSUES_AND_PULLREQUESTS_GRAPHQL_QUERY.Replace("\r\n", string.Empty, StringComparison.OrdinalIgnoreCase),
Variables = new
{
pageSize = PAGE_SIZE,
repoName = repository,
repoOwner = owner,
issueNumber = issue.PublicNumber,
},
};

var graphQLResponse = await _graphQLClient.SendQueryAsync<dynamic>(request).ConfigureAwait(false);

var nodes = ((JsonElement)graphQLResponse.Data).GetFirstJsonElement(new[]
{
"repository.issue.closedByPullRequestsReferences.nodes", // If issue.PublicNumber represents an issue, retrieve the linked PRs
"repository.pullRequest.closingIssuesReferences.nodes", // If issue.PublicNumber represents a PR, retrieve the linked issues
});

using var enumerator = nodes.EnumerateArray();
var linkedIssues = enumerator
.Select(element => _mapper.Map<Issue>(element))
.ToArray();

return linkedIssues;
}

private async Task ExecuteAsync(Func<Task> action)
{
try
Expand Down
19 changes: 19 additions & 0 deletions src/GitReleaseManager.Core/Provider/GitLabProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,25 @@ public string GetIssueType(Issue issue)
return issue.IsPullRequest ? "Merge Request" : "Issue";
}

public Task<Issue[]> GetLinkedIssuesAsync(string owner, string repository, Issue issue)
{
return ExecuteAsync(() =>
{
if (issue.IsPullRequest)
{
var closes = _gitLabClient.MergeRequests.ClosesIssues(issue.PublicNumber);
var issues = _mapper.Map<IEnumerable<Issue>>(closes);
return Task.FromResult(issues.ToArray());
}
else
{
var relatedTo = _gitLabClient.Issues.RelatedTo(GetGitLabProjectId(owner, repository), issue.PublicNumber);
var issues = _mapper.Map<IEnumerable<Issue>>(relatedTo);
return Task.FromResult(issues.ToArray());
}
});
}

private int GetGitLabProjectId(string owner, string repository)
{
if (_projectId.HasValue)
Expand Down
Loading
Loading