Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
2 changes: 2 additions & 0 deletions src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@

<ItemGroup>
<PackageReference Include="DemaConsulting.TestResults" Version="1.4.0" />
<PackageReference Include="GraphQL.Client" Version="6.1.0" />
<PackageReference Include="GraphQL.Client.Serializer.SystemTextJson" Version="6.1.0" />
<PackageReference Include="Microsoft.Sbom.Targets" Version="4.1.5" PrivateAssets="All" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.103" PrivateAssets="All" />
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.103">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@
// SOFTWARE.

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using GraphQL;
using GraphQL.Client.Http;
using GraphQL.Client.Serializer.SystemTextJson;

namespace DemaConsulting.BuildMark.RepoConnectors;
namespace DemaConsulting.BuildMark.RepoConnectors.GitHub;

/// <summary>
/// Helper class for executing GitHub GraphQL queries.
Expand All @@ -35,19 +36,14 @@ internal sealed class GitHubGraphQLClient : IDisposable
private const string DefaultGitHubGraphQLEndpoint = "https://api.github.com/graphql";

/// <summary>
/// HTTP client for making GraphQL requests.
/// GraphQL HTTP client for making GraphQL requests.
/// </summary>
private readonly HttpClient _httpClient;
private readonly GraphQLHttpClient _graphqlClient;

/// <summary>
/// Indicates whether this instance owns the HTTP client and should dispose it.
/// Indicates whether this instance owns the GraphQL client and should dispose it.
/// </summary>
private readonly bool _ownsHttpClient;

/// <summary>
/// GraphQL endpoint URL.
/// </summary>
private readonly string _graphqlEndpoint;
private readonly bool _ownsGraphQLClient;

/// <summary>
/// Initializes a new instance of the <see cref="GitHubGraphQLClient"/> class.
Expand All @@ -57,13 +53,19 @@ internal sealed class GitHubGraphQLClient : IDisposable
public GitHubGraphQLClient(string token, string? graphqlEndpoint = null)
{
// Initialize HTTP client with authentication and user agent headers
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization =
var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
_httpClient.DefaultRequestHeaders.UserAgent.Add(
httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("BuildMark", "1.0"));
_graphqlEndpoint = graphqlEndpoint ?? DefaultGitHubGraphQLEndpoint;
_ownsHttpClient = true;

// Create GraphQL HTTP client with the configured HTTP client
var options = new GraphQLHttpClientOptions
{
EndPoint = new Uri(graphqlEndpoint ?? DefaultGitHubGraphQLEndpoint)
};
_graphqlClient = new GraphQLHttpClient(options, new SystemTextJsonSerializer(), httpClient);
_ownsGraphQLClient = true;
}

/// <summary>
Expand All @@ -78,9 +80,12 @@ public GitHubGraphQLClient(string token, string? graphqlEndpoint = null)
internal GitHubGraphQLClient(HttpClient httpClient, string? graphqlEndpoint = null)
{
// Use provided HTTP client (typically a mocked one for testing)
_httpClient = httpClient;
_graphqlEndpoint = graphqlEndpoint ?? DefaultGitHubGraphQLEndpoint;
_ownsHttpClient = false;
var options = new GraphQLHttpClientOptions
{
EndPoint = new Uri(graphqlEndpoint ?? DefaultGitHubGraphQLEndpoint)
};
_graphqlClient = new GraphQLHttpClient(options, new SystemTextJsonSerializer(), httpClient);
_ownsGraphQLClient = false;
}

