Skip to content

Commit c9cd29b

Browse files
Refactor GitHub connector to use Octokit.Net with reusable GitHubGraphQLClient for issue linking (#28)
* Initial plan * Refactor IRepoConnector to use GetBuildInformationAsync with Octokit.Net Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Update tests to work with new IRepoConnector interface Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Address code review feedback - fix performance and async issues Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Address PR feedback: remove deprecated CreateAsync, remove standardInput, fix issue-to-PR matching Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Move FindTagIndex to RepoConnectorBase for better code organization Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Fix version tag ordering by using releases instead of commit SHAs Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Refactor to use releases for version history and filter by branch commits Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Fix release index logic for proper chronological ordering Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Fix loop condition for searching older releases Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Simplify and clarify helper dictionary building code with better comments Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Fix code formatting (whitespace) issues Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Address PR feedback: simplify code, use var, rename variables, fix commit range exclusion Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Update namespaces: align RepoConnectors folder with DemaConsulting.BuildMark.RepoConnectors namespace Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Fix issue linking and open PR inclusion in build notes Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Simplify commitHashToPr dictionary building with ternary operator Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Fix issue URL to use issue.Url instead of issue.HtmlUrl Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Use GitHub Search API to find issues linked to PRs with deduplication Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Simplify linked:pr query - remove redundant owner/repo since repo is already scoped Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Replace beta Octokit.GraphQL with raw HTTP GraphQL queries for better maintainability Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Extract GraphQL client to separate GitHubGraphQLClient class with GitHub Enterprise support Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com>
1 parent 66ff796 commit c9cd29b

14 files changed

+815
-939
lines changed

src/DemaConsulting.BuildMark/BuildInformation.cs

Lines changed: 0 additions & 195 deletions
Original file line numberDiff line numberDiff line change
@@ -39,180 +39,6 @@ public record BuildInformation(
3939
List<ItemInfo> Bugs,
4040
List<ItemInfo> KnownIssues)
4141
{
42-
/// <summary>
43-
/// Creates a BuildInformation record from a repository connector.
44-
/// </summary>
45-
/// <param name="connector">Repository connector to fetch information from.</param>
46-
/// <param name="version">Optional target version. If not provided, uses the most recent tag if it matches current commit.</param>
47-
/// <returns>BuildInformation record with all collected data.</returns>
48-
/// <exception cref="InvalidOperationException">Thrown if version cannot be determined.</exception>
49-
public static async Task<BuildInformation> CreateAsync(IRepoConnector connector, Version? version = null)
50-
{
51-
// Retrieve tag history and current commit hash from the repository
52-
var tags = await connector.GetTagHistoryAsync();
53-
var currentHash = await connector.GetHashForTagAsync(null);
54-
55-
// Determine the target version and hash for build information
56-
Version toTagInfo;
57-
string toHash;
58-
if (version != null)
59-
{
60-
// Use explicitly specified version as target
61-
toTagInfo = version;
62-
toHash = currentHash;
63-
}
64-
else if (tags.Count > 0)
65-
{
66-
// Verify current commit matches latest tag when no version specified
67-
var latestTag = tags[^1];
68-
var latestTagHash = await connector.GetHashForTagAsync(latestTag.Tag);
69-
70-
if (latestTagHash.Trim() == currentHash.Trim())
71-
{
72-
// Current commit matches latest tag, use it as target
73-
toTagInfo = latestTag;
74-
toHash = currentHash;
75-
}
76-
else
77-
{
78-
// Current commit doesn't match any tag, cannot determine version
79-
throw new InvalidOperationException(
80-
"Target version not specified and current commit does not match any tag. " +
81-
"Please provide a version parameter.");
82-
}
83-
}
84-
else
85-
{
86-
// No tags in repository and no version provided
87-
throw new InvalidOperationException(
88-
"No tags found in repository and no version specified. " +
89-
"Please provide a version parameter.");
90-
}
91-
92-
// Determine the starting version for comparing changes
93-
Version? fromTagInfo = null;
94-
string? fromHash = null;
95-
if (tags.Count > 0)
96-
{
97-
// Find the position of target version in tag history
98-
var toIndex = FindTagIndex(tags, toTagInfo.FullVersion);
99-
100-
if (toTagInfo.IsPreRelease)
101-
{
102-
// Pre-release versions use the immediately previous tag as baseline
103-
if (toIndex > 0)
104-
{
105-
// Target version exists in history, use previous tag
106-
fromTagInfo = tags[toIndex - 1];
107-
}
108-
else if (toIndex == -1)
109-
{
110-
// Target version not in history, use most recent tag as baseline
111-
fromTagInfo = tags[^1];
112-
}
113-
// If toIndex == 0, this is the first tag, no baseline
114-
}
115-
else
116-
{
117-
// Release versions skip pre-releases and use previous release as baseline
118-
int startIndex;
119-
if (toIndex > 0)
120-
{
121-
// Target version exists in history, start search from previous position
122-
startIndex = toIndex - 1;
123-
}
124-
else if (toIndex == -1)
125-
{
126-
// Target version not in history, start from most recent tag
127-
startIndex = tags.Count - 1;
128-
}
129-
else
130-
{
131-
// Target is first tag, no previous release exists
132-
startIndex = -1;
133-
}
134-
135-
// Search backward for previous non-pre-release version
136-
for (var i = startIndex; i >= 0; i--)
137-
{
138-
if (!tags[i].IsPreRelease)
139-
{
140-
fromTagInfo = tags[i];
141-
break;
142-
}
143-
}
144-
}
145-
146-
// Get commit hash for baseline version if one was found
147-
if (fromTagInfo != null)
148-
{
149-
fromHash = await connector.GetHashForTagAsync(fromTagInfo.Tag);
150-
}
151-
}
152-
153-
// Collect all changes (issues and PRs) in version range
154-
var changes = await connector.GetChangesBetweenTagsAsync(fromTagInfo, toTagInfo);
155-
var allChangeIds = new HashSet<string>();
156-
var bugs = new List<ItemInfo>();
157-
var nonBugChanges = new List<ItemInfo>();
158-
159-
// Process and categorize each change
160-
foreach (var change in changes)
161-
{
162-
// Skip changes already processed
163-
if (allChangeIds.Contains(change.Id))
164-
{
165-
continue;
166-
}
167-
168-
// Mark change as processed
169-
allChangeIds.Add(change.Id);
170-
171-
// Categorize change by type
172-
if (change.Type == "bug")
173-
{
174-
bugs.Add(change);
175-
}
176-
else
177-
{
178-
nonBugChanges.Add(change);
179-
}
180-
}
181-
182-
// Collect known issues (open bugs not fixed in this build)
183-
var knownIssues = new List<ItemInfo>();
184-
var openIssues = await connector.GetOpenIssuesAsync();
185-
foreach (var issue in openIssues)
186-
{
187-
// Skip issues already fixed in this build
188-
if (allChangeIds.Contains(issue.Id))
189-
{
190-
continue;
191-
}
192-
193-
// Only include bugs in known issues list
194-
if (issue.Type == "bug")
195-
{
196-
knownIssues.Add(issue);
197-
}
198-
}
199-
200-
// Sort all lists by Index to ensure chronological order
201-
nonBugChanges.Sort((a, b) => a.Index.CompareTo(b.Index));
202-
bugs.Sort((a, b) => a.Index.CompareTo(b.Index));
203-
knownIssues.Sort((a, b) => a.Index.CompareTo(b.Index));
204-
205-
// Create and return build information with all collected data
206-
return new BuildInformation(
207-
fromTagInfo,
208-
toTagInfo,
209-
fromHash?.Trim(),
210-
toHash.Trim(),
211-
nonBugChanges,
212-
bugs,
213-
knownIssues);
214-
}
215-
21642
/// <summary>
21743
/// Generates a Markdown build report from this build information.
21844
/// </summary>
@@ -311,25 +137,4 @@ public string ToMarkdown(int headingDepth = 1, bool includeKnownIssues = false)
311137
// Return the complete markdown report
312138
return markdown.ToString();
313139
}
314-
315-
/// <summary>
316-
/// Finds the index of a tag in the tag history by normalized version.
317-
/// </summary>
318-
/// <param name="tags">List of tags.</param>
319-
/// <param name="normalizedVersion">Normalized version to find.</param>
320-
/// <returns>Index of the tag, or -1 if not found.</returns>
321-
private static int FindTagIndex(List<Version> tags, string normalizedVersion)
322-
{
323-
// Search for tag matching the normalized version
324-
for (var i = 0; i < tags.Count; i++)
325-
{
326-
if (tags[i].FullVersion.Equals(normalizedVersion, StringComparison.OrdinalIgnoreCase))
327-
{
328-
return i;
329-
}
330-
}
331-
332-
// Tag not found in history
333-
return -1;
334-
}
335140
}

src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
<PrivateAssets>all</PrivateAssets>
5454
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
5555
</PackageReference>
56+
<PackageReference Include="Octokit" Version="14.0.0" />
5657
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.18.0.131500">
5758
<PrivateAssets>all</PrivateAssets>
5859
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

src/DemaConsulting.BuildMark/Program.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
// SOFTWARE.
2020

2121
using System.Reflection;
22+
using DemaConsulting.BuildMark.RepoConnectors;
2223

2324
namespace DemaConsulting.BuildMark;
2425

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

0 commit comments

Comments
 (0)