Skip to content

Commit ca0f78b

Browse files
authored
Merge pull request #3 from demaconsulting/copilot/fetch-repository-information
Implement repository information fetching infrastructure
2 parents 354353d + c275eb1 commit ca0f78b

File tree

9 files changed

+1398
-0
lines changed

9 files changed

+1398
-0
lines changed
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright (c) 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.Text.RegularExpressions;
22+
23+
namespace DemaConsulting.BuildMark;
24+
25+
/// <summary>
26+
/// GitHub repository connector implementation.
27+
/// </summary>
28+
public partial class GitHubRepoConnector : RepoConnectorBase
29+
{
30+
private static readonly Dictionary<string, string> LabelTypeMap = new()
31+
{
32+
{ "bug", "bug" },
33+
{ "defect", "bug" },
34+
{ "feature", "feature" },
35+
{ "enhancement", "feature" },
36+
{ "documentation", "documentation" },
37+
{ "performance", "performance" },
38+
{ "security", "security" }
39+
};
40+
41+
/// <summary>
42+
/// Validates and sanitizes a tag name to prevent command injection.
43+
/// </summary>
44+
/// <param name="tag">Tag name to validate.</param>
45+
/// <returns>Sanitized tag name.</returns>
46+
/// <exception cref="ArgumentException">Thrown if tag name is invalid.</exception>
47+
private static string ValidateTag(string tag)
48+
{
49+
if (!TagNameRegex().IsMatch(tag))
50+
{
51+
throw new ArgumentException($"Invalid tag name: {tag}", nameof(tag));
52+
}
53+
54+
return tag;
55+
}
56+
57+
/// <summary>
58+
/// Validates and sanitizes an issue or PR ID to prevent command injection.
59+
/// </summary>
60+
/// <param name="id">ID to validate.</param>
61+
/// <param name="paramName">Parameter name for exception message.</param>
62+
/// <returns>Sanitized ID.</returns>
63+
/// <exception cref="ArgumentException">Thrown if ID is invalid.</exception>
64+
private static string ValidateId(string id, string paramName)
65+
{
66+
if (!NumericIdRegex().IsMatch(id))
67+
{
68+
throw new ArgumentException($"Invalid ID: {id}", paramName);
69+
}
70+
71+
return id;
72+
}
73+
74+
/// <summary>
75+
/// Gets the history of tags leading to the current branch.
76+
/// </summary>
77+
/// <returns>List of tags in chronological order.</returns>
78+
public override async Task<List<string>> GetTagHistoryAsync()
79+
{
80+
var output = await RunCommandAsync("git", "tag --sort=creatordate --merged HEAD");
81+
return output
82+
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
83+
.Select(t => t.Trim())
84+
.ToList();
85+
}
86+
87+
/// <summary>
88+
/// Gets the list of pull request IDs between two tags.
89+
/// </summary>
90+
/// <param name="fromTag">Starting tag (null for start of history).</param>
91+
/// <param name="toTag">Ending tag (null for current state).</param>
92+
/// <returns>List of pull request IDs.</returns>
93+
public override async Task<List<string>> GetPullRequestsBetweenTagsAsync(string? fromTag, string? toTag)
94+
{
95+
string range;
96+
if (string.IsNullOrEmpty(fromTag) && string.IsNullOrEmpty(toTag))
97+
{
98+
range = "HEAD";
99+
}
100+
else if (string.IsNullOrEmpty(fromTag))
101+
{
102+
range = ValidateTag(toTag!);
103+
}
104+
else if (string.IsNullOrEmpty(toTag))
105+
{
106+
range = $"{ValidateTag(fromTag)}..HEAD";
107+
}
108+
else
109+
{
110+
range = $"{ValidateTag(fromTag)}..{ValidateTag(toTag)}";
111+
}
112+
113+
var output = await RunCommandAsync("git", $"log --oneline --merges {range}");
114+
var pullRequests = new List<string>();
115+
var regex = NumberReferenceRegex();
116+
117+
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
118+
{
119+
var match = regex.Match(line);
120+
if (match.Success)
121+
{
122+
pullRequests.Add(match.Groups[1].Value);
123+
}
124+
}
125+
126+
return pullRequests;
127+
}
128+
129+
/// <summary>
130+
/// Gets the issue IDs associated with a pull request.
131+
/// </summary>
132+
/// <param name="pullRequestId">Pull request ID.</param>
133+
/// <returns>List of issue IDs.</returns>
134+
public override async Task<List<string>> GetIssuesForPullRequestAsync(string pullRequestId)
135+
{
136+
var validatedId = ValidateId(pullRequestId, nameof(pullRequestId));
137+
var output = await RunCommandAsync("gh", $"pr view {validatedId} --json body --jq .body");
138+
var issues = new List<string>();
139+
var regex = NumberReferenceRegex();
140+
141+
foreach (Match match in regex.Matches(output))
142+
{
143+
issues.Add(match.Groups[1].Value);
144+
}
145+
146+
return issues;
147+
}
148+
149+
/// <summary>
150+
/// Gets the title of an issue.
151+
/// </summary>
152+
/// <param name="issueId">Issue ID.</param>
153+
/// <returns>Issue title.</returns>
154+
public override async Task<string> GetIssueTitleAsync(string issueId)
155+
{
156+
var validatedId = ValidateId(issueId, nameof(issueId));
157+
return await RunCommandAsync("gh", $"issue view {validatedId} --json title --jq .title");
158+
}
159+
160+
/// <summary>
161+
/// Gets the type of an issue (bug, feature, etc.).
162+
/// </summary>
163+
/// <param name="issueId">Issue ID.</param>
164+
/// <returns>Issue type.</returns>
165+
public override async Task<string> GetIssueTypeAsync(string issueId)
166+
{
167+
var validatedId = ValidateId(issueId, nameof(issueId));
168+
var output = await RunCommandAsync("gh", $"issue view {validatedId} --json labels --jq '.labels[].name'");
169+
var labels = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
170+
171+
// Look for common type labels
172+
foreach (var label in labels)
173+
{
174+
var lowerLabel = label.ToLowerInvariant();
175+
foreach (var (key, value) in LabelTypeMap)
176+
{
177+
if (lowerLabel.Contains(key))
178+
{
179+
return value;
180+
}
181+
}
182+
}
183+
184+
return "other";
185+
}
186+
187+
/// <summary>
188+
/// Gets the git hash for a tag.
189+
/// </summary>
190+
/// <param name="tag">Tag name (null for current state).</param>
191+
/// <returns>Git hash.</returns>
192+
public override async Task<string> GetHashForTagAsync(string? tag)
193+
{
194+
var refName = string.IsNullOrEmpty(tag) ? "HEAD" : ValidateTag(tag);
195+
return await RunCommandAsync("git", $"rev-parse {refName}");
196+
}
197+
198+
/// <summary>
199+
/// Regular expression to match valid tag names (alphanumeric, dots, hyphens, underscores, slashes).
200+
/// </summary>
201+
/// <returns>Compiled regular expression.</returns>
202+
[GeneratedRegex(@"^[a-zA-Z0-9._/-]+$", RegexOptions.Compiled)]
203+
private static partial Regex TagNameRegex();
204+
205+
/// <summary>
206+
/// Regular expression to match numeric IDs.
207+
/// </summary>
208+
/// <returns>Compiled regular expression.</returns>
209+
[GeneratedRegex(@"^\d+$", RegexOptions.Compiled)]
210+
private static partial Regex NumericIdRegex();
211+
212+
/// <summary>
213+
/// Regular expression to match number references (#123).
214+
/// </summary>
215+
/// <returns>Compiled regular expression.</returns>
216+
[GeneratedRegex(@"#(\d+)", RegexOptions.Compiled)]
217+
private static partial Regex NumberReferenceRegex();
218+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) 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+
namespace DemaConsulting.BuildMark;
22+
23+
/// <summary>
24+
/// Interface for repository connectors that fetch repository information.
25+
/// </summary>
26+
public interface IRepoConnector
27+
{
28+
/// <summary>
29+
/// Gets the history of tags leading to the current branch.
30+
/// </summary>
31+
/// <returns>List of tags in chronological order.</returns>
32+
Task<List<string>> GetTagHistoryAsync();
33+
34+
/// <summary>
35+
/// Gets the list of pull request IDs between two tags.
36+
/// </summary>
37+
/// <param name="fromTag">Starting tag (null for start of history).</param>
38+
/// <param name="toTag">Ending tag (null for current state).</param>
39+
/// <returns>List of pull request IDs.</returns>
40+
Task<List<string>> GetPullRequestsBetweenTagsAsync(string? fromTag, string? toTag);
41+
42+
/// <summary>
43+
/// Gets the issue IDs associated with a pull request.
44+
/// </summary>
45+
/// <param name="pullRequestId">Pull request ID.</param>
46+
/// <returns>List of issue IDs.</returns>
47+
Task<List<string>> GetIssuesForPullRequestAsync(string pullRequestId);
48+
49+
/// <summary>
50+
/// Gets the title of an issue.
51+
/// </summary>
52+
/// <param name="issueId">Issue ID.</param>
53+
/// <returns>Issue title.</returns>
54+
Task<string> GetIssueTitleAsync(string issueId);
55+
56+
/// <summary>
57+
/// Gets the type of an issue (bug, feature, etc.).
58+
/// </summary>
59+
/// <param name="issueId">Issue ID.</param>
60+
/// <returns>Issue type.</returns>
61+
Task<string> GetIssueTypeAsync(string issueId);
62+
63+
/// <summary>
64+
/// Gets the git hash for a tag.
65+
/// </summary>
66+
/// <param name="tag">Tag name (null for current state).</param>
67+
/// <returns>Git hash.</returns>
68+
Task<string> GetHashForTagAsync(string? tag);
69+
}

0 commit comments

Comments
 (0)