Skip to content

Commit 403dcc6

Browse files
committed
Get linked issue
1 parent 059296d commit 403dcc6

File tree

8 files changed

+191
-1
lines changed

8 files changed

+191
-1
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using System;
2+
using System.Linq;
3+
using System.Text.Json;
4+
5+
namespace GitReleaseManager.Core.Extensions
6+
{
7+
internal static class JsonExtensions
8+
{
9+
public static JsonElement GetJsonElement(this JsonElement jsonElement, string path)
10+
{
11+
if (jsonElement.ValueKind is JsonValueKind.Null || jsonElement.ValueKind is JsonValueKind.Undefined)
12+
{
13+
return default(JsonElement);
14+
}
15+
16+
string[] segments = path.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries);
17+
18+
foreach (var segment in segments)
19+
{
20+
if (int.TryParse(segment, out var index) && jsonElement.ValueKind == JsonValueKind.Array)
21+
{
22+
jsonElement = jsonElement.EnumerateArray().ElementAtOrDefault(index);
23+
if (jsonElement.ValueKind is JsonValueKind.Null || jsonElement.ValueKind is JsonValueKind.Undefined)
24+
{
25+
return default(JsonElement);
26+
}
27+
28+
continue;
29+
}
30+
31+
jsonElement = jsonElement.TryGetProperty(segment, out var value) ? value : default;
32+
33+
if (jsonElement.ValueKind is JsonValueKind.Null || jsonElement.ValueKind is JsonValueKind.Undefined)
34+
{
35+
return default(JsonElement);
36+
}
37+
}
38+
39+
return jsonElement;
40+
}
41+
42+
public static string GetJsonElementValue(this JsonElement jsonElement) => jsonElement.ValueKind != JsonValueKind.Null &&
43+
jsonElement.ValueKind != JsonValueKind.Undefined
44+
? jsonElement.ToString()
45+
: default;
46+
}
47+
}

src/GitReleaseManager.Core/GitReleaseManager.Core.csproj

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

src/GitReleaseManager.Core/Model/Issue.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,7 @@ public sealed class Issue
1717
public bool IsPullRequest { get; set; }
1818

1919
public User User { get; set; }
20+
21+
public Issue LinkedIssue { get; set; }
2022
}
2123
}

src/GitReleaseManager.Core/Provider/GitHubProvider.cs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
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;
11+
using GraphQL.Client.Serializer.SystemTextJson;
712
using Octokit;
813
using ApiException = GitReleaseManager.Core.Exceptions.ApiException;
914
using ForbiddenException = GitReleaseManager.Core.Exceptions.ForbiddenException;
@@ -26,13 +31,62 @@ public class GitHubProvider : IVcsProvider
2631
private const int PAGE_SIZE = 100;
2732
private const string NOT_FOUND_MESSGAE = "NotFound";
2833

34+
// This query fragment will be executed for issues and pull requests
35+
// because we don't know whether issueNumber refers to an issue or a PR
36+
private const string CONNECT_AND_DISCONNECT_EVENTS_GRAPHQL_QUERY_FRAGMENT = @"
37+
{0}(number: $issueNumber) {{
38+
timelineItems(first: $pageSize, itemTypes: [CONNECTED_EVENT, DISCONNECTED_EVENT]) {{
39+
nodes {{
40+
__typename,
41+
...on ConnectedEvent {{
42+
createdAt,
43+
id,
44+
source {{
45+
__typename,
46+
... on Issue {{
47+
number
48+
}}
49+
... on PullRequest {{
50+
number
51+
}}
52+
}},
53+
subject {{
54+
__typename
55+
... on Issue {{
56+
number
57+
}}
58+
... on PullRequest {{
59+
number
60+
}}
61+
}}
62+
}}
63+
...on DisconnectedEvent {{
64+
createdAt,
65+
}}
66+
}}
67+
}}
68+
}}";
69+
70+
private const string CONNECT_AND_DISCONNECT_EVENTS_GRAPHQL_QUERY = @"
71+
query ConnectAndDisconnectEvents($repoName: String!, $repoOwner: String!, $issueNumber: Int!, $pageSize: Int!) {{
72+
repository(name: $repoName, owner: $repoOwner) {{
73+
{0},
74+
{1}
75+
}}
76+
}}";
77+
2978
private readonly IGitHubClient _gitHubClient;
3079
private readonly IMapper _mapper;
80+
private readonly IGraphQLClient _graphQLClient;
3181

