Skip to content

Commit e672a6b

Browse files
Add build notes generation to build-docs workflow and fix BuildMark bugs (#20)
* Initial plan * Add build notes generation infrastructure Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Address PR feedback: move download step, add mermaid filter, fix intro Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Move Install BuildMark Tool step after Download step Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Fix bug: use HEAD when build version tag doesn't exist Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Improve exception handling: catch specific InvalidOperationException Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Add GH_TOKEN env var for BuildMark to access GitHub CLI Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Fix jq expression quotes for Windows compatibility Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Support squash merges by parsing all commits for PR references Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Fix spelling error in test mock commit hash Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Use GitHub API to find PRs by commit hash when no PR numbers in messages Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Remove commit message parsing for security - use GitHub API exclusively Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Use GitHub API to get PR linked issues instead of parsing body Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Add --state all to gh pr list to include closed PRs Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Fix gh pr list parameter order: move --search after --jq Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Add fetch-depth: 0 to checkout for full git history in CI Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Add temporary debug output to diagnose CI PR detection issue Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Use GitHub API instead of git log and pipe commits for batch PR search Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Implement stdin piping: run gh api first, pipe output to gh pr list Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Remove debug output and fix code formatting for merge readiness 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 4c686c4 commit e672a6b

File tree

12 files changed

+432
-81
lines changed

12 files changed

+432
-81
lines changed

.github/workflows/build.yaml

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,21 @@ jobs:
255255
- name: Restore Tools
256256
run: dotnet tool restore
257257

258+
- name: Download BuildMark Package
259+
uses: actions/download-artifact@v7
260+
with:
261+
name: artifacts-windows-latest
262+
path: packages
263+
264+
- name: Install BuildMark Tool
265+
shell: bash
266+
run: |
267+
echo "Installing BuildMark version ${{ inputs.version }}"
268+
dotnet tool install --global \
269+
--add-source packages \
270+
--version ${{ inputs.version }} \
271+
DemaConsulting.BuildMark
272+
258273
- name: Setup Node.js
259274
uses: actions/setup-node@v6
260275
with:
@@ -308,13 +323,46 @@ jobs:
308323
echo "=== SonarCloud Quality Report ==="
309324
cat docs/quality/sonar-quality.md
310325
326+
- name: Generate Build Notes with BuildMark
327+
env:
328+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
329+
shell: bash
330+
run: >
331+
buildmark
332+
--build-version ${{ inputs.version }}
333+
--report docs/buildnotes.md
334+
--report-depth 1
335+
336+
- name: Display Build Notes Report
337+
shell: bash
338+
run: |
339+
echo "=== Build Notes Report ==="
340+
cat docs/buildnotes.md
341+
342+
- name: Generate Build Notes HTML with Pandoc
343+
shell: bash
344+
run: >
345+
dotnet pandoc
346+
--defaults docs/buildnotes/definition.yaml
347+
--metadata version="${{ inputs.version }}"
348+
--metadata date="$(date +'%Y-%m-%d')"
349+
--filter node_modules/.bin/mermaid-filter.cmd
350+
--output docs/buildnotes/buildnotes.html
351+
352+
- name: Convert Build Notes HTML to PDF with Weasyprint
353+
run: >
354+
dotnet weasyprint
355+
docs/buildnotes/buildnotes.html
356+
"docs/BuildMark Build Notes.pdf"
357+
311358
- name: Generate Code Quality HTML with Pandoc
312359
shell: bash
313360
run: >
314361
dotnet pandoc
315362
--defaults docs/quality/definition.yaml
316363
--metadata version="${{ inputs.version }}"
317364
--metadata date="$(date +'%Y-%m-%d')"
365+
--filter node_modules/.bin/mermaid-filter.cmd
318366
--output docs/quality/quality.html
319367
320368
- name: Convert Code Quality HTML to PDF with Weasyprint
@@ -327,4 +375,6 @@ jobs:
327375
uses: actions/upload-artifact@v6
328376
with:
329377
name: documents
330-
path: docs/*.pdf
378+
path: |
379+
docs/*.pdf
380+
docs/buildnotes.md

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,5 @@ docs/tracematrix/*.html
3535
docs/quality/sonar-quality.md
3636
docs/quality/codeql-quality.md
3737
docs/quality/*.html
38+
docs/buildnotes.md
39+
docs/buildnotes/*.html

docs/buildnotes/definition.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
resource-path:
3+
- docs/buildnotes
4+
- docs/template
5+
6+
input-files:
7+
- docs/buildnotes/title.txt
8+
- docs/buildnotes/introduction.md
9+
- docs/buildnotes.md
10+
11+
template: template.html
12+
13+
table-of-contents: true
14+
15+
number-sections: true

docs/buildnotes/introduction.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Introduction
2+
3+
This document contains the build notes for the BuildMark project.
4+
5+
## Purpose
6+
7+
This report serves as a comprehensive record of changes and bug fixes for this
8+
release of BuildMark. It provides transparency about what has changed since the
9+
previous version and helps users understand the improvements and fixes included
10+
in this build.
11+
12+
## Scope
13+
14+
This build notes report covers:
15+
16+
- Version information and commit details
17+
- Changes and new features implemented
18+
- Bugs fixed in this release
19+
20+
## Generation Source
21+
22+
This report is automatically generated by the BuildMark tool itself, analyzing the
23+
Git repository history and issue tracking information. It serves as evidence that
24+
the BuildMark tool can successfully generate build notes for real-world projects.
25+
26+
## Audience
27+
28+
This document is intended for:
29+
30+
- Software developers working on BuildMark
31+
- Users evaluating what has changed in this release
32+
- Project stakeholders tracking progress
33+
- Contributors understanding recent changes

docs/buildnotes/title.txt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
title: BuildMark Tool
3+
subtitle: Build Notes
4+
author: DEMA Consulting
5+
description: Build notes for the BuildMark Tool for generating markdown build notes
6+
lang: en-US
7+
keywords:
8+
- BuildMark
9+
- Build Notes
10+
- Release Notes
11+
- C#
12+
- .NET
13+
- Documentation
14+
---

src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs

Lines changed: 77 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,27 @@ public override async Task<List<Version>> GetTagHistoryAsync()
9898
.ToList();
9999
}
100100

101+
/// <summary>
102+
/// Checks if a git tag exists in the repository.
103+
/// </summary>
104+
/// <param name="tag">Tag name to check.</param>
105+
/// <returns>True if the tag exists, false otherwise.</returns>
106+
private async Task<bool> TagExistsAsync(string tag)
107+
{
108+
try
109+
{
110+
// Try to resolve the tag to a commit hash
111+
// If tag doesn't exist, RunCommandAsync will throw InvalidOperationException
112+
await RunCommandAsync("git", $"rev-parse --verify {ValidateTag(tag)}");
113+
return true;
114+
}
115+
catch (InvalidOperationException)
116+
{
117+
// Tag doesn't exist
118+
return false;
119+
}
120+
}
121+
101122
/// <summary>
102123
/// Gets the list of pull request IDs between two versions.
103124
/// </summary>
@@ -106,46 +127,64 @@ public override async Task<List<Version>> GetTagHistoryAsync()
106127
/// <returns>List of pull request IDs.</returns>
107128
public override async Task<List<string>> GetPullRequestsBetweenTagsAsync(Version? from, Version? to)
108129
{
109-
// Build git log range based on provided versions
110-
string range;
130+
// Get commits using GitHub API instead of git log
131+
// This approach doesn't require fetch-depth: 0 in CI and works with shallow clones
132+
string commitHashesOutput;
133+
111134
if (from == null && to == null)
112135
{
113-
// No versions specified, use all of HEAD
114-
range = "HEAD";
136+
// No versions specified, get all commits using paginated API
137+
commitHashesOutput = await RunCommandAsync("gh", "api repos/:owner/:repo/commits --paginate --jq .[].sha");
115138
}
116139
else if (from == null)
117140
{
118-
// Only end version specified (to is not null here)
119-
range = ValidateTag(to!.Tag);
141+
// Only end version specified - get commits up to 'to' tag/HEAD
142+
// Check if the tag exists; if not, use HEAD
143+
var toExists = to != null && await TagExistsAsync(to.Tag);
144+
var toRef = toExists ? ValidateTag(to!.Tag) : "HEAD";
145+
146+
// Get all commits up to toRef
147+
commitHashesOutput = await RunCommandAsync("gh", $"api repos/:owner/:repo/commits?sha={toRef} --paginate --jq .[].sha");
120148
}
121149
else if (to == null)
122150
{
123-
// Only start version specified, range to HEAD (from is not null here)
124-
range = $"{ValidateTag(from.Tag)}..HEAD";
151+
// Only start version specified - compare from tag to HEAD
152+
var fromTag = ValidateTag(from.Tag);
153+
commitHashesOutput = await RunCommandAsync("gh", $"api repos/:owner/:repo/compare/{fromTag}...HEAD --jq .commits[].sha");
125154
}
126155
else
127156
{
128-
// Both versions specified (both from and to are not null here)
129-
range = $"{ValidateTag(from.Tag)}..{ValidateTag(to.Tag)}";
130-
}
157+
// Both versions specified - compare from tag to to tag/HEAD
158+
var fromTag = ValidateTag(from.Tag);
159+
var toExists = await TagExistsAsync(to.Tag);
160+
var toRef = toExists ? ValidateTag(to.Tag) : "HEAD";
131161

132-
// Get merge commits in range using git log
133-
// Arguments: --oneline (one line per commit), --merges (only merge commits)
134-
// Output format: "<short-hash> Merge pull request #<number> from <branch>"
135-
var output = await RunCommandAsync("git", $"log --oneline --merges {range}");
162+
commitHashesOutput = await RunCommandAsync("gh", $"api repos/:owner/:repo/compare/{fromTag}...{toRef} --jq .commits[].sha");
163+
}
136164

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

141-
var pullRequests = output
180+
var pullRequestsFromApi = prSearchOutput
142181
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
143-
.Select(line => regex.Match(line))
144-
.Where(match => match.Success)
145-
.Select(match => match.Groups[1].Value)
182+
.Select(n => n.Trim())
183+
.Where(n => !string.IsNullOrEmpty(n))
184+
.Distinct()
146185
.ToList();
147186

148-
return pullRequests;
187+
return pullRequestsFromApi;
149188
}
150189

151190
/// <summary>
@@ -155,20 +194,19 @@ public override async Task<List<string>> GetPullRequestsBetweenTagsAsync(Version
155194
/// <returns>List of issue IDs.</returns>
156195
public override async Task<List<string>> GetIssuesForPullRequestAsync(string pullRequestId)
157196
{
158-
// Validate and fetch PR body using GitHub CLI
159-
// Arguments: --json body (get body field), --jq .body (extract body value)
160-
// Output: raw PR description text which may contain issue references
197+
// Use GitHub API to get issues that are actually linked to close when PR merges
198+
// This is more reliable than parsing PR body text which could contain any #numbers
199+
// Arguments: --json closingIssuesReferences (get linked issues), --jq to extract numbers
200+
// Output: issue numbers (one per line)
161201
var validatedId = ValidateId(pullRequestId, nameof(pullRequestId));
162-
var output = await RunCommandAsync("gh", $"pr view {validatedId} --json body --jq .body");
202+
var output = await RunCommandAsync("gh", $"pr view {validatedId} --json closingIssuesReferences --jq .closingIssuesReferences[].number");
163203

164-
// Extract issue references (e.g., #123, #456) from PR body text
165-
var issues = new List<string>();
166-
var regex = NumberReferenceRegex();
167-
168-
foreach (Match match in regex.Matches(output))
169-
{
170-
issues.Add(match.Groups[1].Value);
171-
}
204+
// Parse output to get issue numbers
205+
var issues = output
206+
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
207+
.Select(n => n.Trim())
208+
.Where(n => !string.IsNullOrEmpty(n))
209+
.ToList();
172210

173211
return issues;
174212
}
@@ -195,10 +233,10 @@ public override async Task<string> GetIssueTitleAsync(string issueId)
195233
public override async Task<string> GetIssueTypeAsync(string issueId)
196234
{
197235
// Validate and fetch issue labels using GitHub CLI
198-
// Arguments: --json labels (get labels array), --jq '.labels[].name' (extract label names)
236+
// Arguments: --json labels (get labels array), --jq .labels[].name (extract label names)
199237
// Output: one label name per line
200238
var validatedId = ValidateId(issueId, nameof(issueId));
201-
var output = await RunCommandAsync("gh", $"issue view {validatedId} --json labels --jq '.labels[].name'");
239+
var output = await RunCommandAsync("gh", $"issue view {validatedId} --json labels --jq .labels[].name");
202240
var labels = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
203241

204242
// Map labels to standardized issue types
@@ -248,9 +286,9 @@ public override async Task<string> GetIssueUrlAsync(string issueId)
248286
public override async Task<List<string>> GetOpenIssuesAsync()
249287
{
250288
// Fetch all open issue numbers using GitHub CLI
251-
// Arguments: --state open (open issues only), --json number (get number field), --jq '.[].number' (extract numbers from array)
289+
// Arguments: --state open (open issues only), --json number (get number field), --jq .[].number (extract numbers from array)
252290
// Output: one issue number per line
253-
var output = await RunCommandAsync("gh", "issue list --state open --json number --jq '.[].number'");
291+
var output = await RunCommandAsync("gh", "issue list --state open --json number --jq .[].number");
254292

255293
// Parse output into list of issue IDs
256294
return output
@@ -272,11 +310,4 @@ public override async Task<List<string>> GetOpenIssuesAsync()
272310
/// <returns>Compiled regular expression.</returns>
273311
[GeneratedRegex(@"^\d+$", RegexOptions.Compiled)]
274312
private static partial Regex NumericIdRegex();
275-
276-
/// <summary>
277-
/// Regular expression to match number references (#123).
278-
/// </summary>
279-
/// <returns>Compiled regular expression.</returns>
280-
[GeneratedRegex(@"#(\d+)", RegexOptions.Compiled)]
281-
private static partial Regex NumberReferenceRegex();
282313
}

src/DemaConsulting.BuildMark/RepoConnectors/ProcessRunner.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ internal static class ProcessRunner
3333
/// </summary>
3434
/// <param name="command">Command to run.</param>
3535
/// <param name="arguments">Command arguments.</param>
36+
/// <param name="standardInput">Optional input to pipe to the command's stdin.</param>
3637
/// <returns>Command output.</returns>
3738
/// <exception cref="InvalidOperationException">Thrown when command fails.</exception>
38-
public static async Task<string> RunAsync(string command, string arguments)
39+
public static async Task<string> RunAsync(string command, string arguments, string? standardInput = null)
3940
{
4041
// Configure process to capture output
4142
var startInfo = new ProcessStartInfo
@@ -44,6 +45,7 @@ public static async Task<string> RunAsync(string command, string arguments)
4445
Arguments = arguments,
4546
RedirectStandardOutput = true,
4647
RedirectStandardError = true,
48+
RedirectStandardInput = standardInput != null,
4749
UseShellExecute = false,
4850
CreateNoWindow = true
4951
};
@@ -71,6 +73,14 @@ public static async Task<string> RunAsync(string command, string arguments)
7173

7274
// Start process and begin reading streams
7375
process.Start();
76+
77+
// Write to stdin if provided
78+
if (standardInput != null)
79+
{
80+
await process.StandardInput.WriteAsync(standardInput);
81+
process.StandardInput.Close();
82+
}
83+
7484
process.BeginOutputReadLine();
7585
process.BeginErrorReadLine();
7686
await process.WaitForExitAsync();

src/DemaConsulting.BuildMark/RepoConnectors/RepoConnectorBase.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@ public abstract class RepoConnectorBase : IRepoConnector
3030
/// </summary>
3131
/// <param name="command">Command to run.</param>
3232
/// <param name="arguments">Command arguments.</param>
33+
/// <param name="standardInput">Optional input to pipe to the command's stdin.</param>
3334
/// <returns>Command output.</returns>
34-
protected virtual Task<string> RunCommandAsync(string command, string arguments)
35+
protected virtual Task<string> RunCommandAsync(string command, string arguments, string? standardInput = null)
3536
{
36-
return ProcessRunner.RunAsync(command, arguments);
37+
return ProcessRunner.RunAsync(command, arguments, standardInput);
3738
}
3839

3940
/// <summary>

src/DemaConsulting.BuildMark/Version.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,14 @@ public partial record Version(string Tag, string FullVersion, string SemanticVer
6464
var separator = match.Groups["separator"];
6565
var preReleaseGroup = match.Groups["pre_release"];
6666
var metadataGroup = match.Groups["metadata"];
67-
67+
6868
// Determine if pre-release based on separator and content
6969
var hasPreRelease = separator.Success && preReleaseGroup.Success && !string.IsNullOrEmpty(preReleaseGroup.Value);
70-
70+
7171
// Get pre-release and metadata strings
7272
var preRelease = hasPreRelease ? preReleaseGroup.Value : string.Empty;
7373
var metadata = metadataGroup.Success ? metadataGroup.Value : string.Empty;
74-
74+
7575
// Construct full version string from components
7676
var fullVersion = version;
7777
if (hasPreRelease)

0 commit comments

Comments
 (0)