Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 34 additions & 20 deletions src/DemaConsulting.BuildMark/BuildInformation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,88 +56,91 @@ public record BuildInformation(
/// <exception cref="InvalidOperationException">Thrown if version cannot be determined.</exception>
public static async Task<BuildInformation> CreateAsync(IRepoConnector connector, Version? version = null)
{
// Get tag history and current hash
// Retrieve tag history and current commit hash from the repository
var tags = await connector.GetTagHistoryAsync();
var currentHash = await connector.GetHashForTagAsync(null);

// Determine the "To" version
// Determine the target version and hash for build information
Version toTagInfo;
string toHash;

if (version != null)
{
// Use the provided version
// Use explicitly specified version as target
toTagInfo = version;
toHash = currentHash;
}
else if (tags.Count > 0)
{
// Check if current commit matches the most recent tag
// Verify current commit matches latest tag when no version specified
var latestTag = tags[^1];
var latestTagHash = await connector.GetHashForTagAsync(latestTag.Tag);

if (latestTagHash.Trim() == currentHash.Trim())
{
// Current commit matches latest tag, use it as target
toTagInfo = latestTag;
toHash = currentHash;
}
else
{
// Current commit doesn't match any tag, cannot determine version
throw new InvalidOperationException(
"Target version not specified and current commit does not match any tag. " +
"Please provide a version parameter.");
}
}
else
{
// No tags in repository and no version provided
throw new InvalidOperationException(
"No tags found in repository and no version specified. " +
"Please provide a version parameter.");
}

// Determine the "From" version
// Determine the starting version for comparing changes
Version? fromTagInfo = null;
string? fromHash = null;

if (tags.Count > 0)
{
// Find the position of target version in tag history
var toIndex = FindTagIndex(tags, toTagInfo.FullVersion);

if (toTagInfo.IsPreRelease)
{
// For pre-release: use the previous tag (any type)
// Pre-release versions use the immediately previous tag as baseline
if (toIndex > 0)
{
// The to version exists in tag history, use the previous tag
// Target version exists in history, use previous tag
fromTagInfo = tags[toIndex - 1];
}
else if (toIndex == -1)
{
// The to version doesn't exist in tag history, use the most recent tag
// Target version not in history, use most recent tag as baseline
fromTagInfo = tags[^1];
}
// If toIndex == 0, fromTagInfo stays null (first release)
// If toIndex == 0, this is the first tag, no baseline
}
else
{
// For release: use the previous release tag (skip pre-releases)
// Release versions skip pre-releases and use previous release as baseline
int startIndex;
if (toIndex > 0)
{
// The to version exists in tag history
// Target version exists in history, start search from previous position
startIndex = toIndex - 1;
}
else if (toIndex == -1)
{
// The to version doesn't exist in tag history, use the most recent tag
// Target version not in history, start from most recent tag
startIndex = tags.Count - 1;
}
else
{
// toIndex == 0, this is the first tag, no previous release
// Target is first tag, no previous release exists
startIndex = -1;
}

// Search backward for previous non-pre-release version
for (var i = startIndex; i >= 0; i--)
{
if (!tags[i].IsPreRelease)
Expand All @@ -148,37 +151,45 @@ public static async Task<BuildInformation> CreateAsync(IRepoConnector connector,
}
}

// Get commit hash for baseline version if one was found
if (fromTagInfo != null)
{
fromHash = await connector.GetHashForTagAsync(fromTagInfo.Tag);
}
}

// Get pull requests and issues between versions
// Collect all pull requests and their associated issues in version range
var pullRequests = await connector.GetPullRequestsBetweenTagsAsync(fromTagInfo, toTagInfo);

var allIssues = new HashSet<string>();
var bugIssues = new List<IssueInfo>();
var changeIssues = new List<IssueInfo>();

// Process each pull request to extract and categorize issues
foreach (var pr in pullRequests)
{
// Get all issues referenced by this pull request
var issueIds = await connector.GetIssuesForPullRequestAsync(pr);

foreach (var issueId in issueIds)
{
// Skip issues already processed
if (allIssues.Contains(issueId))
{
continue;
}

// Mark issue as processed
allIssues.Add(issueId);

// Fetch issue details
var title = await connector.GetIssueTitleAsync(issueId);
var url = await connector.GetIssueUrlAsync(issueId);
var type = await connector.GetIssueTypeAsync(issueId);

// Create issue record
var issueInfo = new IssueInfo(issueId, title, url);

// Categorize issue by type
if (type == "bug")
{
bugIssues.Add(issueInfo);
Expand All @@ -190,18 +201,18 @@ public static async Task<BuildInformation> CreateAsync(IRepoConnector connector,
}
}

// Get known issues (open bugs that are not already fixed in this build)
// Collect known issues (open bugs not fixed in this build)
var knownIssues = new List<IssueInfo>();
var openIssueIds = await connector.GetOpenIssuesAsync();

foreach (var issueId in openIssueIds)
{
// Skip if already included in fixed bugs
// Skip issues already fixed in this build
if (allIssues.Contains(issueId))
{
continue;
}

// Only include bugs in known issues list
var type = await connector.GetIssueTypeAsync(issueId);
if (type == "bug")
{
Expand All @@ -211,6 +222,7 @@ public static async Task<BuildInformation> CreateAsync(IRepoConnector connector,
}
}

// Create and return build information with all collected data
return new BuildInformation(
fromTagInfo,
toTagInfo,
Expand All @@ -229,6 +241,7 @@ public static async Task<BuildInformation> CreateAsync(IRepoConnector connector,
/// <returns>Index of the tag, or -1 if not found.</returns>
private static int FindTagIndex(List<Version> tags, string normalizedVersion)
{
// Search for tag matching the normalized version
for (var i = 0; i < tags.Count; i++)
{
if (tags[i].FullVersion.Equals(normalizedVersion, StringComparison.OrdinalIgnoreCase))
Expand All @@ -237,6 +250,7 @@ private static int FindTagIndex(List<Version> tags, string normalizedVersion)
}
}

// Tag not found in history
return -1;
}
}
8 changes: 6 additions & 2 deletions src/DemaConsulting.BuildMark/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ public static string Version
{
get
{
// Get the assembly containing this program
var assembly = typeof(Program).Assembly;

// Try to get version from assembly attributes, fallback to AssemblyVersion, or default to 0.0.0
return assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? assembly.GetName().Version?.ToString()
?? "0.0.0";
Expand All @@ -48,14 +51,14 @@ public static string Version
/// <returns>Exit code: 0 for success, non-zero for failure.</returns>
private static int Main(string[] args)
{
// Print version if --version is specified
// Handle version display request
if (args.Length > 0 && args[0] == "--version")
{
Console.WriteLine($"BuildMark version {Version}");
return 0;
}

// Print help if --help is specified or no arguments
// Handle help display request or missing arguments
if (args.Length == 0 || args[0] == "--help")
{
Console.WriteLine("BuildMark - Tool to generate Markdown Build Notes");
Expand All @@ -68,6 +71,7 @@ private static int Main(string[] args)
return 0;
}

// Display placeholder message for unhandled arguments
Console.WriteLine("Hello from BuildMark!");
return 0;
}
Expand Down
47 changes: 44 additions & 3 deletions src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public partial class GitHubRepoConnector : RepoConnectorBase
/// <exception cref="ArgumentException">Thrown if tag name is invalid.</exception>
private static string ValidateTag(string tag)
{
// Ensure tag name matches allowed pattern to prevent injection attacks
if (!TagNameRegex().IsMatch(tag))
{
throw new ArgumentException($"Invalid tag name: {tag}", nameof(tag));
Expand All @@ -63,6 +64,7 @@ private static string ValidateTag(string tag)
/// <exception cref="ArgumentException">Thrown if ID is invalid.</exception>
private static string ValidateId(string id, string paramName)
{
// Ensure ID is numeric to prevent injection attacks
if (!NumericIdRegex().IsMatch(id))
{
throw new ArgumentException($"Invalid ID: {id}", paramName);
Expand All @@ -77,12 +79,18 @@ private static string ValidateId(string id, string paramName)
/// <returns>List of tags in chronological order.</returns>
public override async Task<List<Version>> GetTagHistoryAsync()
{
// Get all tags merged into current branch, sorted by creation date
// Arguments: --sort=creatordate (chronological order), --merged HEAD (reachable from HEAD)
// Output format: one tag name per line
var output = await RunCommandAsync("git", "tag --sort=creatordate --merged HEAD");

// Split output into individual tag names
var tagNames = output
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.Trim())
.ToList();
// Filter out non-version tags

// Parse and filter to valid version tags only
return tagNames
.Select(Version.TryCreate)
.Where(t => t != null)
Expand All @@ -98,30 +106,40 @@ public override async Task<List<Version>> GetTagHistoryAsync()
/// <returns>List of pull request IDs.</returns>
public override async Task<List<string>> GetPullRequestsBetweenTagsAsync(Version? from, Version? to)
{
// Build git log range based on provided versions
string range;
if (from == null && to == null)
{
// No versions specified, use all of HEAD
range = "HEAD";
}
else if (from == null && to != null)
{
// Only end version specified
range = ValidateTag(to.Tag);
}
else if (to == null && from != null)
{
// Only start version specified, range to HEAD
range = $"{ValidateTag(from.Tag)}..HEAD";
}
else
{
// Both from and to are not null (verified by the preceding conditions)
// Both versions specified
if (from == null || to == null)
{
throw new InvalidOperationException("Unexpected null version");
}
range = $"{ValidateTag(from.Tag)}..{ValidateTag(to.Tag)}";
}

// Get merge commits in range using git log
// Arguments: --oneline (one line per commit), --merges (only merge commits)
// Output format: "<short-hash> Merge pull request #<number> from <branch>"
var output = await RunCommandAsync("git", $"log --oneline --merges {range}");

// Extract pull request numbers from merge commit messages
// Each line is parsed for "#<number>" pattern to identify the PR
var pullRequests = new List<string>();
var regex = NumberReferenceRegex();

Expand All @@ -144,8 +162,13 @@ public override async Task<List<string>> GetPullRequestsBetweenTagsAsync(Version
/// <returns>List of issue IDs.</returns>
public override async Task<List<string>> GetIssuesForPullRequestAsync(string pullRequestId)
{
// Validate and fetch PR body using GitHub CLI
// Arguments: --json body (get body field), --jq .body (extract body value)
// Output: raw PR description text which may contain issue references
var validatedId = ValidateId(pullRequestId, nameof(pullRequestId));
var output = await RunCommandAsync("gh", $"pr view {validatedId} --json body --jq .body");

// Extract issue references (e.g., #123, #456) from PR body text
var issues = new List<string>();
var regex = NumberReferenceRegex();

Expand All @@ -164,6 +187,9 @@ public override async Task<List<string>> GetIssuesForPullRequestAsync(string pul
/// <returns>Issue title.</returns>
public override async Task<string> GetIssueTitleAsync(string issueId)
{
// Validate and fetch issue title using GitHub CLI
// Arguments: --json title (get title field), --jq .title (extract title value)
// Output: issue title as plain text
var validatedId = ValidateId(issueId, nameof(issueId));
return await RunCommandAsync("gh", $"issue view {validatedId} --json title --jq .title");
}
Expand All @@ -175,11 +201,14 @@ public override async Task<string> GetIssueTitleAsync(string issueId)
/// <returns>Issue type.</returns>
public override async Task<string> GetIssueTypeAsync(string issueId)
{
// Validate and fetch issue labels using GitHub CLI
// Arguments: --json labels (get labels array), --jq '.labels[].name' (extract label names)
// Output: one label name per line
var validatedId = ValidateId(issueId, nameof(issueId));
var output = await RunCommandAsync("gh", $"issue view {validatedId} --json labels --jq '.labels[].name'");
var labels = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);

// Look for common type labels
// Map labels to standardized issue types
foreach (var label in labels)
{
var lowerLabel = label.ToLowerInvariant();
Expand All @@ -192,6 +221,7 @@ public override async Task<string> GetIssueTypeAsync(string issueId)
}
}

// Default type when no recognized label found
return "other";
}

Expand All @@ -202,6 +232,9 @@ public override async Task<string> GetIssueTypeAsync(string issueId)
/// <returns>Git hash.</returns>
public override async Task<string> GetHashForTagAsync(string? tag)
{
// Get commit hash for tag or HEAD using git rev-parse
// Arguments: tag name or "HEAD" for current commit
// Output: full 40-character commit SHA
var refName = tag == null ? "HEAD" : ValidateTag(tag);
return await RunCommandAsync("git", $"rev-parse {refName}");
}
Expand All @@ -213,6 +246,9 @@ public override async Task<string> GetHashForTagAsync(string? tag)
/// <returns>Issue URL.</returns>
public override async Task<string> GetIssueUrlAsync(string issueId)
{
// Validate and fetch issue URL using GitHub CLI
// Arguments: --json url (get url field), --jq .url (extract url value)
// Output: full HTTPS URL to the issue
var validatedId = ValidateId(issueId, nameof(issueId));
return await RunCommandAsync("gh", $"issue view {validatedId} --json url --jq .url");
}
Expand All @@ -223,7 +259,12 @@ public override async Task<string> GetIssueUrlAsync(string issueId)
/// <returns>List of open issue IDs.</returns>
public override async Task<List<string>> GetOpenIssuesAsync()
{
// Fetch all open issue numbers using GitHub CLI
// Arguments: --state open (open issues only), --json number (get number field), --jq '.[].number' (extract numbers from array)
// Output: one issue number per line
var output = await RunCommandAsync("gh", "issue list --state open --json number --jq '.[].number'");

// Parse output into list of issue IDs
return output
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(n => n.Trim())
Expand Down
Loading