3282
public GitHubProvider(IGitHubClient gitHubClient, IMapper mapper)
3383
{
3484
_gitHubClient = gitHubClient;
3585
_mapper = mapper;
86+
87+
var graphQLClient = new GraphQLHttpClient(new GraphQLHttpClientOptions { EndPoint = new Uri("https://api.github.com/graphql") }, new SystemTextJsonSerializer());
88+
graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"bearer {_gitHubClient.Connection.Credentials.Password}");
89+
_graphQLClient = graphQLClient;
3690
}
3791

3892
public Task DeleteAssetAsync(string owner, string repository, ReleaseAsset asset)
@@ -356,6 +410,72 @@ public string GetIssueType(Issue issue)
356410
return issue.IsPullRequest ? "Pull Request" : "Issue";
357411
}
358412

413+
public async Task<Issue> GetLinkedIssueAsync(string owner, string repository, int issueNumber)
414+
{
415+
var graphQLQuery = string.Format(CultureInfo.InvariantCulture, CONNECT_AND_DISCONNECT_EVENTS_GRAPHQL_QUERY,
416+
string.Format(CultureInfo.InvariantCulture, CONNECT_AND_DISCONNECT_EVENTS_GRAPHQL_QUERY_FRAGMENT, "issue"),
417+
string.Format(CultureInfo.InvariantCulture, CONNECT_AND_DISCONNECT_EVENTS_GRAPHQL_QUERY_FRAGMENT, "pullRequest"));
418+
419+
var request = new GraphQLHttpRequest
420+
{
421+
Query = graphQLQuery.Replace("\r\n", string.Empty),
422+
Variables = new
423+
{
424+
pageSize = PAGE_SIZE,
425+
repoName = repository,
426+
repoOwner = owner,
427+
issueNumber = issueNumber,
428+
},
429+
};
430+
431+
var graphQLResponse = await _graphQLClient.SendQueryAsync<dynamic>(request).ConfigureAwait(false);
432+
433+
var rootNode = (JsonElement)graphQLResponse.Data;
434+
var issueNode = rootNode.GetJsonElement("repository.issue");
435+
if (issueNode.ValueKind == JsonValueKind.Null || issueNode.ValueKind == JsonValueKind.Undefined)
436+
{
437+
issueNode = rootNode.GetJsonElement("repository.pullRequest");
438+
}
439+
440+
if (issueNode.ValueKind == JsonValueKind.Null || issueNode.ValueKind == JsonValueKind.Undefined)
441+
{
442+
throw new NotFoundException($"Unable to find issue/pull request {issueNumber}");
443+
}
444+
445+
var nodes = issueNode.GetJsonElement("timelineItems.nodes");
446+
var sortedNodes = nodes.EnumerateArray().OrderByDescending(n => n.GetJsonElement("createdAt").GetDateTime());
447+
var mostRecentConnectedEvent = sortedNodes.FirstOrDefault(n => n.GetJsonElement("__typename").GetString() == "ConnectedEvent");
448+
var mostRecentDisconnectedEvent = sortedNodes.FirstOrDefault(n => n.GetJsonElement("__typename").GetString() == "DisconnectedEvent");
449+
450+
// Make sure we found an event that indicates that an issue/PR was linked to this issue/PR
451+
if (mostRecentConnectedEvent.ValueKind == JsonValueKind.Null || mostRecentConnectedEvent.ValueKind == JsonValueKind.Undefined)
452+
{
453+
return null;
454+
}
455+
456+
// We found an event indicating that an issue was linked. Make sure it wasn't un-linked
457+
else if (mostRecentDisconnectedEvent.ValueKind == JsonValueKind.Null || mostRecentDisconnectedEvent.ValueKind == JsonValueKind.Undefined)
458+
{
459+
var linkedIssueNumber = mostRecentConnectedEvent.GetJsonElement("subject.number").GetInt32();
460+
var issue = await _gitHubClient.Issue.Get(owner, repository, linkedIssueNumber).ConfigureAwait(false);
461+
return _mapper.Map<Issue>(issue);
462+
}
463+
464+
// We found a linked issue and a disconnection event. Check which one is the most recent
465+
else if (mostRecentDisconnectedEvent.GetJsonElement("createdAt").GetDateTime() >= mostRecentConnectedEvent.GetJsonElement("createdAt").GetDateTime())
466+
{
467+
return null;
468+
}
469+
470+
// We found an event indicating that an issue was linked and we determined that it is more recent than any of the "un-link" events
471+
else
472+
{
473+
var linkedIssueNumber = mostRecentConnectedEvent.GetJsonElement("subject.number").GetInt32();
474+
var issue = await _gitHubClient.Issue.Get(owner, repository, linkedIssueNumber).ConfigureAwait(false);
475+
return _mapper.Map<Issue>(issue);
476+
}
477+
}
478+
359479
private async Task ExecuteAsync(Func<Task> action)
360480
{
361481
try

src/GitReleaseManager.Core/Provider/IVcsProvider.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,7 @@ public interface IVcsProvider
4949
string GetMilestoneQueryString();
5050

5151
string GetIssueType(Issue issue);
52+
53+
Task<Issue> GetLinkedIssueAsync(string owner, string repository, int issueNumber);
5254
}
5355
}