/// <summary>
Expand All @@ -97,10 +102,11 @@ public async Task<List<int>> FindIssueIdsLinkedToPullRequestAsync(
{
try
{
// GraphQL query to get closing issues for a pull request
var graphqlQuery = new
// Create GraphQL request to get closing issues for a pull request.
// Note: Limited to first 100 issues per GitHub API. In practice, PRs rarely have more than 100 linked issues.
var request = new GraphQLRequest
{
query = @"
Query = @"
query($owner: String!, $repo: String!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
Expand All @@ -112,41 +118,22 @@ public async Task<List<int>> FindIssueIdsLinkedToPullRequestAsync(
}
}
}",
variables = new
Variables = new
{
owner,
repo,
prNumber
}
};

// Serialize query and send POST request to GraphQL endpoint
var jsonContent = JsonSerializer.Serialize(graphqlQuery);
using var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
// Execute GraphQL query
var response = await _graphqlClient.SendQueryAsync<GitHubGraphQLTypes.FindIssueIdsResponse>(request);

// Execute GraphQL query and ensure success
var response = await _httpClient.PostAsync(_graphqlEndpoint, content);
response.EnsureSuccessStatusCode();

// Parse response JSON
var responseBody = await response.Content.ReadAsStringAsync();
var jsonDoc = JsonDocument.Parse(responseBody);

// Extract issue numbers from the GraphQL response
var issueNumbers = new List<int>();
if (jsonDoc.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("repository", out var repository) &&
repository.TryGetProperty("pullRequest", out var pullRequest) &&
pullRequest.TryGetProperty("closingIssuesReferences", out var closingIssues) &&
closingIssues.TryGetProperty("nodes", out var nodes))
{
// Enumerate all issue nodes and extract their numbers
foreach (var node in nodes.EnumerateArray().Where(n => n.TryGetProperty("number", out _)))
{
node.TryGetProperty("number", out var number);
issueNumbers.Add(number.GetInt32());
}
}
// Extract issue numbers from the GraphQL response, filtering out null or invalid values
var issueNumbers = response.Data?.Repository?.PullRequest?.ClosingIssuesReferences?.Nodes?
.Where(n => n.Number.HasValue)
.Select(n => n.Number!.Value)
.ToList() ?? [];

// Return list of linked issue numbers
return issueNumbers;
Expand All @@ -159,14 +146,14 @@ public async Task<List<int>> FindIssueIdsLinkedToPullRequestAsync(
}

/// <summary>
/// Disposes the HTTP client if owned by this instance.
/// Disposes the GraphQL client if owned by this instance.
/// </summary>
public void Dispose()
{
// Clean up HTTP client resources only if we own it
if (_ownsHttpClient)
// Clean up GraphQL client resources only if we own it
if (_ownsGraphQLClient)
{
_httpClient.Dispose();
_graphqlClient.Dispose();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) 2024-2025 Dema Consulting
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

namespace DemaConsulting.BuildMark.RepoConnectors.GitHub;

/// <summary>
/// GitHub GraphQL response types.
/// </summary>
internal static class GitHubGraphQLTypes
{
/// <summary>
/// Response for finding issues linked to a pull request.
/// </summary>
/// <param name="Repository">Repository data.</param>
internal record FindIssueIdsResponse(
RepositoryData? Repository);

/// <summary>
/// Repository data containing pull request information.
/// </summary>
/// <param name="PullRequest">Pull request data.</param>
internal record RepositoryData(
PullRequestData? PullRequest);

/// <summary>
/// Pull request data containing closing issues.
/// </summary>
/// <param name="ClosingIssuesReferences">Closing issues references.</param>
internal record PullRequestData(
ClosingIssuesReferencesData? ClosingIssuesReferences);

/// <summary>
/// Closing issues references data containing nodes.
/// </summary>
/// <param name="Nodes">Issue nodes.</param>
internal record ClosingIssuesReferencesData(
List<IssueNode>? Nodes);

/// <summary>
/// Issue node containing issue number.
/// </summary>
/// <param name="Number">Issue number.</param>
internal record IssueNode(
int? Number);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

using DemaConsulting.BuildMark.RepoConnectors.GitHub;
using Octokit;

namespace DemaConsulting.BuildMark.RepoConnectors;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

using System.Net;
using System.Text;
using DemaConsulting.BuildMark.RepoConnectors;
using DemaConsulting.BuildMark.RepoConnectors.GitHub;

namespace DemaConsulting.BuildMark.Tests;

Expand Down