Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0146f54
Initial plan
Copilot Feb 3, 2026
ee810b7
Add build notes generation infrastructure
Copilot Feb 3, 2026
68ccdd0
Address PR feedback: move download step, add mermaid filter, fix intro
Copilot Feb 3, 2026
c0669de
Move Install BuildMark Tool step after Download step
Copilot Feb 3, 2026
30dda21
Fix bug: use HEAD when build version tag doesn't exist
Copilot Feb 3, 2026
7213897
Improve exception handling: catch specific InvalidOperationException
Copilot Feb 3, 2026
2f2c0f5
Add GH_TOKEN env var for BuildMark to access GitHub CLI
Copilot Feb 3, 2026
48556ba
Fix jq expression quotes for Windows compatibility
Copilot Feb 3, 2026
c3eb81c
Support squash merges by parsing all commits for PR references
Copilot Feb 3, 2026
5e54119
Fix spelling error in test mock commit hash
Copilot Feb 3, 2026
26a23dd
Use GitHub API to find PRs by commit hash when no PR numbers in messages
Copilot Feb 3, 2026
ee810f4
Remove commit message parsing for security - use GitHub API exclusively
Copilot Feb 3, 2026
3d87dac
Use GitHub API to get PR linked issues instead of parsing body
Copilot Feb 3, 2026
a07aa52
Add --state all to gh pr list to include closed PRs
Copilot Feb 4, 2026
3959b60
Fix gh pr list parameter order: move --search after --jq
Copilot Feb 4, 2026
1820ba1
Add fetch-depth: 0 to checkout for full git history in CI
Copilot Feb 4, 2026
37fa333
Add temporary debug output to diagnose CI PR detection issue
Copilot Feb 4, 2026
d8c5223
Use GitHub API instead of git log and pipe commits for batch PR search
Copilot Feb 4, 2026
625b2b3
Implement stdin piping: run gh api first, pipe output to gh pr list
Copilot Feb 4, 2026
c9de260
Remove debug output and fix code formatting for merge readiness
Copilot Feb 4, 2026
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
52 changes: 51 additions & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,21 @@ jobs:
- name: Restore Tools
run: dotnet tool restore

- name: Download BuildMark Package
uses: actions/download-artifact@v7
with:
name: artifacts-windows-latest
path: packages

- name: Install BuildMark Tool
shell: bash
run: |
echo "Installing BuildMark version ${{ inputs.version }}"
dotnet tool install --global \
--add-source packages \
--version ${{ inputs.version }} \
DemaConsulting.BuildMark

- name: Setup Node.js
uses: actions/setup-node@v6
with:
Expand Down Expand Up @@ -308,13 +323,46 @@ jobs:
echo "=== SonarCloud Quality Report ==="
cat docs/quality/sonar-quality.md

- name: Generate Build Notes with BuildMark
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: >
buildmark
--build-version ${{ inputs.version }}
--report docs/buildnotes.md
--report-depth 1

- name: Display Build Notes Report
shell: bash
run: |
echo "=== Build Notes Report ==="
cat docs/buildnotes.md

- name: Generate Build Notes HTML with Pandoc
shell: bash
run: >
dotnet pandoc
--defaults docs/buildnotes/definition.yaml
--metadata version="${{ inputs.version }}"
--metadata date="$(date +'%Y-%m-%d')"
--filter node_modules/.bin/mermaid-filter.cmd
--output docs/buildnotes/buildnotes.html

- name: Convert Build Notes HTML to PDF with Weasyprint
run: >
dotnet weasyprint
docs/buildnotes/buildnotes.html
"docs/BuildMark Build Notes.pdf"

- name: Generate Code Quality HTML with Pandoc
shell: bash
run: >
dotnet pandoc
--defaults docs/quality/definition.yaml
--metadata version="${{ inputs.version }}"
--metadata date="$(date +'%Y-%m-%d')"
--filter node_modules/.bin/mermaid-filter.cmd
--output docs/quality/quality.html

