Skip to content

Commit a995bbf

Browse files
CopilotMalcolmnixon
andcommitted
Extract GraphQL client to separate GitHubGraphQLClient class with GitHub Enterprise support
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
1 parent 36c2da8 commit a995bbf

File tree

2 files changed

+146
-88
lines changed

2 files changed

+146
-88
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright (c) 2024-2025 Dema Consulting
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in all
11+
// copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
// SOFTWARE.
20+
21+
using System.Net.Http.Headers;
22+
using System.Text;
23+
using System.Text.Json;
24+
25+
namespace DemaConsulting.BuildMark.RepoConnectors;
26+
27+
/// <summary>
28+
/// Helper class for executing GitHub GraphQL queries.
29+
/// </summary>
30+
internal sealed class GitHubGraphQLClient : IDisposable
31+
{
32+
/// <summary>
33+
/// Default GitHub GraphQL API endpoint.
34+
/// </summary>
35+
private const string DefaultGitHubGraphQLEndpoint = "https://api.github.com/graphql";
36+
37+
/// <summary>
38+
/// HTTP client for making GraphQL requests.
39+
/// </summary>
40+
private readonly HttpClient _httpClient;
41+
42+
/// <summary>
43+
/// GraphQL endpoint URL.
44+
/// </summary>
45+
private readonly string _graphqlEndpoint;
46+
47+
/// <summary>
48+
/// Initializes a new instance of the <see cref="GitHubGraphQLClient"/> class.
49+
/// </summary>
50+
/// <param name="token">GitHub authentication token.</param>
51+
/// <param name="graphqlEndpoint">Optional GraphQL endpoint URL. Defaults to public GitHub API. For GitHub Enterprise, use https://your-github-enterprise/api/graphql.</param>
52+
public GitHubGraphQLClient(string token, string? graphqlEndpoint = null)
53+
{
54+
_httpClient = new HttpClient();
55+
_httpClient.DefaultRequestHeaders.Authorization =
56+
new AuthenticationHeaderValue("Bearer", token);
57+
_httpClient.DefaultRequestHeaders.UserAgent.Add(
58+
new ProductInfoHeaderValue("BuildMark", "1.0"));
59+
_graphqlEndpoint = graphqlEndpoint ?? DefaultGitHubGraphQLEndpoint;
60+
}
61+
62+
/// <summary>
63+
/// Finds issue IDs linked to a pull request via closingIssuesReferences.
64+
/// </summary>
65+
/// <param name="owner">Repository owner.</param>
66+
/// <param name="repo">Repository name.</param>
67+
/// <param name="prNumber">Pull request number.</param>
68+
/// <returns>List of issue IDs linked to the pull request.</returns>
69+
public async Task<List<int>> FindIssueIdsLinkedToPullRequestAsync(
70+
string owner,
71+
string repo,
72+
int prNumber)
73+
{
74+
try
75+
{
76+
// GraphQL query to get closing issues for a pull request
77+
var graphqlQuery = new
78+
{
79+
query = @"
80+
query($owner: String!, $repo: String!, $prNumber: Int!) {
81+
repository(owner: $owner, name: $repo) {
82+
pullRequest(number: $prNumber) {
83+
closingIssuesReferences(first: 100) {
84+
nodes {
85+
number
86+
}
87+
}
88+
}
89+
}
90+
}",
91+
variables = new
92+
{
93+
owner,
94+
repo,
95+
prNumber
96+
}
97+
};
98+
99+
var jsonContent = JsonSerializer.Serialize(graphqlQuery);
100+
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
101+
102+
var response = await _httpClient.PostAsync(_graphqlEndpoint, content);
103+
response.EnsureSuccessStatusCode();
104+
105+
var responseBody = await response.Content.ReadAsStringAsync();
106+
var jsonDoc = JsonDocument.Parse(responseBody);
107+
108+
// Extract issue numbers from the GraphQL response
109+
var issueNumbers = new List<int>();
110+
if (jsonDoc.RootElement.TryGetProperty("data", out var data) &&
111+
data.TryGetProperty("repository", out var repository) &&
112+
repository.TryGetProperty("pullRequest", out var pullRequest) &&
113+
pullRequest.TryGetProperty("closingIssuesReferences", out var closingIssues) &&
114+
closingIssues.TryGetProperty("nodes", out var nodes))
115+
{
116+
foreach (var node in nodes.EnumerateArray())
117+
{
118+
if (node.TryGetProperty("number", out var number))
119+
{
120+
issueNumbers.Add(number.GetInt32());
121+
}
122+
}
123+
}
124+
125+
return issueNumbers;
126+
}
127+
catch
128+
{
129+
// If GraphQL query fails, return empty list
130+
return [];
131+
}
132+
}
133+
134+
/// <summary>
135+
/// Disposes the HTTP client.
136+
/// </summary>
137+
public void Dispose()
138+
{
139+
_httpClient.Dispose();
140+
}
141+
}

src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs

Lines changed: 5 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@
1818
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1919
// SOFTWARE.
2020

