From 93cb23662951d08ca069c9b2f4ad8a822d12a8d1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 03:54:25 +0000
Subject: [PATCH 1/7] Initial plan
From 4ae2fb2bb8db397d8d1a1c3af896cb216c4a1d86 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 03:59:56 +0000
Subject: [PATCH 2/7] Add GraphQL releases query with pagination support
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
---
.../GitHub/GitHubGraphQLClient.cs | 72 ++++
.../GitHub/GitHubGraphQLTypes.cs | 30 ++
.../RepoConnectors/GitHubRepoConnector.cs | 26 +-
.../GitHub/GitHubGraphQLClientTests.cs | 329 ++++++++++++++++++
4 files changed, 456 insertions(+), 1 deletion(-)
diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs
index 4c8332e..01e622e 100644
--- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs
@@ -172,6 +172,78 @@ ... on Commit {
}
}
+ ///
+ /// Gets all releases for a repository using GraphQL with pagination.
+ ///
+ /// Repository owner.
+ /// Repository name.
+ /// List of release tag names.
+ public async Task> GetReleasesAsync(
+ string owner,
+ string repo)
+ {
+ try
+ {
+ var allReleaseTagNames = new List();
+ string? afterCursor = null;
+ bool hasNextPage;
+
+ // Paginate through all releases
+ do
+ {
+ // Create GraphQL request to get releases for a repository with pagination support
+ var request = new GraphQLRequest
+ {
+ Query = @"
+ query($owner: String!, $repo: String!, $after: String) {
+ repository(owner: $owner, name: $repo) {
+ releases(first: 100, after: $after, orderBy: {field: CREATED_AT, direction: DESC}) {
+ nodes {
+ tagName
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ }
+ }
+ }",
+ Variables = new
+ {
+ owner,
+ repo,
+ after = afterCursor
+ }
+ };
+
+ // Execute GraphQL query
+ var response = await _graphqlClient.SendQueryAsync(request);
+
+ // Extract release tag names from the GraphQL response, filtering out null or invalid values
+ var pageReleaseTagNames = response.Data?.Repository?.Releases?.Nodes?
+ .Where(n => !string.IsNullOrEmpty(n.TagName))
+ .Select(n => n.TagName!)
+ .ToList() ?? [];
+
+ allReleaseTagNames.AddRange(pageReleaseTagNames);
+
+ // Check if there are more pages
+ var pageInfo = response.Data?.Repository?.Releases?.PageInfo;
+ hasNextPage = pageInfo?.HasNextPage ?? false;
+ afterCursor = pageInfo?.EndCursor;
+ }
+ while (hasNextPage);
+
+ // Return list of all release tag names
+ return allReleaseTagNames;
+ }
+ catch
+ {
+ // If GraphQL query fails, return empty list
+ return [];
+ }
+ }
+
///
/// Finds issue IDs linked to a pull request via closingIssuesReferences.
///
diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLTypes.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLTypes.cs
index 454bcd5..35a4064 100644
--- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLTypes.cs
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLTypes.cs
@@ -109,3 +109,33 @@ internal record CommitHistoryData(
/// Git object ID (SHA).
internal record CommitNode(
string? Oid);
+
+///
+/// Response for getting releases from a repository.
+///
+/// Repository data containing release information.
+internal record GetReleasesResponse(
+ ReleaseRepositoryData? Repository);
+
+///
+/// Repository data containing releases information.
+///
+/// Releases connection data.
+internal record ReleaseRepositoryData(
+ ReleasesConnectionData? Releases);
+
+///
+/// Releases connection data containing nodes and page info.
+///
+/// Release nodes.
+/// Pagination information.
+internal record ReleasesConnectionData(
+ List? Nodes,
+ PageInfo? PageInfo);
+
+///
+/// Release node containing release information.
+///
+/// Tag name associated with the release.
+internal record ReleaseNode(
+ string? TagName);
diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
index bcd98a4..65720e3 100644
--- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
@@ -166,7 +166,7 @@ private static async Task FetchGitHubDataAsync(
{
// Fetch all data from GitHub in parallel
var commitsTask = GetAllCommitsAsync(graphqlClient, owner, repo, branch);
- var releasesTask = client.Repository.Release.GetAll(owner, repo);
+ var releasesTask = GetAllReleasesAsync(graphqlClient, owner, repo);
var tagsTask = client.Repository.GetAllTags(owner, repo);
var pullRequestsTask = client.PullRequest.GetAllForRepository(owner, repo, new PullRequestRequest { State = ItemStateFilter.All });
var issuesTask = client.Issue.GetAllForRepository(owner, repo, new RepositoryIssueRequest { State = ItemStateFilter.All });
@@ -572,6 +572,30 @@ private static async Task> GetAllCommitsAsync(
return commitShas.Select(sha => new Commit(sha)).ToList();
}
+ ///
+ /// Gets all releases for a repository using GraphQL pagination.
+ ///
+ /// GitHub GraphQL client.
+ /// Repository owner.
+ /// Repository name.
+ /// List of all releases.
+ private static async Task> GetAllReleasesAsync(
+ GitHubGraphQLClient graphqlClient,
+ string owner,
+ string repo)
+ {
+ // Fetch all release tag names for the repository using GraphQL
+ var releaseTagNames = await graphqlClient.GetReleasesAsync(owner, repo);
+
+ // Convert tag names to Release objects using JSON deserialization
+ // This creates minimal Release objects with only TagName populated
+ return releaseTagNames.Select(tagName =>
+ {
+ var json = $$"""{"tag_name":"{{tagName}}"}""";
+ return System.Text.Json.JsonSerializer.Deserialize(json) ?? throw new InvalidOperationException($"Failed to create Release object for tag {tagName}");
+ }).ToList();
+ }
+
///
/// Gets commits in the range from fromHash (exclusive) to toHash (inclusive).
///
diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs
index 930ff94..a5bee84 100644
--- a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs
+++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs
@@ -790,4 +790,333 @@ protected override async Task SendAsync(
return response;
}
}
+
+ ///
+ /// Test that GetReleasesAsync returns expected release tag names with valid response.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_ValidResponse_ReturnsReleaseTagNames()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v1.0.0"" },
+ { ""tagName"": ""v0.9.0"" },
+ { ""tagName"": ""v0.8.5"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseTagNames);
+ Assert.HasCount(3, releaseTagNames);
+ Assert.AreEqual("v1.0.0", releaseTagNames[0]);
+ Assert.AreEqual("v0.9.0", releaseTagNames[1]);
+ Assert.AreEqual("v0.8.5", releaseTagNames[2]);
+ }
+
+ ///
+ /// Test that GetReleasesAsync returns empty list when no releases are found.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_NoReleases_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseTagNames);
+ Assert.IsEmpty(releaseTagNames);
+ }
+
+ ///
+ /// Test that GetReleasesAsync returns empty list when response has missing data.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_MissingData_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": null
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseTagNames);
+ Assert.IsEmpty(releaseTagNames);
+ }
+
+ ///
+ /// Test that GetReleasesAsync returns empty list on HTTP error.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_HttpError_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{ ""message"": ""Not Found"" }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.NotFound);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseTagNames);
+ Assert.IsEmpty(releaseTagNames);
+ }
+
+ ///
+ /// Test that GetReleasesAsync returns empty list on invalid JSON.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_InvalidJson_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = "This is not valid JSON";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseTagNames);
+ Assert.IsEmpty(releaseTagNames);
+ }
+
+ ///
+ /// Test that GetReleasesAsync returns single release tag correctly.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_SingleRelease_ReturnsOneTagName()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v2.0.0-beta1"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseTagNames);
+ Assert.HasCount(1, releaseTagNames);
+ Assert.AreEqual("v2.0.0-beta1", releaseTagNames[0]);
+ }
+
+ ///
+ /// Test that GetReleasesAsync handles nodes with missing tagName property.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_MissingTagNameProperty_SkipsInvalidNodes()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v1.0.0"" },
+ { ""name"": ""Missing tag name"" },
+ { ""tagName"": ""v0.9.0"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseTagNames);
+ Assert.HasCount(2, releaseTagNames);
+ Assert.AreEqual("v1.0.0", releaseTagNames[0]);
+ Assert.AreEqual("v0.9.0", releaseTagNames[1]);
+ }
+
+ ///
+ /// Test that GetReleasesAsync handles pagination correctly.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_WithPagination_ReturnsAllReleases()
+ {
+ // Arrange - Create mock handler that returns different responses for different pages
+ var mockHandler = new ReleasePaginationMockHttpMessageHandler();
+ using var httpClient = new HttpClient(mockHandler);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseTagNames);
+ Assert.HasCount(3, releaseTagNames);
+ Assert.AreEqual("v3.0.0", releaseTagNames[0]);
+ Assert.AreEqual("v2.0.0", releaseTagNames[1]);
+ Assert.AreEqual("v1.0.0", releaseTagNames[2]);
+ }
+
+ ///
+ /// Mock HTTP message handler for testing release pagination.
+ ///
+ private sealed class ReleasePaginationMockHttpMessageHandler : HttpMessageHandler
+ {
+ ///
+ /// Request count to track pagination.
+ ///
+ private int _requestCount;
+
+ ///
+ /// Sends a mock HTTP response with pagination.
+ ///
+ /// HTTP request message.
+ /// Cancellation token.
+ /// Mock HTTP response.
+ protected override async Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ // Read request body to determine which page to return
+ var requestBody = request.Content != null
+ ? await request.Content.ReadAsStringAsync(cancellationToken)
+ : string.Empty;
+
+ string responseContent;
+ if (_requestCount == 0 || !requestBody.Contains("\"after\""))
+ {
+ // First page
+ responseContent = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v3.0.0"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": true,
+ ""endCursor"": ""cursor1""
+ }
+ }
+ }
+ }
+ }";
+ }
+ else if (requestBody.Contains("\"cursor1\""))
+ {
+ // Second page
+ responseContent = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v2.0.0"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": true,
+ ""endCursor"": ""cursor2""
+ }
+ }
+ }
+ }
+ }";
+ }
+ else
+ {
+ // Third (last) page
+ responseContent = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v1.0.0"" }
+ ],
+ ""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;
+ }
+ }
}
From ba5a8bbdd6b4a055e808be2ea764904943c1e4b5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 04:03:21 +0000
Subject: [PATCH 3/7] Use Newtonsoft.Json for Release deserialization and fix
formatting
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
---
.../DemaConsulting.BuildMark.csproj | 1 +
.../RepoConnectors/GitHubRepoConnector.cs | 2 +-
.../RepoConnectors/GitHub/GitHubGraphQLClientTests.cs | 6 +++---
3 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj b/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj
index 1535928..a0f0b22 100644
--- a/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj
+++ b/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj
@@ -56,6 +56,7 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
all
diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
index 65720e3..ba941bd 100644
--- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
@@ -592,7 +592,7 @@ private static async Task> GetAllReleasesAsync(
return releaseTagNames.Select(tagName =>
{
var json = $$"""{"tag_name":"{{tagName}}"}""";
- return System.Text.Json.JsonSerializer.Deserialize(json) ?? throw new InvalidOperationException($"Failed to create Release object for tag {tagName}");
+ return Newtonsoft.Json.JsonConvert.DeserializeObject(json) ?? throw new InvalidOperationException($"Failed to create Release object for tag {tagName}");
}).ToList();
}
diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs
index a5bee84..48c280e 100644
--- a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs
+++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs
@@ -353,7 +353,7 @@ protected override async Task SendAsync(
var requestBody = request.Content != null
? await request.Content.ReadAsStringAsync(cancellationToken)
: string.Empty;
-
+
string responseContent;
if (_requestCount == 0 || !requestBody.Contains("\"after\""))
{
@@ -704,7 +704,7 @@ protected override async Task SendAsync(
var requestBody = request.Content != null
? await request.Content.ReadAsStringAsync(cancellationToken)
: string.Empty;
-
+
string responseContent;
if (_requestCount == 0 || !requestBody.Contains("\"after\""))
{
@@ -1045,7 +1045,7 @@ protected override async Task SendAsync(
var requestBody = request.Content != null
? await request.Content.ReadAsStringAsync(cancellationToken)
: string.Empty;
-
+
string responseContent;
if (_requestCount == 0 || !requestBody.Contains("\"after\""))
{
From ce91b03ca6383a5726987ecbb8ac1f0892fdc966 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 04:04:56 +0000
Subject: [PATCH 4/7] Fix JSON injection vulnerability by using JsonSerializer
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
---
.../RepoConnectors/GitHubRepoConnector.cs | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
index ba941bd..ce71f3e 100644
--- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
@@ -591,8 +591,11 @@ private static async Task> GetAllReleasesAsync(
// This creates minimal Release objects with only TagName populated
return releaseTagNames.Select(tagName =>
{
- var json = $$"""{"tag_name":"{{tagName}}"}""";
- return Newtonsoft.Json.JsonConvert.DeserializeObject(json) ?? throw new InvalidOperationException($"Failed to create Release object for tag {tagName}");
+ // Use JsonConvert with a proper object to avoid JSON injection
+ var releaseData = new { tag_name = tagName };
+ var json = Newtonsoft.Json.JsonConvert.SerializeObject(releaseData);
+ var release = Newtonsoft.Json.JsonConvert.DeserializeObject(json);
+ return release ?? throw new InvalidOperationException($"Failed to create Release object for tag {tagName}");
}).ToList();
}
From a1820c1307f64b755f53b4e837ac78ad894eb6af Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 11:51:57 +0000
Subject: [PATCH 5/7] Replace Octokit Release type with custom Release record
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
---
.../DemaConsulting.BuildMark.csproj | 1 -
.../RepoConnectors/GitHubRepoConnector.cs | 28 +++++++++----------
2 files changed, 13 insertions(+), 16 deletions(-)
diff --git a/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj b/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj
index a0f0b22..1535928 100644
--- a/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj
+++ b/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj
@@ -56,7 +56,6 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
all
diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
index ce71f3e..dce0989 100644
--- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
@@ -126,6 +126,12 @@ public override async Task GetBuildInformationAsync(Version? v
internal sealed record Commit(
string Sha);
+ ///
+ /// Simple release representation containing only the tag name.
+ ///
+ internal sealed record Release(
+ string TagName);
+
///
/// Container for GitHub data fetched from the API.
///
@@ -214,7 +220,7 @@ internal static LookupData BuildLookupData(GitHubData data)
// Build an ordered list of releases on the current branch.
// This is used to select the prior release version for identifying changes in the build.
var branchReleases = data.Releases
- .Where(r => !string.IsNullOrEmpty(r.TagName) && branchTagNames.Contains(r.TagName))
+ .Where(r => branchTagNames.Contains(r.TagName))
.ToList();
// Build a mapping from tag name to tag object for quick lookup.
@@ -223,12 +229,12 @@ internal static LookupData BuildLookupData(GitHubData data)
// Build a mapping from tag name to release for version lookup.
// This is used to match version objects back to their releases.
- var tagToRelease = branchReleases.ToDictionary(r => r.TagName!, r => r);
+ var tagToRelease = branchReleases.ToDictionary(r => r.TagName, r => r);
// Parse release tags into Version objects, maintaining release order (newest to oldest).
// This is used to determine version history and find previous releases.
var releaseVersions = branchReleases
- .Select(r => Version.TryCreate(r.TagName!))
+ .Select(r => Version.TryCreate(r.TagName))
.Where(v => v != null)
.Cast()
.ToList();
@@ -277,7 +283,7 @@ internal static (Version toVersion, string toHash) DetermineTargetVersion(
// Use the most recent release (first in list since releases are newest to oldest)
var latestRelease = lookupData.BranchReleases[0];
var latestReleaseVersion = lookupData.ReleaseVersions[0];
- var latestTagCommit = lookupData.TagsByName[latestRelease.TagName!];
+ var latestTagCommit = lookupData.TagsByName[latestRelease.TagName];
// Check if current commit matches latest release tag
if (latestTagCommit.Commit.Sha == toHash)
@@ -319,7 +325,7 @@ internal static (Version? fromVersion, string? fromHash) DetermineBaselineVersio
// Get commit hash for baseline version if one was found
if (fromVersion != null &&
lookupData.TagToRelease.TryGetValue(fromVersion.Tag, out var fromRelease) &&
- lookupData.TagsByName.TryGetValue(fromRelease.TagName!, out var fromTagCommit))
+ lookupData.TagsByName.TryGetValue(fromRelease.TagName, out var fromTagCommit))
{
return (fromVersion, fromTagCommit.Commit.Sha);
}
@@ -587,16 +593,8 @@ private static async Task> GetAllReleasesAsync(
// Fetch all release tag names for the repository using GraphQL
var releaseTagNames = await graphqlClient.GetReleasesAsync(owner, repo);
- // Convert tag names to Release objects using JSON deserialization
- // This creates minimal Release objects with only TagName populated
- return releaseTagNames.Select(tagName =>
- {
- // Use JsonConvert with a proper object to avoid JSON injection
- var releaseData = new { tag_name = tagName };
- var json = Newtonsoft.Json.JsonConvert.SerializeObject(releaseData);
- var release = Newtonsoft.Json.JsonConvert.DeserializeObject(json);
- return release ?? throw new InvalidOperationException($"Failed to create Release object for tag {tagName}");
- }).ToList();
+ // Convert tag names to Release objects
+ return releaseTagNames.Select(tagName => new Release(tagName)).ToList();
}
///
From e2a22ca4ca2e080ea668982503578d798f87a6ab Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 12:23:44 +0000
Subject: [PATCH 6/7] Return ReleaseNode directly from GetReleasesAsync to
eliminate extra conversions
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
---
.../GitHub/GitHubGraphQLClient.cs | 17 +++--
.../RepoConnectors/GitHubRepoConnector.cs | 31 ++++-----
.../GitHub/GitHubGraphQLClientTests.cs | 66 +++++++++----------
3 files changed, 52 insertions(+), 62 deletions(-)
diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs
index 01e622e..f054866 100644
--- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs
@@ -177,14 +177,14 @@ ... on Commit {
///
/// Repository owner.
/// Repository name.
- /// List of release tag names.
- public async Task> GetReleasesAsync(
+ /// List of release nodes.
+ public async Task> GetReleasesAsync(
string owner,
string repo)
{
try
{
- var allReleaseTagNames = new List();
+ var allReleaseNodes = new List();
string? afterCursor = null;
bool hasNextPage;
@@ -219,13 +219,12 @@ public async Task> GetReleasesAsync(
// Execute GraphQL query
var response = await _graphqlClient.SendQueryAsync(request);
- // Extract release tag names from the GraphQL response, filtering out null or invalid values
- var pageReleaseTagNames = response.Data?.Repository?.Releases?.Nodes?
+ // Extract release nodes from the GraphQL response, filtering out null or invalid values
+ var pageReleaseNodes = response.Data?.Repository?.Releases?.Nodes?
.Where(n => !string.IsNullOrEmpty(n.TagName))
- .Select(n => n.TagName!)
.ToList() ?? [];
- allReleaseTagNames.AddRange(pageReleaseTagNames);
+ allReleaseNodes.AddRange(pageReleaseNodes);
// Check if there are more pages
var pageInfo = response.Data?.Repository?.Releases?.PageInfo;
@@ -234,8 +233,8 @@ public async Task> GetReleasesAsync(
}
while (hasNextPage);
- // Return list of all release tag names
- return allReleaseTagNames;
+ // Return list of all release nodes
+ return allReleaseNodes;
}
catch
{
diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
index dce0989..ad26a2b 100644
--- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
@@ -126,18 +126,12 @@ public override async Task GetBuildInformationAsync(Version? v
internal sealed record Commit(
string Sha);
- ///
- /// Simple release representation containing only the tag name.
- ///
- internal sealed record Release(
- string TagName);
-
///
/// Container for GitHub data fetched from the API.
///
internal sealed record GitHubData(
IReadOnlyList Commits,
- IReadOnlyList Releases,
+ IReadOnlyList Releases,
IReadOnlyList Tags,
IReadOnlyList PullRequests,
IReadOnlyList Issues);
@@ -148,9 +142,9 @@ internal sealed record GitHubData(
internal sealed record LookupData(
Dictionary IssueById,
Dictionary CommitHashToPr,
- List BranchReleases,
+ List BranchReleases,
Dictionary TagsByName,
- Dictionary TagToRelease,
+ Dictionary TagToRelease,
List ReleaseVersions,
HashSet BranchTagNames);
@@ -220,7 +214,7 @@ internal static LookupData BuildLookupData(GitHubData data)
// Build an ordered list of releases on the current branch.
// This is used to select the prior release version for identifying changes in the build.
var branchReleases = data.Releases
- .Where(r => branchTagNames.Contains(r.TagName))
+ .Where(r => r.TagName != null && branchTagNames.Contains(r.TagName))
.ToList();
// Build a mapping from tag name to tag object for quick lookup.
@@ -229,12 +223,12 @@ internal static LookupData BuildLookupData(GitHubData data)
// Build a mapping from tag name to release for version lookup.
// This is used to match version objects back to their releases.
- var tagToRelease = branchReleases.ToDictionary(r => r.TagName, r => r);
+ var tagToRelease = branchReleases.ToDictionary(r => r.TagName!, r => r);
// Parse release tags into Version objects, maintaining release order (newest to oldest).
// This is used to determine version history and find previous releases.
var releaseVersions = branchReleases
- .Select(r => Version.TryCreate(r.TagName))
+ .Select(r => Version.TryCreate(r.TagName!))
.Where(v => v != null)
.Cast()
.ToList();
@@ -283,7 +277,7 @@ internal static (Version toVersion, string toHash) DetermineTargetVersion(
// Use the most recent release (first in list since releases are newest to oldest)
var latestRelease = lookupData.BranchReleases[0];
var latestReleaseVersion = lookupData.ReleaseVersions[0];
- var latestTagCommit = lookupData.TagsByName[latestRelease.TagName];
+ var latestTagCommit = lookupData.TagsByName[latestRelease.TagName!];
// Check if current commit matches latest release tag
if (latestTagCommit.Commit.Sha == toHash)
@@ -325,7 +319,7 @@ internal static (Version? fromVersion, string? fromHash) DetermineBaselineVersio
// Get commit hash for baseline version if one was found
if (fromVersion != null &&
lookupData.TagToRelease.TryGetValue(fromVersion.Tag, out var fromRelease) &&
- lookupData.TagsByName.TryGetValue(fromRelease.TagName, out var fromTagCommit))
+ lookupData.TagsByName.TryGetValue(fromRelease.TagName!, out var fromTagCommit))
{
return (fromVersion, fromTagCommit.Commit.Sha);
}
@@ -585,16 +579,13 @@ private static async Task> GetAllCommitsAsync(
/// Repository owner.
/// Repository name.
/// List of all releases.
- private static async Task> GetAllReleasesAsync(
+ private static async Task> GetAllReleasesAsync(
GitHubGraphQLClient graphqlClient,
string owner,
string repo)
{
- // Fetch all release tag names for the repository using GraphQL
- var releaseTagNames = await graphqlClient.GetReleasesAsync(owner, repo);
-
- // Convert tag names to Release objects
- return releaseTagNames.Select(tagName => new Release(tagName)).ToList();
+ // Fetch all releases for the repository using GraphQL
+ return await graphqlClient.GetReleasesAsync(owner, repo);
}
///
diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs
index 48c280e..c4f0726 100644
--- a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs
+++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs
@@ -820,14 +820,14 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_ValidResponse_ReturnsRele
using var client = new GitHubGraphQLClient(httpClient);
// Act
- var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
// Assert
- Assert.IsNotNull(releaseTagNames);
- Assert.HasCount(3, releaseTagNames);
- Assert.AreEqual("v1.0.0", releaseTagNames[0]);
- Assert.AreEqual("v0.9.0", releaseTagNames[1]);
- Assert.AreEqual("v0.8.5", releaseTagNames[2]);
+ Assert.IsNotNull(releaseNodes);
+ Assert.HasCount(3, releaseNodes);
+ Assert.AreEqual("v1.0.0", releaseNodes[0].TagName);
+ Assert.AreEqual("v0.9.0", releaseNodes[1].TagName);
+ Assert.AreEqual("v0.8.5", releaseNodes[2].TagName);
}
///
@@ -855,11 +855,11 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_NoReleases_ReturnsEmptyLi
using var client = new GitHubGraphQLClient(httpClient);
// Act
- var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
// Assert
- Assert.IsNotNull(releaseTagNames);
- Assert.IsEmpty(releaseTagNames);
+ Assert.IsNotNull(releaseNodes);
+ Assert.IsEmpty(releaseNodes);
}
///
@@ -879,11 +879,11 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_MissingData_ReturnsEmptyL
using var client = new GitHubGraphQLClient(httpClient);
// Act
- var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
// Assert
- Assert.IsNotNull(releaseTagNames);
- Assert.IsEmpty(releaseTagNames);
+ Assert.IsNotNull(releaseNodes);
+ Assert.IsEmpty(releaseNodes);
}
///
@@ -899,11 +899,11 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_HttpError_ReturnsEmptyLis
using var client = new GitHubGraphQLClient(httpClient);
// Act
- var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
// Assert
- Assert.IsNotNull(releaseTagNames);
- Assert.IsEmpty(releaseTagNames);
+ Assert.IsNotNull(releaseNodes);
+ Assert.IsEmpty(releaseNodes);
}
///
@@ -919,11 +919,11 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_InvalidJson_ReturnsEmptyL
using var client = new GitHubGraphQLClient(httpClient);
// Act
- var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
// Assert
- Assert.IsNotNull(releaseTagNames);
- Assert.IsEmpty(releaseTagNames);
+ Assert.IsNotNull(releaseNodes);
+ Assert.IsEmpty(releaseNodes);
}
///
@@ -953,12 +953,12 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_SingleRelease_ReturnsOneT
using var client = new GitHubGraphQLClient(httpClient);
// Act
- var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
// Assert
- Assert.IsNotNull(releaseTagNames);
- Assert.HasCount(1, releaseTagNames);
- Assert.AreEqual("v2.0.0-beta1", releaseTagNames[0]);
+ Assert.IsNotNull(releaseNodes);
+ Assert.HasCount(1, releaseNodes);
+ Assert.AreEqual("v2.0.0-beta1", releaseNodes[0].TagName);
}
///
@@ -990,13 +990,13 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_MissingTagNameProperty_Sk
using var client = new GitHubGraphQLClient(httpClient);
// Act
- var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
// Assert
- Assert.IsNotNull(releaseTagNames);
- Assert.HasCount(2, releaseTagNames);
- Assert.AreEqual("v1.0.0", releaseTagNames[0]);
- Assert.AreEqual("v0.9.0", releaseTagNames[1]);
+ Assert.IsNotNull(releaseNodes);
+ Assert.HasCount(2, releaseNodes);
+ Assert.AreEqual("v1.0.0", releaseNodes[0].TagName);
+ Assert.AreEqual("v0.9.0", releaseNodes[1].TagName);
}
///
@@ -1011,14 +1011,14 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_WithPagination_ReturnsAll
using var client = new GitHubGraphQLClient(httpClient);
// Act
- var releaseTagNames = await client.GetReleasesAsync("owner", "repo");
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
// Assert
- Assert.IsNotNull(releaseTagNames);
- Assert.HasCount(3, releaseTagNames);
- Assert.AreEqual("v3.0.0", releaseTagNames[0]);
- Assert.AreEqual("v2.0.0", releaseTagNames[1]);
- Assert.AreEqual("v1.0.0", releaseTagNames[2]);
+ Assert.IsNotNull(releaseNodes);
+ Assert.HasCount(3, releaseNodes);
+ Assert.AreEqual("v3.0.0", releaseNodes[0].TagName);
+ Assert.AreEqual("v2.0.0", releaseNodes[1].TagName);
+ Assert.AreEqual("v1.0.0", releaseNodes[2].TagName);
}
///
From aaafacb0fee72a2832808aeaeb5867f965116175 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Feb 2026 13:33:58 +0000
Subject: [PATCH 7/7] Split GitHubGraphQLClientTests into separate files by
functionality
Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
---
.../GitHubGraphQLClientFindIssueIdsTests.cs | 436 +++++++
.../GitHubGraphQLClientGetCommitsTests.cs | 449 +++++++
.../GitHubGraphQLClientGetReleasesTests.cs | 421 +++++++
.../GitHub/GitHubGraphQLClientTests.cs | 1122 -----------------
4 files changed, 1306 insertions(+), 1122 deletions(-)
create mode 100644 test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientFindIssueIdsTests.cs
create mode 100644 test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetCommitsTests.cs
create mode 100644 test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetReleasesTests.cs
delete mode 100644 test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs
diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientFindIssueIdsTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientFindIssueIdsTests.cs
new file mode 100644
index 0000000..6477b33
--- /dev/null
+++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientFindIssueIdsTests.cs
@@ -0,0 +1,436 @@
+// 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.
+
+using System.Net;
+using System.Text;
+using DemaConsulting.BuildMark.RepoConnectors.GitHub;
+
+namespace DemaConsulting.BuildMark.Tests;
+
+///
+/// Tests for the GitHubGraphQLClient FindIssueIdsLinkedToPullRequestAsync method.
+///
+[TestClass]
+public class GitHubGraphQLClientFindIssueIdsTests
+{
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync returns expected issue IDs with valid response.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_ValidResponse_ReturnsIssueIds()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""pullRequest"": {
+ ""closingIssuesReferences"": {
+ ""nodes"": [
+ { ""number"": 123 },
+ { ""number"": 456 },
+ { ""number"": 789 }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
+
+ // Assert
+ Assert.IsNotNull(issueIds);
+ Assert.HasCount(3, issueIds);
+ Assert.AreEqual(123, issueIds[0]);
+ Assert.AreEqual(456, issueIds[1]);
+ Assert.AreEqual(789, issueIds[2]);
+ }
+
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list when no issues are linked.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_NoIssues_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""pullRequest"": {
+ ""closingIssuesReferences"": {
+ ""nodes"": [],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
+
+ // Assert
+ Assert.IsNotNull(issueIds);
+ Assert.IsEmpty(issueIds);
+ }
+
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list when response has missing data.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_MissingData_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": null
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
+
+ // Assert
+ Assert.IsNotNull(issueIds);
+ Assert.IsEmpty(issueIds);
+ }
+
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list on HTTP error.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_HttpError_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{ ""message"": ""Not Found"" }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.NotFound);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
+
+ // Assert
+ Assert.IsNotNull(issueIds);
+ Assert.IsEmpty(issueIds);
+ }
+
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list on invalid JSON.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_InvalidJson_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = "This is not valid JSON";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
+
+ // Assert
+ Assert.IsNotNull(issueIds);
+ Assert.IsEmpty(issueIds);
+ }
+
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync returns single issue ID correctly.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_SingleIssue_ReturnsOneIssueId()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""pullRequest"": {
+ ""closingIssuesReferences"": {
+ ""nodes"": [
+ { ""number"": 999 }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 1);
+
+ // Assert
+ Assert.IsNotNull(issueIds);
+ Assert.HasCount(1, issueIds);
+ Assert.AreEqual(999, issueIds[0]);
+ }
+
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync handles nodes with missing number property.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_MissingNumberProperty_SkipsInvalidNodes()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""pullRequest"": {
+ ""closingIssuesReferences"": {
+ ""nodes"": [
+ { ""number"": 100 },
+ { ""title"": ""Missing number"" },
+ { ""number"": 200 }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 5);
+
+ // Assert
+ Assert.IsNotNull(issueIds);
+ Assert.HasCount(2, issueIds);
+ Assert.AreEqual(100, issueIds[0]);
+ Assert.AreEqual(200, issueIds[1]);
+ }
+
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync handles pagination correctly.
+ ///
+ [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]);
+ }
+
+ ///
+ /// Creates a mock HttpClient with pre-canned response.
+ ///
+ /// Response content to return.
+ /// HTTP status code to return.
+ /// HttpClient configured with mock handler.
+ private static HttpClient CreateMockHttpClient(string responseContent, HttpStatusCode statusCode)
+ {
+ var handler = new MockHttpMessageHandler(responseContent, statusCode);
+ return new HttpClient(handler);
+ }
+
+ ///
+ /// Mock HTTP message handler for testing.
+ ///
+ private sealed class MockHttpMessageHandler : HttpMessageHandler
+ {
+ ///
+ /// Response content to return.
+ ///
+ private readonly string _responseContent;
+
+ ///
+ /// HTTP status code to return.
+ ///
+ private readonly HttpStatusCode _statusCode;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Response content to return.
+ /// HTTP status code to return.
+ public MockHttpMessageHandler(string responseContent, HttpStatusCode statusCode)
+ {
+ _responseContent = responseContent;
+ _statusCode = statusCode;
+ }
+
+ ///
+ /// Sends a mock HTTP response.
+ ///
+ /// HTTP request message.
+ /// Cancellation token.
+ /// Mock HTTP response.
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ // 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(_statusCode)
+ {
+ Content = content
+ };
+
+ return Task.FromResult(response);
+ }
+ }
+
+ ///
+ /// Mock HTTP message handler for testing pagination.
+ ///
+ private sealed class PaginationMockHttpMessageHandler : HttpMessageHandler
+ {
+ ///
+ /// Request count to track pagination.
+ ///
+ private int _requestCount;
+
+ ///
+ /// Sends a mock HTTP response with pagination.
+ ///
+ /// HTTP request message.
+ /// Cancellation token.
+ /// Mock HTTP response.
+ protected override async Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ // Read request body to determine which page to return
+ var requestBody = request.Content != null
+ ? await request.Content.ReadAsStringAsync(cancellationToken)
+ : string.Empty;
+
+ 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;
+ }
+ }
+}
diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetCommitsTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetCommitsTests.cs
new file mode 100644
index 0000000..da3d0bd
--- /dev/null
+++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetCommitsTests.cs
@@ -0,0 +1,449 @@
+// 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.
+
+using System.Net;
+using System.Text;
+using DemaConsulting.BuildMark.RepoConnectors.GitHub;
+
+namespace DemaConsulting.BuildMark.Tests;
+
+///
+/// Tests for the GitHubGraphQLClient GetCommitsAsync method.
+///
+[TestClass]
+public class GitHubGraphQLClientGetCommitsTests
+{
+ ///
+ /// Test that GetCommitsAsync returns expected commit SHAs with valid response.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetCommitsAsync_ValidResponse_ReturnsCommitShas()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""ref"": {
+ ""target"": {
+ ""history"": {
+ ""nodes"": [
+ { ""oid"": ""abc123"" },
+ { ""oid"": ""def456"" },
+ { ""oid"": ""ghi789"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
+
+ // Assert
+ Assert.IsNotNull(commitShas);
+ Assert.HasCount(3, commitShas);
+ Assert.AreEqual("abc123", commitShas[0]);
+ Assert.AreEqual("def456", commitShas[1]);
+ Assert.AreEqual("ghi789", commitShas[2]);
+ }
+
+ ///
+ /// Test that GetCommitsAsync returns empty list when no commits are found.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetCommitsAsync_NoCommits_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""ref"": {
+ ""target"": {
+ ""history"": {
+ ""nodes"": [],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
+
+ // Assert
+ Assert.IsNotNull(commitShas);
+ Assert.IsEmpty(commitShas);
+ }
+
+ ///
+ /// Test that GetCommitsAsync returns empty list when response has missing data.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetCommitsAsync_MissingData_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": null
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
+
+ // Assert
+ Assert.IsNotNull(commitShas);
+ Assert.IsEmpty(commitShas);
+ }
+
+ ///
+ /// Test that GetCommitsAsync returns empty list on HTTP error.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetCommitsAsync_HttpError_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{ ""message"": ""Not Found"" }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.NotFound);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
+
+ // Assert
+ Assert.IsNotNull(commitShas);
+ Assert.IsEmpty(commitShas);
+ }
+
+ ///
+ /// Test that GetCommitsAsync returns empty list on invalid JSON.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetCommitsAsync_InvalidJson_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = "This is not valid JSON";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
+
+ // Assert
+ Assert.IsNotNull(commitShas);
+ Assert.IsEmpty(commitShas);
+ }
+
+ ///
+ /// Test that GetCommitsAsync returns single commit SHA correctly.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetCommitsAsync_SingleCommit_ReturnsOneCommitSha()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""ref"": {
+ ""target"": {
+ ""history"": {
+ ""nodes"": [
+ { ""oid"": ""abc123def456"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
+
+ // Assert
+ Assert.IsNotNull(commitShas);
+ Assert.HasCount(1, commitShas);
+ Assert.AreEqual("abc123def456", commitShas[0]);
+ }
+
+ ///
+ /// Test that GetCommitsAsync handles nodes with missing oid property.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetCommitsAsync_MissingOidProperty_SkipsInvalidNodes()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""ref"": {
+ ""target"": {
+ ""history"": {
+ ""nodes"": [
+ { ""oid"": ""commit1"" },
+ { ""author"": ""missing oid"" },
+ { ""oid"": ""commit2"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
+
+ // Assert
+ Assert.IsNotNull(commitShas);
+ Assert.HasCount(2, commitShas);
+ Assert.AreEqual("commit1", commitShas[0]);
+ Assert.AreEqual("commit2", commitShas[1]);
+ }
+
+ ///
+ /// Test that GetCommitsAsync handles pagination correctly.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetCommitsAsync_WithPagination_ReturnsAllCommits()
+ {
+ // Arrange - Create mock handler that returns different responses for different pages
+ var mockHandler = new CommitPaginationMockHttpMessageHandler();
+ using var httpClient = new HttpClient(mockHandler);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
+
+ // Assert
+ Assert.IsNotNull(commitShas);
+ Assert.HasCount(3, commitShas);
+ Assert.AreEqual("page1commit", commitShas[0]);
+ Assert.AreEqual("page2commit", commitShas[1]);
+ Assert.AreEqual("page3commit", commitShas[2]);
+ }
+
+ ///
+ /// Mock HTTP message handler for testing commit pagination.
+ ///
+ private sealed class CommitPaginationMockHttpMessageHandler : HttpMessageHandler
+ {
+ ///
+ /// Request count to track pagination.
+ ///
+ private int _requestCount;
+
+ ///
+ /// Sends a mock HTTP response with pagination.
+ ///
+ /// HTTP request message.
+ /// Cancellation token.
+ /// Mock HTTP response.
+ protected override async Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ // Read request body to determine which page to return
+ var requestBody = request.Content != null
+ ? await request.Content.ReadAsStringAsync(cancellationToken)
+ : string.Empty;
+
+ string responseContent;
+ if (_requestCount == 0 || !requestBody.Contains("\"after\""))
+ {
+ // First page
+ responseContent = @"{
+ ""data"": {
+ ""repository"": {
+ ""ref"": {
+ ""target"": {
+ ""history"": {
+ ""nodes"": [
+ { ""oid"": ""page1commit"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": true,
+ ""endCursor"": ""cursor1""
+ }
+ }
+ }
+ }
+ }
+ }
+ }";
+ }
+ else if (requestBody.Contains("\"cursor1\""))
+ {
+ // Second page
+ responseContent = @"{
+ ""data"": {
+ ""repository"": {
+ ""ref"": {
+ ""target"": {
+ ""history"": {
+ ""nodes"": [
+ { ""oid"": ""page2commit"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": true,
+ ""endCursor"": ""cursor2""
+ }
+ }
+ }
+ }
+ }
+ }
+ }";
+ }
+ else
+ {
+ // Third (last) page
+ responseContent = @"{
+ ""data"": {
+ ""repository"": {
+ ""ref"": {
+ ""target"": {
+ ""history"": {
+ ""nodes"": [
+ { ""oid"": ""page3commit"" }
+ ],
+ ""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;
+ }
+ }
+ ///
+ /// Creates a mock HttpClient with pre-canned response.
+ ///
+ /// Response content to return.
+ /// HTTP status code to return.
+ /// HttpClient configured with mock handler.
+ private static HttpClient CreateMockHttpClient(string responseContent, HttpStatusCode statusCode)
+ {
+ var handler = new MockHttpMessageHandler(responseContent, statusCode);
+ return new HttpClient(handler);
+ }
+
+ ///
+ /// Mock HTTP message handler for testing.
+ ///
+ private sealed class MockHttpMessageHandler : HttpMessageHandler
+ {
+ ///
+ /// Response content to return.
+ ///
+ private readonly string _responseContent;
+
+ ///
+ /// HTTP status code to return.
+ ///
+ private readonly HttpStatusCode _statusCode;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Response content to return.
+ /// HTTP status code to return.
+ public MockHttpMessageHandler(string responseContent, HttpStatusCode statusCode)
+ {
+ _responseContent = responseContent;
+ _statusCode = statusCode;
+ }
+
+ ///
+ /// Sends a mock HTTP response.
+ ///
+ /// HTTP request message.
+ /// Cancellation token.
+ /// Mock HTTP response.
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ // 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(_statusCode)
+ {
+ Content = content
+ };
+
+ return Task.FromResult(response);
+ }
+ }
+}
diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetReleasesTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetReleasesTests.cs
new file mode 100644
index 0000000..23c228a
--- /dev/null
+++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetReleasesTests.cs
@@ -0,0 +1,421 @@
+// 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.
+
+using System.Net;
+using System.Text;
+using DemaConsulting.BuildMark.RepoConnectors.GitHub;
+
+namespace DemaConsulting.BuildMark.Tests;
+
+///
+/// Tests for the GitHubGraphQLClient GetReleasesAsync method.
+///
+[TestClass]
+public class GitHubGraphQLClientGetReleasesTests
+{
+ ///
+ /// Test that GetReleasesAsync returns expected release tag names with valid response.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_ValidResponse_ReturnsReleaseTagNames()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v1.0.0"" },
+ { ""tagName"": ""v0.9.0"" },
+ { ""tagName"": ""v0.8.5"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.HasCount(3, releaseNodes);
+ Assert.AreEqual("v1.0.0", releaseNodes[0].TagName);
+ Assert.AreEqual("v0.9.0", releaseNodes[1].TagName);
+ Assert.AreEqual("v0.8.5", releaseNodes[2].TagName);
+ }
+
+ ///
+ /// Test that GetReleasesAsync returns empty list when no releases are found.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_NoReleases_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.IsEmpty(releaseNodes);
+ }
+
+ ///
+ /// Test that GetReleasesAsync returns empty list when response has missing data.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_MissingData_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": null
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.IsEmpty(releaseNodes);
+ }
+
+ ///
+ /// Test that GetReleasesAsync returns empty list on HTTP error.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_HttpError_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{ ""message"": ""Not Found"" }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.NotFound);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.IsEmpty(releaseNodes);
+ }
+
+ ///
+ /// Test that GetReleasesAsync returns empty list on invalid JSON.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_InvalidJson_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = "This is not valid JSON";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.IsEmpty(releaseNodes);
+ }
+
+ ///
+ /// Test that GetReleasesAsync returns single release tag correctly.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_SingleRelease_ReturnsOneTagName()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v2.0.0-beta1"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.HasCount(1, releaseNodes);
+ Assert.AreEqual("v2.0.0-beta1", releaseNodes[0].TagName);
+ }
+
+ ///
+ /// Test that GetReleasesAsync handles nodes with missing tagName property.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_MissingTagNameProperty_SkipsInvalidNodes()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v1.0.0"" },
+ { ""name"": ""Missing tag name"" },
+ { ""tagName"": ""v0.9.0"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.HasCount(2, releaseNodes);
+ Assert.AreEqual("v1.0.0", releaseNodes[0].TagName);
+ Assert.AreEqual("v0.9.0", releaseNodes[1].TagName);
+ }
+
+ ///
+ /// Test that GetReleasesAsync handles pagination correctly.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_WithPagination_ReturnsAllReleases()
+ {
+ // Arrange - Create mock handler that returns different responses for different pages
+ var mockHandler = new ReleasePaginationMockHttpMessageHandler();
+ using var httpClient = new HttpClient(mockHandler);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.HasCount(3, releaseNodes);
+ Assert.AreEqual("v3.0.0", releaseNodes[0].TagName);
+ Assert.AreEqual("v2.0.0", releaseNodes[1].TagName);
+ Assert.AreEqual("v1.0.0", releaseNodes[2].TagName);
+ }
+
+ ///
+ /// Mock HTTP message handler for testing release pagination.
+ ///
+ private sealed class ReleasePaginationMockHttpMessageHandler : HttpMessageHandler
+ {
+ ///
+ /// Request count to track pagination.
+ ///
+ private int _requestCount;
+
+ ///
+ /// Sends a mock HTTP response with pagination.
+ ///
+ /// HTTP request message.
+ /// Cancellation token.
+ /// Mock HTTP response.
+ protected override async Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ // Read request body to determine which page to return
+ var requestBody = request.Content != null
+ ? await request.Content.ReadAsStringAsync(cancellationToken)
+ : string.Empty;
+
+ string responseContent;
+ if (_requestCount == 0 || !requestBody.Contains("\"after\""))
+ {
+ // First page
+ responseContent = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v3.0.0"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": true,
+ ""endCursor"": ""cursor1""
+ }
+ }
+ }
+ }
+ }";
+ }
+ else if (requestBody.Contains("\"cursor1\""))
+ {
+ // Second page
+ responseContent = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v2.0.0"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": true,
+ ""endCursor"": ""cursor2""
+ }
+ }
+ }
+ }
+ }";
+ }
+ else
+ {
+ // Third (last) page
+ responseContent = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v1.0.0"" }
+ ],
+ ""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;
+ }
+ }
+ ///
+ /// Creates a mock HttpClient with pre-canned response.
+ ///
+ /// Response content to return.
+ /// HTTP status code to return.
+ /// HttpClient configured with mock handler.
+ private static HttpClient CreateMockHttpClient(string responseContent, HttpStatusCode statusCode)
+ {
+ var handler = new MockHttpMessageHandler(responseContent, statusCode);
+ return new HttpClient(handler);
+ }
+
+ ///
+ /// Mock HTTP message handler for testing.
+ ///
+ private sealed class MockHttpMessageHandler : HttpMessageHandler
+ {
+ ///
+ /// Response content to return.
+ ///
+ private readonly string _responseContent;
+
+ ///
+ /// HTTP status code to return.
+ ///
+ private readonly HttpStatusCode _statusCode;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Response content to return.
+ /// HTTP status code to return.
+ public MockHttpMessageHandler(string responseContent, HttpStatusCode statusCode)
+ {
+ _responseContent = responseContent;
+ _statusCode = statusCode;
+ }
+
+ ///
+ /// Sends a mock HTTP response.
+ ///
+ /// HTTP request message.
+ /// Cancellation token.
+ /// Mock HTTP response.
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ // 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(_statusCode)
+ {
+ Content = content
+ };
+
+ return Task.FromResult(response);
+ }
+ }
+}
diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs
deleted file mode 100644
index c4f0726..0000000
--- a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs
+++ /dev/null
@@ -1,1122 +0,0 @@
-// 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.
-
-using System.Net;
-using System.Text;
-using DemaConsulting.BuildMark.RepoConnectors.GitHub;
-
-namespace DemaConsulting.BuildMark.Tests;
-
-///
-/// Tests for the GitHubGraphQLClient class.
-///
-[TestClass]
-public class GitHubGraphQLClientTests
-{
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync returns expected issue IDs with valid response.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_ValidResponse_ReturnsIssueIds()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""pullRequest"": {
- ""closingIssuesReferences"": {
- ""nodes"": [
- { ""number"": 123 },
- { ""number"": 456 },
- { ""number"": 789 }
- ],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
-
- // Assert
- Assert.IsNotNull(issueIds);
- Assert.HasCount(3, issueIds);
- Assert.AreEqual(123, issueIds[0]);
- Assert.AreEqual(456, issueIds[1]);
- Assert.AreEqual(789, issueIds[2]);
- }
-
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list when no issues are linked.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_NoIssues_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""pullRequest"": {
- ""closingIssuesReferences"": {
- ""nodes"": [],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
-
- // Assert
- Assert.IsNotNull(issueIds);
- Assert.IsEmpty(issueIds);
- }
-
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list when response has missing data.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_MissingData_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": null
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
-
- // Assert
- Assert.IsNotNull(issueIds);
- Assert.IsEmpty(issueIds);
- }
-
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list on HTTP error.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_HttpError_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = @"{ ""message"": ""Not Found"" }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.NotFound);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
-
- // Assert
- Assert.IsNotNull(issueIds);
- Assert.IsEmpty(issueIds);
- }
-
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list on invalid JSON.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_InvalidJson_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = "This is not valid JSON";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
-
- // Assert
- Assert.IsNotNull(issueIds);
- Assert.IsEmpty(issueIds);
- }
-
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync returns single issue ID correctly.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_SingleIssue_ReturnsOneIssueId()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""pullRequest"": {
- ""closingIssuesReferences"": {
- ""nodes"": [
- { ""number"": 999 }
- ],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 1);
-
- // Assert
- Assert.IsNotNull(issueIds);
- Assert.HasCount(1, issueIds);
- Assert.AreEqual(999, issueIds[0]);
- }
-
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync handles nodes with missing number property.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_MissingNumberProperty_SkipsInvalidNodes()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""pullRequest"": {
- ""closingIssuesReferences"": {
- ""nodes"": [
- { ""number"": 100 },
- { ""title"": ""Missing number"" },
- { ""number"": 200 }
- ],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 5);
-
- // Assert
- Assert.IsNotNull(issueIds);
- Assert.HasCount(2, issueIds);
- Assert.AreEqual(100, issueIds[0]);
- Assert.AreEqual(200, issueIds[1]);
- }
-
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync handles pagination correctly.
- ///
- [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]);
- }
-
- ///
- /// Creates a mock HttpClient with pre-canned response.
- ///
- /// Response content to return.
- /// HTTP status code to return.
- /// HttpClient configured with mock handler.
- private static HttpClient CreateMockHttpClient(string responseContent, HttpStatusCode statusCode)
- {
- var handler = new MockHttpMessageHandler(responseContent, statusCode);
- return new HttpClient(handler);
- }
-
- ///
- /// Mock HTTP message handler for testing.
- ///
- private sealed class MockHttpMessageHandler : HttpMessageHandler
- {
- ///
- /// Response content to return.
- ///
- private readonly string _responseContent;
-
- ///
- /// HTTP status code to return.
- ///
- private readonly HttpStatusCode _statusCode;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// Response content to return.
- /// HTTP status code to return.
- public MockHttpMessageHandler(string responseContent, HttpStatusCode statusCode)
- {
- _responseContent = responseContent;
- _statusCode = statusCode;
- }
-
- ///
- /// Sends a mock HTTP response.
- ///
- /// HTTP request message.
- /// Cancellation token.
- /// Mock HTTP response.
- protected override Task SendAsync(
- HttpRequestMessage request,
- CancellationToken cancellationToken)
- {
- // 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(_statusCode)
- {
- Content = content
- };
-
- return Task.FromResult(response);
- }
- }
-
- ///
- /// Mock HTTP message handler for testing pagination.
- ///
- private sealed class PaginationMockHttpMessageHandler : HttpMessageHandler
- {
- ///
- /// Request count to track pagination.
- ///
- private int _requestCount;
-
- ///
- /// Sends a mock HTTP response with pagination.
- ///
- /// HTTP request message.
- /// Cancellation token.
- /// Mock HTTP response.
- protected override async Task SendAsync(
- HttpRequestMessage request,
- CancellationToken cancellationToken)
- {
- // Read request body to determine which page to return
- var requestBody = request.Content != null
- ? await request.Content.ReadAsStringAsync(cancellationToken)
- : string.Empty;
-
- 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;
- }
- }
-
- ///
- /// Test that GetCommitsAsync returns expected commit SHAs with valid response.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetCommitsAsync_ValidResponse_ReturnsCommitShas()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""ref"": {
- ""target"": {
- ""history"": {
- ""nodes"": [
- { ""oid"": ""abc123"" },
- { ""oid"": ""def456"" },
- { ""oid"": ""ghi789"" }
- ],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
-
- // Assert
- Assert.IsNotNull(commitShas);
- Assert.HasCount(3, commitShas);
- Assert.AreEqual("abc123", commitShas[0]);
- Assert.AreEqual("def456", commitShas[1]);
- Assert.AreEqual("ghi789", commitShas[2]);
- }
-
- ///
- /// Test that GetCommitsAsync returns empty list when no commits are found.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetCommitsAsync_NoCommits_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""ref"": {
- ""target"": {
- ""history"": {
- ""nodes"": [],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
-
- // Assert
- Assert.IsNotNull(commitShas);
- Assert.IsEmpty(commitShas);
- }
-
- ///
- /// Test that GetCommitsAsync returns empty list when response has missing data.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetCommitsAsync_MissingData_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": null
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
-
- // Assert
- Assert.IsNotNull(commitShas);
- Assert.IsEmpty(commitShas);
- }
-
- ///
- /// Test that GetCommitsAsync returns empty list on HTTP error.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetCommitsAsync_HttpError_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = @"{ ""message"": ""Not Found"" }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.NotFound);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
-
- // Assert
- Assert.IsNotNull(commitShas);
- Assert.IsEmpty(commitShas);
- }
-
- ///
- /// Test that GetCommitsAsync returns empty list on invalid JSON.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetCommitsAsync_InvalidJson_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = "This is not valid JSON";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
-
- // Assert
- Assert.IsNotNull(commitShas);
- Assert.IsEmpty(commitShas);
- }
-
- ///
- /// Test that GetCommitsAsync returns single commit SHA correctly.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetCommitsAsync_SingleCommit_ReturnsOneCommitSha()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""ref"": {
- ""target"": {
- ""history"": {
- ""nodes"": [
- { ""oid"": ""abc123def456"" }
- ],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
-
- // Assert
- Assert.IsNotNull(commitShas);
- Assert.HasCount(1, commitShas);
- Assert.AreEqual("abc123def456", commitShas[0]);
- }
-
- ///
- /// Test that GetCommitsAsync handles nodes with missing oid property.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetCommitsAsync_MissingOidProperty_SkipsInvalidNodes()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""ref"": {
- ""target"": {
- ""history"": {
- ""nodes"": [
- { ""oid"": ""commit1"" },
- { ""author"": ""missing oid"" },
- { ""oid"": ""commit2"" }
- ],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
-
- // Assert
- Assert.IsNotNull(commitShas);
- Assert.HasCount(2, commitShas);
- Assert.AreEqual("commit1", commitShas[0]);
- Assert.AreEqual("commit2", commitShas[1]);
- }
-
- ///
- /// Test that GetCommitsAsync handles pagination correctly.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetCommitsAsync_WithPagination_ReturnsAllCommits()
- {
- // Arrange - Create mock handler that returns different responses for different pages
- var mockHandler = new CommitPaginationMockHttpMessageHandler();
- using var httpClient = new HttpClient(mockHandler);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var commitShas = await client.GetCommitsAsync("owner", "repo", "main");
-
- // Assert
- Assert.IsNotNull(commitShas);
- Assert.HasCount(3, commitShas);
- Assert.AreEqual("page1commit", commitShas[0]);
- Assert.AreEqual("page2commit", commitShas[1]);
- Assert.AreEqual("page3commit", commitShas[2]);
- }
-
- ///
- /// Mock HTTP message handler for testing commit pagination.
- ///
- private sealed class CommitPaginationMockHttpMessageHandler : HttpMessageHandler
- {
- ///
- /// Request count to track pagination.
- ///
- private int _requestCount;
-
- ///
- /// Sends a mock HTTP response with pagination.
- ///
- /// HTTP request message.
- /// Cancellation token.
- /// Mock HTTP response.
- protected override async Task SendAsync(
- HttpRequestMessage request,
- CancellationToken cancellationToken)
- {
- // Read request body to determine which page to return
- var requestBody = request.Content != null
- ? await request.Content.ReadAsStringAsync(cancellationToken)
- : string.Empty;
-
- string responseContent;
- if (_requestCount == 0 || !requestBody.Contains("\"after\""))
- {
- // First page
- responseContent = @"{
- ""data"": {
- ""repository"": {
- ""ref"": {
- ""target"": {
- ""history"": {
- ""nodes"": [
- { ""oid"": ""page1commit"" }
- ],
- ""pageInfo"": {
- ""hasNextPage"": true,
- ""endCursor"": ""cursor1""
- }
- }
- }
- }
- }
- }
- }";
- }
- else if (requestBody.Contains("\"cursor1\""))
- {
- // Second page
- responseContent = @"{
- ""data"": {
- ""repository"": {
- ""ref"": {
- ""target"": {
- ""history"": {
- ""nodes"": [
- { ""oid"": ""page2commit"" }
- ],
- ""pageInfo"": {
- ""hasNextPage"": true,
- ""endCursor"": ""cursor2""
- }
- }
- }
- }
- }
- }
- }";
- }
- else
- {
- // Third (last) page
- responseContent = @"{
- ""data"": {
- ""repository"": {
- ""ref"": {
- ""target"": {
- ""history"": {
- ""nodes"": [
- { ""oid"": ""page3commit"" }
- ],
- ""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;
- }
- }
-
- ///
- /// Test that GetReleasesAsync returns expected release tag names with valid response.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetReleasesAsync_ValidResponse_ReturnsReleaseTagNames()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""releases"": {
- ""nodes"": [
- { ""tagName"": ""v1.0.0"" },
- { ""tagName"": ""v0.9.0"" },
- { ""tagName"": ""v0.8.5"" }
- ],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var releaseNodes = await client.GetReleasesAsync("owner", "repo");
-
- // Assert
- Assert.IsNotNull(releaseNodes);
- Assert.HasCount(3, releaseNodes);
- Assert.AreEqual("v1.0.0", releaseNodes[0].TagName);
- Assert.AreEqual("v0.9.0", releaseNodes[1].TagName);
- Assert.AreEqual("v0.8.5", releaseNodes[2].TagName);
- }
-
- ///
- /// Test that GetReleasesAsync returns empty list when no releases are found.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetReleasesAsync_NoReleases_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""releases"": {
- ""nodes"": [],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var releaseNodes = await client.GetReleasesAsync("owner", "repo");
-
- // Assert
- Assert.IsNotNull(releaseNodes);
- Assert.IsEmpty(releaseNodes);
- }
-
- ///
- /// Test that GetReleasesAsync returns empty list when response has missing data.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetReleasesAsync_MissingData_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": null
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var releaseNodes = await client.GetReleasesAsync("owner", "repo");
-
- // Assert
- Assert.IsNotNull(releaseNodes);
- Assert.IsEmpty(releaseNodes);
- }
-
- ///
- /// Test that GetReleasesAsync returns empty list on HTTP error.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetReleasesAsync_HttpError_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = @"{ ""message"": ""Not Found"" }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.NotFound);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var releaseNodes = await client.GetReleasesAsync("owner", "repo");
-
- // Assert
- Assert.IsNotNull(releaseNodes);
- Assert.IsEmpty(releaseNodes);
- }
-
- ///
- /// Test that GetReleasesAsync returns empty list on invalid JSON.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetReleasesAsync_InvalidJson_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = "This is not valid JSON";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var releaseNodes = await client.GetReleasesAsync("owner", "repo");
-
- // Assert
- Assert.IsNotNull(releaseNodes);
- Assert.IsEmpty(releaseNodes);
- }
-
- ///
- /// Test that GetReleasesAsync returns single release tag correctly.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetReleasesAsync_SingleRelease_ReturnsOneTagName()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""releases"": {
- ""nodes"": [
- { ""tagName"": ""v2.0.0-beta1"" }
- ],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var releaseNodes = await client.GetReleasesAsync("owner", "repo");
-
- // Assert
- Assert.IsNotNull(releaseNodes);
- Assert.HasCount(1, releaseNodes);
- Assert.AreEqual("v2.0.0-beta1", releaseNodes[0].TagName);
- }
-
- ///
- /// Test that GetReleasesAsync handles nodes with missing tagName property.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetReleasesAsync_MissingTagNameProperty_SkipsInvalidNodes()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""releases"": {
- ""nodes"": [
- { ""tagName"": ""v1.0.0"" },
- { ""name"": ""Missing tag name"" },
- { ""tagName"": ""v0.9.0"" }
- ],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var releaseNodes = await client.GetReleasesAsync("owner", "repo");
-
- // Assert
- Assert.IsNotNull(releaseNodes);
- Assert.HasCount(2, releaseNodes);
- Assert.AreEqual("v1.0.0", releaseNodes[0].TagName);
- Assert.AreEqual("v0.9.0", releaseNodes[1].TagName);
- }
-
- ///
- /// Test that GetReleasesAsync handles pagination correctly.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_GetReleasesAsync_WithPagination_ReturnsAllReleases()
- {
- // Arrange - Create mock handler that returns different responses for different pages
- var mockHandler = new ReleasePaginationMockHttpMessageHandler();
- using var httpClient = new HttpClient(mockHandler);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var releaseNodes = await client.GetReleasesAsync("owner", "repo");
-
- // Assert
- Assert.IsNotNull(releaseNodes);
- Assert.HasCount(3, releaseNodes);
- Assert.AreEqual("v3.0.0", releaseNodes[0].TagName);
- Assert.AreEqual("v2.0.0", releaseNodes[1].TagName);
- Assert.AreEqual("v1.0.0", releaseNodes[2].TagName);
- }
-
- ///
- /// Mock HTTP message handler for testing release pagination.
- ///
- private sealed class ReleasePaginationMockHttpMessageHandler : HttpMessageHandler
- {
- ///
- /// Request count to track pagination.
- ///
- private int _requestCount;
-
- ///
- /// Sends a mock HTTP response with pagination.
- ///
- /// HTTP request message.
- /// Cancellation token.
- /// Mock HTTP response.
- protected override async Task SendAsync(
- HttpRequestMessage request,
- CancellationToken cancellationToken)
- {
- // Read request body to determine which page to return
- var requestBody = request.Content != null
- ? await request.Content.ReadAsStringAsync(cancellationToken)
- : string.Empty;
-
- string responseContent;
- if (_requestCount == 0 || !requestBody.Contains("\"after\""))
- {
- // First page
- responseContent = @"{
- ""data"": {
- ""repository"": {
- ""releases"": {
- ""nodes"": [
- { ""tagName"": ""v3.0.0"" }
- ],
- ""pageInfo"": {
- ""hasNextPage"": true,
- ""endCursor"": ""cursor1""
- }
- }
- }
- }
- }";
- }
- else if (requestBody.Contains("\"cursor1\""))
- {
- // Second page
- responseContent = @"{
- ""data"": {
- ""repository"": {
- ""releases"": {
- ""nodes"": [
- { ""tagName"": ""v2.0.0"" }
- ],
- ""pageInfo"": {
- ""hasNextPage"": true,
- ""endCursor"": ""cursor2""
- }
- }
- }
- }
- }";
- }
- else
- {
- // Third (last) page
- responseContent = @"{
- ""data"": {
- ""repository"": {
- ""releases"": {
- ""nodes"": [
- { ""tagName"": ""v1.0.0"" }
- ],
- ""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;
- }
- }
-}