Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
@@ -1,4 +1,4 @@
// Copyright (c) 2024-2025 Dema Consulting
// Copyright (c) 2026 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
Expand All @@ -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,59 +102,61 @@ public async Task<List<int>> FindIssueIdsLinkedToPullRequestAsync(
{
try
{
// GraphQL query to get closing issues for a pull request
var graphqlQuery = new
var allIssueNumbers = new List<int>();
string? afterCursor = null;
bool hasNextPage;

// Paginate through all closing issues
do
{
query = @"
query($owner: String!, $repo: String!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
closingIssuesReferences(first: 100) {
nodes {
number
// Create GraphQL request to get closing issues for a pull request with pagination support
var request = new GraphQLRequest
{
Query = @"
query($owner: String!, $repo: String!, $prNumber: Int!, $after: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
closingIssuesReferences(first: 100, after: $after) {
nodes {
number
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
}",
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 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());
}
}",
Variables = new
{
owner,
repo,
prNumber,
after = afterCursor
}
};

// Execute GraphQL query
var response = await _graphqlClient.SendQueryAsync<FindIssueIdsResponse>(request);

// Extract issue numbers from the GraphQL response, filtering out null or invalid values
var pageIssueNumbers = response.Data?.Repository?.PullRequest?.ClosingIssuesReferences?.Nodes?
.Where(n => n.Number.HasValue)
.Select(n => n.Number!.Value)
.ToList() ?? [];

allIssueNumbers.AddRange(pageIssueNumbers);

// Check if there are more pages
var pageInfo = response.Data?.Repository?.PullRequest?.ClosingIssuesReferences?.PageInfo;
hasNextPage = pageInfo?.HasNextPage ?? false;
afterCursor = pageInfo?.EndCursor;
}
while (hasNextPage);

// Return list of linked issue numbers
return issueNumbers;
// Return list of all linked issue numbers
return allIssueNumbers;
}
catch
{
Expand All @@ -159,14 +166,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,67 @@
// Copyright (c) 2026 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>
/// 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 and page info.
/// </summary>
/// <param name="Nodes">Issue nodes.</param>
/// <param name="PageInfo">Pagination information.</param>
internal record ClosingIssuesReferencesData(
List<IssueNode>? Nodes,
PageInfo? PageInfo);

/// <summary>
/// Issue node containing issue number.
/// </summary>
/// <param name="Number">Issue number.</param>
internal record IssueNode(
int? Number);

/// <summary>
/// Pagination information for GraphQL queries.
/// </summary>
/// <param name="HasNextPage">Indicates whether there are more pages.</param>
/// <param name="EndCursor">Cursor for the next page.</param>
internal record PageInfo(
bool HasNextPage,
string? EndCursor);
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
Loading