src/GitReleaseManager.Core/ReleaseNotes/ReleaseNotesBuilder.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ public async Task<string> BuildReleaseNotesAsync(string user, string repository,
6666

6767
var commitsLink = _vcsProvider.GetCommitsUrl(_user, _repository, _targetMilestone?.Title, previousMilestone?.Title);
6868

69+
foreach (var issue in issues)
70+
{
71+
issue.LinkedIssue = await _vcsProvider.GetLinkedIssueAsync(_user, _repository, issue.Number).ConfigureAwait(false);
72+
}
73+
6974
var issuesDict = GetIssuesDict(issues);
7075

7176
var milestoneQueryString = _vcsProvider.GetMilestoneQueryString();

src/GitReleaseManager.Core/Templates/default/issue-note.sbn

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
if issue.is_pull_request
33
}}- [__!{{ issue.public_number }}__]({{ issue.html_url }}) {{ issue.title }}
44
{{ else
5-
}}- [__#{{ issue.public_number }}__]({{ issue.html_url }}) {{ issue.title }} raised by [{{ issue.user.login }}]({{ issue.user.html_url }})
5+
}}- [__#{{ issue.public_number }}__]({{ issue.html_url }}) {{ issue.title }} raised by [{{ issue.user.login }}]({{ issue.user.html_url }}) {{ if issue.linked_issue != null }} resolved in [#{{ issue.linked_issue.number }}]({{ issue.linked_issue.html_url }}) by [{{ issue.linked_issue.user.login }}]({{ issue.linked_issue.user.html_url }}){{ end }}
66
{{ end -}}

src/GitReleaseManager.IntegrationTests/GitHubProviderIntegrationTests.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,5 +101,17 @@ public async Task Should_Get_Commits_Count()
101101
var result = await _gitHubProvider.GetCommitsCountAsync(OWNER, REPOSITORY, _releaseBaseTag, _releaseHeadTag).ConfigureAwait(false);
102102
result.ShouldBeGreaterThan(0);
103103
}
104+
105+
[Test]
106+
public async Task GetLinkedIssue()
107+
{
108+
// Assert that issue 43 is linked to pull request 108
109+
var result1 = await _gitHubProvider.GetLinkedIssueAsync("jericho", "_testing", 43).ConfigureAwait(false);
110+
Assert.AreEqual(108, result1.Number);
111+
112+
// Assert that pull request 108 is linked to issue 43
113+
var result2 = await _gitHubProvider.GetLinkedIssueAsync("jericho", "_testing", 108).ConfigureAwait(false);
114+
Assert.AreEqual(43, result2.Number);
115+
}
104116
}
105117
}

0 commit comments

Comments
 (0)