Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Flatten record types and add pagination support
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
  • Loading branch information
Copilot and Malcolmnixon committed Feb 16, 2026
commit 957a74d3e1280aee527cbf2ff920feade15c0e73
Original file line number Diff line number Diff line change
Expand Up @@ -102,41 +102,61 @@ public async Task<List<int>> FindIssueIdsLinkedToPullRequestAsync(
{
try
{
// 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
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
}
};

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

// 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;
}",
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 && afterCursor != null);

// Return list of all linked issue numbers
return allIssueNumbers;
}
catch
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,42 +21,47 @@
namespace DemaConsulting.BuildMark.RepoConnectors.GitHub;

/// <summary>
/// GitHub GraphQL response types.
/// Response for finding issues linked to a pull request.
/// </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);
/// <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>
/// 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>
/// 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>
/// 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>
/// 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);
149 changes: 145 additions & 4 deletions test/DemaConsulting.BuildMark.Tests/GitHubGraphQLClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_Valid
{ ""number"": 123 },
{ ""number"": 456 },
{ ""number"": 789 }
]
],
""pageInfo"": {
""hasNextPage"": false,
""endCursor"": null
}
}
}
}
Expand Down Expand Up @@ -79,7 +83,11 @@ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_NoIss
""repository"": {
""pullRequest"": {
""closingIssuesReferences"": {
""nodes"": []
""nodes"": [],
""pageInfo"": {
""hasNextPage"": false,
""endCursor"": null
}
}
}
}
Expand Down Expand Up @@ -175,7 +183,11 @@ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_Singl
""closingIssuesReferences"": {
""nodes"": [
{ ""number"": 999 }
]
],
""pageInfo"": {
""hasNextPage"": false,
""endCursor"": null
}
}
}
}
Expand Down Expand Up @@ -210,7 +222,11 @@ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_Missi
{ ""number"": 100 },
{ ""title"": ""Missing number"" },
{ ""number"": 200 }
]
],
""pageInfo"": {
""hasNextPage"": false,
""endCursor"": null
}
}
}
}
Expand All @@ -230,6 +246,28 @@ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_Missi
Assert.AreEqual(200, issueIds[1]);
}

/// <summary>
/// Test that FindIssueIdsLinkedToPullRequestAsync handles pagination correctly.
/// </summary>
[TestMethod]
public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_WithPagination_ReturnsAllIssues()
{
// Arrange - Create mock handler that returns different responses for different pages
var mockHandler = new PaginationMockHttpMessageHandler();
using var httpClient = new HttpClient(mockHandler);
using var client = new GitHubGraphQLClient(httpClient);

// Act
var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 10);

// Assert
Assert.IsNotNull(issueIds);
Assert.HasCount(3, issueIds);
Assert.AreEqual(100, issueIds[0]);
Assert.AreEqual(200, issueIds[1]);
Assert.AreEqual(300, issueIds[2]);
}

/// <summary>
/// Creates a mock HttpClient with pre-canned response.
/// </summary>
Expand Down Expand Up @@ -290,4 +328,107 @@ protected override Task<HttpResponseMessage> SendAsync(
return Task.FromResult(response);
}
}

/// <summary>
/// Mock HTTP message handler for testing pagination.
/// </summary>
private sealed class PaginationMockHttpMessageHandler : HttpMessageHandler
{
/// <summary>
/// Request count to track pagination.
/// </summary>
private int _requestCount;

/// <summary>
/// Sends a mock HTTP response with pagination.
/// </summary>
/// <param name="request">HTTP request message.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Mock HTTP response.</returns>
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
// Read request body to determine which page to return
var requestBody = await request.Content!.ReadAsStringAsync(cancellationToken);

string responseContent;
if (_requestCount == 0 || !requestBody.Contains("\"after\""))
{
// First page
responseContent = @"{
""data"": {
""repository"": {
""pullRequest"": {
""closingIssuesReferences"": {
""nodes"": [
{ ""number"": 100 }
],
""pageInfo"": {
""hasNextPage"": true,
""endCursor"": ""cursor1""
}
}
}
}
}
}";
}
else if (requestBody.Contains("\"cursor1\""))
{
// Second page
responseContent = @"{
""data"": {
""repository"": {
""pullRequest"": {
""closingIssuesReferences"": {
""nodes"": [
{ ""number"": 200 }
],
""pageInfo"": {
""hasNextPage"": true,
""endCursor"": ""cursor2""
}
}
}
}
}
}";
}
else
{
// Third (last) page
responseContent = @"{
""data"": {
""repository"": {
""pullRequest"": {
""closingIssuesReferences"": {
""nodes"": [
{ ""number"": 300 }
],
""pageInfo"": {
""hasNextPage"": false,
""endCursor"": null
}
}
}
}
}
}";
}

_requestCount++;

// Create response with content
// Note: The returned HttpResponseMessage will be disposed by HttpClient,
// which also disposes the Content. This is the expected pattern for HttpMessageHandler.
var content = new StringContent(responseContent, Encoding.UTF8, "application/json");
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = content
};

return response;
}
}
}