- name: Convert Code Quality HTML to PDF with Weasyprint
Expand All @@ -327,4 +375,6 @@ jobs:
uses: actions/upload-artifact@v6
with:
name: documents
path: docs/*.pdf
path: |
docs/*.pdf
docs/buildnotes.md
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ docs/tracematrix/*.html
docs/quality/sonar-quality.md
docs/quality/codeql-quality.md
docs/quality/*.html
docs/buildnotes.md
docs/buildnotes/*.html
15 changes: 15 additions & 0 deletions docs/buildnotes/definition.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
resource-path:
- docs/buildnotes
- docs/template

input-files:
- docs/buildnotes/title.txt
- docs/buildnotes/introduction.md
- docs/buildnotes.md

template: template.html

table-of-contents: true

number-sections: true
33 changes: 33 additions & 0 deletions docs/buildnotes/introduction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Introduction

This document contains the build notes for the BuildMark project.

## Purpose

This report serves as a comprehensive record of changes and bug fixes for this
release of BuildMark. It provides transparency about what has changed since the
previous version and helps users understand the improvements and fixes included
in this build.

## Scope

This build notes report covers:

- Version information and commit details
- Changes and new features implemented
- Bugs fixed in this release

## Generation Source

This report is automatically generated by the BuildMark tool itself, analyzing the
Git repository history and issue tracking information. It serves as evidence that
the BuildMark tool can successfully generate build notes for real-world projects.

## Audience

This document is intended for:

- Software developers working on BuildMark
- Users evaluating what has changed in this release
- Project stakeholders tracking progress
- Contributors understanding recent changes
14 changes: 14 additions & 0 deletions docs/buildnotes/title.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
title: BuildMark Tool
subtitle: Build Notes
author: DEMA Consulting
description: Build notes for the BuildMark Tool for generating markdown build notes
lang: en-US
keywords:
- BuildMark
- Build Notes
- Release Notes
- C#
- .NET
- Documentation
---
123 changes: 77 additions & 46 deletions src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,27 @@
.ToList();
}

/// <summary>
/// Checks if a git tag exists in the repository.
/// </summary>
/// <param name="tag">Tag name to check.</param>
/// <returns>True if the tag exists, false otherwise.</returns>
private async Task<bool> TagExistsAsync(string tag)
{
try
{
// Try to resolve the tag to a commit hash
// If tag doesn't exist, RunCommandAsync will throw InvalidOperationException
await RunCommandAsync("git", $"rev-parse --verify {ValidateTag(tag)}");
return true;
}
catch (InvalidOperationException)
{
// Tag doesn't exist
return false;
}
}

/// <summary>
/// Gets the list of pull request IDs between two versions.
/// </summary>
Expand All @@ -106,46 +127,64 @@
/// <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;
// Get commits using GitHub API instead of git log
// This approach doesn't require fetch-depth: 0 in CI and works with shallow clones
string commitHashesOutput;

if (from == null && to == null)
{
// No versions specified, use all of HEAD
range = "HEAD";
// No versions specified, get all commits using paginated API
commitHashesOutput = await RunCommandAsync("gh", "api repos/:owner/:repo/commits --paginate --jq .[].sha");
}
else if (from == null)
{
// Only end version specified (to is not null here)
range = ValidateTag(to!.Tag);
// Only end version specified - get commits up to 'to' tag/HEAD
// Check if the tag exists; if not, use HEAD
var toExists = to != null && await TagExistsAsync(to.Tag);

Check warning on line 143 in src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs

View workflow job for this annotation

GitHub Actions / Build / Build windows-latest

Change this condition so that it does not always evaluate to 'True'. (https://rules.sonarsource.com/csharp/RSPEC-2589)

Check warning on line 143 in src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs

View workflow job for this annotation

GitHub Actions / Build / Build windows-latest

Change this condition so that it does not always evaluate to 'True'. (https://rules.sonarsource.com/csharp/RSPEC-2589)

Check warning on line 143 in src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs

View workflow job for this annotation

GitHub Actions / Build / Build windows-latest

Change this condition so that it does not always evaluate to 'True'. (https://rules.sonarsource.com/csharp/RSPEC-2589)

Check warning on line 143 in src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs

View workflow job for this annotation

GitHub Actions / Build / Build ubuntu-latest

Change this condition so that it does not always evaluate to 'True'. (https://rules.sonarsource.com/csharp/RSPEC-2589)

Check warning on line 143 in src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs

View workflow job for this annotation

GitHub Actions / Build / Build ubuntu-latest

Change this condition so that it does not always evaluate to 'True'. (https://rules.sonarsource.com/csharp/RSPEC-2589)

Check warning on line 143 in src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs

View workflow job for this annotation

GitHub Actions / Build / Build ubuntu-latest

Change this condition so that it does not always evaluate to 'True'. (https://rules.sonarsource.com/csharp/RSPEC-2589)
var toRef = toExists ? ValidateTag(to!.Tag) : "HEAD";

// Get all commits up to toRef
commitHashesOutput = await RunCommandAsync("gh", $"api repos/:owner/:repo/commits?sha={toRef} --paginate --jq .[].sha");
}
else if (to == null)
{
// Only start version specified, range to HEAD (from is not null here)
range = $"{ValidateTag(from.Tag)}..HEAD";
// Only start version specified - compare from tag to HEAD
var fromTag = ValidateTag(from.Tag);
commitHashesOutput = await RunCommandAsync("gh", $"api repos/:owner/:repo/compare/{fromTag}...HEAD --jq .commits[].sha");
}
else
{
// Both versions specified (both from and to are not null here)
range = $"{ValidateTag(from.Tag)}..{ValidateTag(to.Tag)}";
}
// Both versions specified - compare from tag to to tag/HEAD
var fromTag = ValidateTag(from.Tag);
var toExists = await TagExistsAsync(to.Tag);
var toRef = toExists ? ValidateTag(to.Tag) : "HEAD";

// 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}");
commitHashesOutput = await RunCommandAsync("gh", $"api repos/:owner/:repo/compare/{fromTag}...{toRef} --jq .commits[].sha");
}

// Extract pull request numbers from merge commit messages
// Each line is parsed for "#<number>" pattern to identify the PR
var regex = NumberReferenceRegex();
// Pipe commit hashes to gh pr list to batch search for PRs
// This is much faster than querying each commit individually
// The commit hashes from the first command are piped as stdin to the second command
string prSearchOutput;
try
{
// Search for PRs by piping commit hashes to gh pr list
prSearchOutput = await RunCommandAsync("gh", "pr list --state all --json number --jq .[].number", commitHashesOutput);
}
catch (InvalidOperationException)
{
// Fallback to empty result if batch query fails
prSearchOutput = string.Empty;
}

var pullRequests = output
var pullRequestsFromApi = prSearchOutput
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line => regex.Match(line))
.Where(match => match.Success)
.Select(match => match.Groups[1].Value)
.Select(n => n.Trim())
.Where(n => !string.IsNullOrEmpty(n))
.Distinct()
.ToList();

return pullRequests;
return pullRequestsFromApi;
}

/// <summary>
Expand All @@ -155,20 +194,19 @@
/// <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
// Use GitHub API to get issues that are actually linked to close when PR merges
// This is more reliable than parsing PR body text which could contain any #numbers
// Arguments: --json closingIssuesReferences (get linked issues), --jq to extract numbers
// Output: issue numbers (one per line)
var validatedId = ValidateId(pullRequestId, nameof(pullRequestId));
var output = await RunCommandAsync("gh", $"pr view {validatedId} --json body --jq .body");
var output = await RunCommandAsync("gh", $"pr view {validatedId} --json closingIssuesReferences --jq .closingIssuesReferences[].number");

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

foreach (Match match in regex.Matches(output))
{
issues.Add(match.Groups[1].Value);
}
// Parse output to get issue numbers
var issues = output
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(n => n.Trim())
.Where(n => !string.IsNullOrEmpty(n))
.ToList();

return issues;
}
Expand All @@ -195,10 +233,10 @@
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)
// 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 output = await RunCommandAsync("gh", $"issue view {validatedId} --json labels --jq .labels[].name");
var labels = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);

// Map labels to standardized issue types
Expand Down Expand Up @@ -248,9 +286,9 @@
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)
// 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'");
var output = await RunCommandAsync("gh", "issue list --state open --json number --jq .[].number");

// Parse output into list of issue IDs
return output
Expand All @@ -272,11 +310,4 @@
/// <returns>Compiled regular expression.</returns>
[GeneratedRegex(@"^\d+$", RegexOptions.Compiled)]
private static partial Regex NumericIdRegex();

/// <summary>
/// Regular expression to match number references (#123).
/// </summary>
/// <returns>Compiled regular expression.</returns>
[GeneratedRegex(@"#(\d+)", RegexOptions.Compiled)]
private static partial Regex NumberReferenceRegex();
}
12 changes: 11 additions & 1 deletion src/DemaConsulting.BuildMark/RepoConnectors/ProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ internal static class ProcessRunner
/// </summary>
/// <param name="command">Command to run.</param>
/// <param name="arguments">Command arguments.</param>
/// <param name="standardInput">Optional input to pipe to the command's stdin.</param>
/// <returns>Command output.</returns>
/// <exception cref="InvalidOperationException">Thrown when command fails.</exception>
public static async Task<string> RunAsync(string command, string arguments)
public static async Task<string> RunAsync(string command, string arguments, string? standardInput = null)
{
// Configure process to capture output
var startInfo = new ProcessStartInfo
Expand All @@ -44,6 +45,7 @@ public static async Task<string> RunAsync(string command, string arguments)
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = standardInput != null,
UseShellExecute = false,
CreateNoWindow = true
};
Expand Down Expand Up @@ -71,6 +73,14 @@ public static async Task<string> RunAsync(string command, string arguments)

// Start process and begin reading streams
process.Start();

// Write to stdin if provided
if (standardInput != null)
{
await process.StandardInput.WriteAsync(standardInput);
process.StandardInput.Close();
}

process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ public abstract class RepoConnectorBase : IRepoConnector
/// </summary>
/// <param name="command">Command to run.</param>
/// <param name="arguments">Command arguments.</param>
/// <param name="standardInput">Optional input to pipe to the command's stdin.</param>
/// <returns>Command output.</returns>
protected virtual Task<string> RunCommandAsync(string command, string arguments)
protected virtual Task<string> RunCommandAsync(string command, string arguments, string? standardInput = null)
{
return ProcessRunner.RunAsync(command, arguments);
return ProcessRunner.RunAsync(command, arguments, standardInput);
}

/// <summary>
Expand Down
6 changes: 3 additions & 3 deletions src/DemaConsulting.BuildMark/Version.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,14 @@ public partial record Version(string Tag, string FullVersion, string SemanticVer
var separator = match.Groups["separator"];
var preReleaseGroup = match.Groups["pre_release"];
var metadataGroup = match.Groups["metadata"];

// Determine if pre-release based on separator and content
var hasPreRelease = separator.Success && preReleaseGroup.Success && !string.IsNullOrEmpty(preReleaseGroup.Value);

// Get pre-release and metadata strings
var preRelease = hasPreRelease ? preReleaseGroup.Value : string.Empty;
var metadata = metadataGroup.Success ? metadataGroup.Value : string.Empty;

// Construct full version string from components
var fullVersion = version;
if (hasPreRelease)
Expand Down
Loading
Loading