21-
using System.Net.Http.Headers;
22-
using System.Text;
23-
using System.Text.Json;
2421
using Octokit;
2522

2623
namespace DemaConsulting.BuildMark.RepoConnectors;
@@ -30,8 +27,6 @@ namespace DemaConsulting.BuildMark.RepoConnectors;
3027
/// </summary>
3128
public class GitHubRepoConnector : RepoConnectorBase
3229
{
33-
private const string GitHubGraphQLEndpoint = "https://api.github.com/graphql";
34-
3530
private static readonly Dictionary<string, string> LabelTypeMap = new()
3631
{
3732
{ "bug", "bug" },
@@ -236,13 +231,16 @@ public override async Task<BuildInformation> GetBuildInformationAsync(Version? v
236231
var bugs = new List<ItemInfo>();
237232
var nonBugChanges = new List<ItemInfo>();
238233

234+
// Create GraphQL client for finding linked issues (reused across multiple PR queries)
235+
using var graphqlClient = new GitHubGraphQLClient(token);
236+
239237
foreach (var commit in commitsInRange)
240238
{
241239
if (commitHashToPr.TryGetValue(commit.Sha, out var pr))
242240
{
243-
// Find issue IDs that are linked to this PR using GitHub Search API
241+
// Find issue IDs that are linked to this PR using GitHub GraphQL API
244242
// All PRs are also issues, so we need to find the "real" issues (non-PR issues) that link to this PR
245-
var linkedIssueIds = await FindIssueIdsLinkedToPullRequestAsync(client, owner, repo, pr.Number);
243+
var linkedIssueIds = await graphqlClient.FindIssueIdsLinkedToPullRequestAsync(owner, repo, pr.Number);
246244

247245
if (linkedIssueIds.Count > 0)
248246
{
@@ -368,87 +366,6 @@ private static List<GitHubCommit> GetCommitsInRange(IReadOnlyList<GitHubCommit>
368366
return result;
369367
}
370368

371-
/// <summary>
372-
/// Finds issue IDs linked to a pull request using GitHub GraphQL API.
373-
/// Uses the closingIssuesReferences connection to find issues that the PR closes.
374-
/// </summary>
375-
/// <param name="client">GitHub REST client (used to get credentials).</param>
376-
/// <param name="owner">Repository owner.</param>
377-
/// <param name="repo">Repository name.</param>
378-
/// <param name="prNumber">Pull request number.</param>
379-
/// <returns>List of issue IDs (numbers) that are linked/closed by the pull request.</returns>
380-
private static async Task<List<int>> FindIssueIdsLinkedToPullRequestAsync(
381-
GitHubClient client,
382-
string owner,
383-
string repo,
384-
int prNumber)
385-
{
386-
try
387-
{
388-
using var httpClient = new HttpClient();
389-
httpClient.DefaultRequestHeaders.Authorization =
390-
new AuthenticationHeaderValue("Bearer", client.Credentials.GetToken());
391-
httpClient.DefaultRequestHeaders.UserAgent.Add(
392-
new ProductInfoHeaderValue("BuildMark", "1.0"));
393-
394-
// GraphQL query to get closing issues for a pull request
395-
var graphqlQuery = new
396-
{
397-
query = @"
398-
query($owner: String!, $repo: String!, $prNumber: Int!) {
399-
repository(owner: $owner, name: $repo) {
400-
pullRequest(number: $prNumber) {
401-
closingIssuesReferences(first: 100) {
402-
nodes {
403-
number
404-
}
405-
}
406-
}
407-
}
408-
}",
409-
variables = new
410-
{
411-
owner,
412-
repo,
413-
prNumber
414-
}
415-
};
416-
417-
var jsonContent = JsonSerializer.Serialize(graphqlQuery);
418-
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
419-
420-
var response = await httpClient.PostAsync(GitHubGraphQLEndpoint, content);
421-
response.EnsureSuccessStatusCode();
422-
423-
var responseBody = await response.Content.ReadAsStringAsync();
424-
var jsonDoc = JsonDocument.Parse(responseBody);
425-
426-
// Extract issue numbers from the GraphQL response
427-
var issueNumbers = new List<int>();
428-
if (jsonDoc.RootElement.TryGetProperty("data", out var data) &&
429-
data.TryGetProperty("repository", out var repository) &&
430-
repository.TryGetProperty("pullRequest", out var pullRequest) &&
431-
pullRequest.TryGetProperty("closingIssuesReferences", out var closingIssues) &&
432-
closingIssues.TryGetProperty("nodes", out var nodes))
433-
{
434-
foreach (var node in nodes.EnumerateArray())
435-
{
436-
if (node.TryGetProperty("number", out var number))
437-
{
438-
issueNumbers.Add(number.GetInt32());
439-
}
440-
}
441-
}
442-
443-
return issueNumbers;
444-
}
445-
catch
446-
{
447-
// If GraphQL query fails (e.g., PR doesn't exist or no permissions), return empty list
448-
return new List<int>();
449-
}
450-
}
451-
452369
/// <summary>
453370
/// Creates an ItemInfo from an issue.
454371
/// </summary>

0 commit comments

Comments
 (0)