Skip to content

Commit 225ed31

Browse files
Feature: From-Version Selection Rules (#95)
* Initial plan * Implement from-version selection rules for pre-release and release versions Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Document from-version selection rules in user guide Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> * Fix spell-check violations in test file by renaming hash variables 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 43a3c3c commit 225ed31

File tree

3 files changed

+306
-11
lines changed

3 files changed

+306
-11
lines changed

docs/guide/guide.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,82 @@ buildmark --validate --results validation-results.trx
399399
buildmark --validate --results validation-results.xml
400400
```
401401

402+
# Version Selection Rules
403+
404+
BuildMark automatically determines which previous version to use as the baseline when generating build notes. This
405+
section explains how BuildMark selects the baseline version for different scenarios.
406+
407+
## Pre-Release Versions
408+
409+
For pre-release versions (e.g., `1.2.3-beta.1`, `1.2.3-rc.1`), BuildMark picks the **previous tag (release or
410+
pre-release) that has a different commit hash**.
411+
412+
This behavior handles cases where multiple pre-release tags point to the same commit (re-tagging scenarios), ensuring
413+
the generated changelog shows actual code changes rather than an empty diff.
414+
415+
### Example: Pre-Release with Re-Tagged Commits
416+
417+
Consider the following tags:
418+
419+
- `1.1.2-rc.1` (commit hash: `a1b2c3d4`)
420+
- `1.1.2-beta.2` (commit hash: `a1b2c3d4`)
421+
- `1.1.2-beta.1` (commit hash: `734713bc`)
422+
423+
When generating build notes for `1.1.2-rc.1`:
424+
425+
1. BuildMark identifies that `1.1.2-beta.2` has the same commit hash (`a1b2c3d4`)
426+
2. BuildMark skips `1.1.2-beta.2` since it would result in an empty changelog
427+
3. BuildMark selects `1.1.2-beta.1` as the baseline (different commit hash: `734713bc`)
428+
429+
The generated build notes will show changes between `1.1.2-beta.1` and `1.1.2-rc.1`.
430+
431+
## Release Versions
432+
433+
For release versions (e.g., `1.2.3`), BuildMark picks the **previous release tag**, skipping all pre-release versions.
434+
435+
This ensures release notes compare against the previous stable release, showing the complete set of changes since the
436+
last production release.
437+
438+
### Example: Release Skipping Pre-Releases
439+
440+
Consider the following tags:
441+
442+
- `1.1.2` (release)
443+
- `1.1.2-rc.1` (pre-release)
444+
- `1.1.2-beta.2` (pre-release)
445+
- `1.1.2-beta.1` (pre-release)
446+
- `1.1.1` (release)
447+
448+
When generating build notes for `1.1.2`:
449+
450+
1. BuildMark identifies `1.1.2` as a release version (no pre-release suffix)
451+
2. BuildMark skips all pre-release tags (`1.1.2-rc.1`, `1.1.2-beta.2`, `1.1.2-beta.1`)
452+
3. BuildMark selects `1.1.1` as the baseline (the previous release)
453+
454+
The generated build notes will show all changes between `1.1.1` and `1.1.2`, including changes from all the
455+
pre-release versions.
456+
457+
## No Previous Version
458+
459+
If no previous version is found (e.g., generating build notes for the first release), BuildMark will build the
460+
history from the beginning of the repository, showing all commits up to the specified version.
461+
462+
## Version Tag Format
463+
464+
BuildMark recognizes version tags with various formats:
465+
466+
- Simple format: `1.2.3`
467+
- V-prefix: `v1.2.3`
468+
- Custom prefixes: `ver-1.2.3`, `release_1.2.3`
469+
- Pre-release suffixes: `-alpha.1`, `-beta.2`, `-rc.1`, `.pre.1`
470+
- Build metadata: `+build.123`, `+linux.x64`
471+
472+
Examples of recognized version tags:
473+
474+
- `1.0.0`, `v1.0.0`, `ver-1.0.0`
475+
- `2.0.0-beta.1`, `v2.0.0-rc.2`
476+
- `1.2.3+build.456`, `v2.0.0-rc.1+linux`
477+
402478
# Best Practices
403479

404480
## Version Tagging

src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ public override async Task<BuildInformation> GetBuildInformationAsync(Version? v
8787
var (toVersion, toHash) = DetermineTargetVersion(version, currentCommitHash.Trim(), lookupData);
8888

8989
// Determine the starting release for comparing changes
90-
var (fromVersion, fromHash) = DetermineBaselineVersion(toVersion, lookupData);
90+
var (fromVersion, fromHash) = DetermineBaselineVersion(toVersion, toHash, lookupData);
9191

9292
// Get commits in range
9393
var commitsInRange = GetCommitsInRange(gitHubData.Commits, fromHash, toHash);
@@ -348,10 +348,12 @@ private static (Version toVersion, string toHash) DetermineTargetVersion(
348348
/// Determines the baseline version for comparing changes.
349349
/// </summary>
350350
/// <param name="toVersion">Target version.</param>
351+
/// <param name="toHash">Commit hash of target version.</param>
351352
/// <param name="lookupData">Lookup data structures.</param>
352353
/// <returns>Tuple of (fromVersion, fromHash).</returns>
353354
private static (Version? fromVersion, string? fromHash) DetermineBaselineVersion(
354355
Version toVersion,
356+
string toHash,
355357
LookupData lookupData)
356358
{
357359
// Return null baseline if no releases exist
@@ -365,7 +367,7 @@ private static (Version? fromVersion, string? fromHash) DetermineBaselineVersion
365367

366368
// Determine baseline version based on whether target is pre-release
367369
var fromVersion = toVersion.IsPreRelease
368-
? DetermineBaselineForPreRelease(toIndex, lookupData.ReleaseVersions)
370+
? DetermineBaselineForPreRelease(toIndex, toHash, lookupData)
369371
: DetermineBaselineForRelease(toIndex, lookupData.ReleaseVersions);
370372

371373
// Get commit hash for baseline version if one was found
@@ -382,26 +384,56 @@ private static (Version? fromVersion, string? fromHash) DetermineBaselineVersion
382384

383385
/// <summary>
384386
/// Determines the baseline version for a pre-release.
387+
/// Pre-release versions pick the previous tag (release or pre-release) that isn't the same commit-hash.
385388
/// </summary>
386389
/// <param name="toIndex">Index of target version in release history.</param>
387-
/// <param name="releaseVersions">List of release versions.</param>
390+
/// <param name="toHash">Commit hash of target version.</param>
391+
/// <param name="lookupData">Lookup data structures.</param>
388392
/// <returns>Baseline version or null.</returns>
389-
private static Version? DetermineBaselineForPreRelease(int toIndex, List<Version> releaseVersions)
393+
private static Version? DetermineBaselineForPreRelease(int toIndex, string toHash, LookupData lookupData)
390394
{
391-
// Pre-release versions use the immediately previous (older) release as baseline
395+
var releaseVersions = lookupData.ReleaseVersions;
396+
397+
// Determine starting index for search
398+
int startIndex;
392399
if (toIndex >= 0 && toIndex < releaseVersions.Count - 1)
393400
{
394-
// Target version exists in history, use next older release (higher index)
395-
return releaseVersions[toIndex + 1];
401+
// Target exists, start from next older release
402+
startIndex = toIndex + 1;
403+
}
404+
else if (toIndex == -1 && releaseVersions.Count > 0)
405+
{
406+
// Target not in history, start from most recent
407+
startIndex = 0;
408+
}
409+
else
410+
{
411+
// No valid starting point
412+
startIndex = -1;
396413
}
397414

398-
// Target version not in history, use most recent release as baseline
399-
if (toIndex == -1 && releaseVersions.Count > 0)
415+
// If no valid starting point, return null
416+
if (startIndex < 0)
400417
{
401-
return releaseVersions[0];
418+
return null;
419+
}
420+
421+
// Search forward through older releases (incrementing index) for previous version with different commit hash
422+
for (var i = startIndex; i < releaseVersions.Count; i++)
423+
{
424+
var candidateVersion = releaseVersions[i];
425+
426+
// Get commit hash for candidate version
427+
if (lookupData.TagToRelease.TryGetValue(candidateVersion.Tag, out var candidateRelease) &&
428+
lookupData.TagsByName.TryGetValue(candidateRelease.TagName!, out var candidateTag) &&
429+
candidateTag.Commit.Sha != toHash)
430+
{
431+
// Found a version with a different commit hash - use it
432+
return candidateVersion;
433+
}
402434
}
403435

404-
// If toIndex is last in list, this is the oldest release, no baseline
436+
// No version with different commit hash found
405437
return null;
406438
}
407439

test/DemaConsulting.BuildMark.Tests/GitHubRepoConnectorTests.cs

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,4 +265,191 @@ public async Task GitHubRepoConnector_GetBuildInformationAsync_WithOpenIssues_Id
265265
var hasExpectedIssue = knownIssueTitles.Exists(t => t.Contains("Known bug") || t.Contains("Feature request"));
266266
Assert.IsTrue(hasExpectedIssue, "Should have at least one of the open issues as a known issue");
267267
}
268+
269+
/// <summary>
270+
/// Test that pre-release baseline selection skips tags with the same commit hash.
271+
/// Example: 1.1.2-rc.1 (hash a1b2c3d4) and 1.1.2-beta.2 (hash a1b2c3d4) are re-tags.
272+
/// When processing 1.1.2-rc.1, it should skip 1.1.2-beta.2 and use 1.1.2-beta.1 (hash 734713bc).
273+
/// </summary>
274+
[TestMethod]
275+
public async Task GitHubRepoConnector_GetBuildInformationAsync_PreReleaseWithSameCommitHash_SkipsToNextDifferentHash()
276+
{
277+
// Arrange - Create mock responses with multiple pre-releases on same and different hashes
278+
using var mockHandler = new MockGitHubGraphQLHttpMessageHandler()
279+
.AddCommitsResponse("a1b2c3d4", "734713bc", "commit1")
280+
.AddReleasesResponse(
281+
new MockRelease("1.1.2-rc.1", "2024-03-03T00:00:00Z"), // Same hash as beta.2
282+
new MockRelease("1.1.2-beta.2", "2024-03-02T00:00:00Z"), // Same hash as rc.1
283+
new MockRelease("1.1.2-beta.1", "2024-03-01T00:00:00Z"), // Different hash
284+
new MockRelease("v1.1.1", "2024-02-01T00:00:00Z"))
285+
.AddPullRequestsResponse()
286+
.AddIssuesResponse()
287+
.AddTagsResponse(
288+
new MockTag("1.1.2-rc.1", "a1b2c3d4"), // rc.1 and beta.2 on same hash
289+
new MockTag("1.1.2-beta.2", "a1b2c3d4"), // Same hash as rc.1
290+
new MockTag("1.1.2-beta.1", "734713bc"), // Different hash
291+
new MockTag("v1.1.1", "commit1"));
292+
293+
using var mockHttpClient = new HttpClient(mockHandler);
294+
var connector = new MockableGitHubRepoConnector(mockHttpClient);
295+
296+
// Set up mock command responses
297+
connector.SetCommandResponse("git remote get-url origin", "https://github.com/test/repo.git");
298+
connector.SetCommandResponse("git rev-parse --abbrev-ref HEAD", "main");
299+
connector.SetCommandResponse("git rev-parse HEAD", "a1b2c3d4");
300+
connector.SetCommandResponse("gh auth token", "test-token");
301+
302+
// Act - Process 1.1.2-rc.1
303+
var buildInfo = await connector.GetBuildInformationAsync(Version.Create("1.1.2-rc.1"));
304+
305+
// Assert
306+
Assert.IsNotNull(buildInfo);
307+
Assert.AreEqual("1.1.2-rc.1", buildInfo.CurrentVersionTag.VersionInfo.FullVersion);
308+
Assert.AreEqual("a1b2c3d4", buildInfo.CurrentVersionTag.CommitHash);
309+
310+
// Should have skipped 1.1.2-beta.2 (same hash) and selected 1.1.2-beta.1 (different hash)
311+
Assert.IsNotNull(buildInfo.BaselineVersionTag);
312+
Assert.AreEqual("1.1.2-beta.1", buildInfo.BaselineVersionTag.VersionInfo.FullVersion);
313+
Assert.AreEqual("734713bc", buildInfo.BaselineVersionTag.CommitHash);
314+
315+
// Should have changelog link between beta.1 and rc.1
316+
Assert.IsNotNull(buildInfo.CompleteChangelogLink);
317+
Assert.Contains("1.1.2-beta.1...1.1.2-rc.1", buildInfo.CompleteChangelogLink.TargetUrl);
318+
}
319+
320+
/// <summary>
321+
/// Test that release baseline selection skips all pre-release versions.
322+
/// Example: 1.1.2 should skip 1.1.2-rc.1, 1.1.2-beta.2, 1.1.2-beta.1 and use 1.1.1.
323+
/// </summary>
324+
[TestMethod]
325+
public async Task GitHubRepoConnector_GetBuildInformationAsync_ReleaseVersion_SkipsAllPreReleases()
326+
{
327+
// Arrange - Create mock responses with release and multiple pre-releases
328+
using var mockHandler = new MockGitHubGraphQLHttpMessageHandler()
329+
.AddCommitsResponse("commit5", "commit4", "commit3", "commit2", "commit1")
330+
.AddReleasesResponse(
331+
new MockRelease("1.1.2", "2024-03-05T00:00:00Z"),
332+
new MockRelease("1.1.2-rc.1", "2024-03-04T00:00:00Z"),
333+
new MockRelease("1.1.2-beta.2", "2024-03-03T00:00:00Z"),
334+
new MockRelease("1.1.2-beta.1", "2024-03-02T00:00:00Z"),
335+
new MockRelease("v1.1.1", "2024-02-01T00:00:00Z"))
336+
.AddPullRequestsResponse()
337+
.AddIssuesResponse()
338+
.AddTagsResponse(
339+
new MockTag("1.1.2", "commit5"),
340+
new MockTag("1.1.2-rc.1", "commit4"),
341+
new MockTag("1.1.2-beta.2", "commit3"),
342+
new MockTag("1.1.2-beta.1", "commit2"),
343+
new MockTag("v1.1.1", "commit1"));
344+
345+
using var mockHttpClient = new HttpClient(mockHandler);
346+
var connector = new MockableGitHubRepoConnector(mockHttpClient);
347+
348+
// Set up mock command responses
349+
connector.SetCommandResponse("git remote get-url origin", "https://github.com/test/repo.git");
350+
connector.SetCommandResponse("git rev-parse --abbrev-ref HEAD", "main");
351+
connector.SetCommandResponse("git rev-parse HEAD", "commit5");
352+
connector.SetCommandResponse("gh auth token", "test-token");
353+
354+
// Act - Process 1.1.2
355+
var buildInfo = await connector.GetBuildInformationAsync(Version.Create("1.1.2"));
356+
357+
// Assert
358+
Assert.IsNotNull(buildInfo);
359+
Assert.AreEqual("1.1.2", buildInfo.CurrentVersionTag.VersionInfo.FullVersion);
360+
Assert.AreEqual("commit5", buildInfo.CurrentVersionTag.CommitHash);
361+
362+
// Should have skipped all pre-releases and selected 1.1.1
363+
Assert.IsNotNull(buildInfo.BaselineVersionTag);
364+
Assert.AreEqual("1.1.1", buildInfo.BaselineVersionTag.VersionInfo.FullVersion);
365+
Assert.AreEqual("commit1", buildInfo.BaselineVersionTag.CommitHash);
366+
367+
// Should have changelog link between 1.1.1 and 1.1.2
368+
Assert.IsNotNull(buildInfo.CompleteChangelogLink);
369+
Assert.Contains("v1.1.1...1.1.2", buildInfo.CompleteChangelogLink.TargetUrl);
370+
}
371+
372+
/// <summary>
373+
/// Test that pre-release baseline selection works correctly when target is not in release history.
374+
/// This happens when generating build notes for a version that hasn't been tagged yet.
375+
/// </summary>
376+
[TestMethod]
377+
public async Task GitHubRepoConnector_GetBuildInformationAsync_PreReleaseNotInHistory_UsesLatestDifferentHash()
378+
{
379+
// Arrange - Create mock responses where target version doesn't exist yet
380+
using var mockHandler = new MockGitHubGraphQLHttpMessageHandler()
381+
.AddCommitsResponse("new-hash-123", "commit2", "commit1")
382+
.AddReleasesResponse(
383+
new MockRelease("1.1.2-beta.1", "2024-03-01T00:00:00Z"),
384+
new MockRelease("v1.1.1", "2024-02-01T00:00:00Z"))
385+
.AddPullRequestsResponse()
386+
.AddIssuesResponse()
387+
.AddTagsResponse(
388+
new MockTag("1.1.2-beta.1", "commit2"),
389+
new MockTag("v1.1.1", "commit1"));
390+
391+
using var mockHttpClient = new HttpClient(mockHandler);
392+
var connector = new MockableGitHubRepoConnector(mockHttpClient);
393+
394+
// Set up mock command responses
395+
connector.SetCommandResponse("git remote get-url origin", "https://github.com/test/repo.git");
396+
connector.SetCommandResponse("git rev-parse --abbrev-ref HEAD", "main");
397+
connector.SetCommandResponse("git rev-parse HEAD", "new-hash-123");
398+
connector.SetCommandResponse("gh auth token", "test-token");
399+
400+
// Act - Process 1.1.2-beta.2 which doesn't exist in releases yet
401+
var buildInfo = await connector.GetBuildInformationAsync(Version.Create("1.1.2-beta.2"));
402+
403+
// Assert
404+
Assert.IsNotNull(buildInfo);
405+
Assert.AreEqual("1.1.2-beta.2", buildInfo.CurrentVersionTag.VersionInfo.FullVersion);
406+
Assert.AreEqual("new-hash-123", buildInfo.CurrentVersionTag.CommitHash);
407+
408+
// Should use most recent release with different hash
409+
Assert.IsNotNull(buildInfo.BaselineVersionTag);
410+
Assert.AreEqual("1.1.2-beta.1", buildInfo.BaselineVersionTag.VersionInfo.FullVersion);
411+
Assert.AreEqual("commit2", buildInfo.BaselineVersionTag.CommitHash);
412+
}
413+
414+
/// <summary>
415+
/// Test that pre-release baseline selection returns null when all previous versions have the same hash.
416+
/// This is an edge case where all previous tags are re-tags of the current commit.
417+
/// </summary>
418+
[TestMethod]
419+
public async Task GitHubRepoConnector_GetBuildInformationAsync_PreReleaseAllPreviousSameHash_ReturnsNullBaseline()
420+
{
421+
// Arrange - Create mock responses where all versions are on the same commit
422+
using var mockHandler = new MockGitHubGraphQLHttpMessageHandler()
423+
.AddCommitsResponse("same-hash-123")
424+
.AddReleasesResponse(
425+
new MockRelease("1.1.2-rc.1", "2024-03-03T00:00:00Z"),
426+
new MockRelease("1.1.2-beta.2", "2024-03-02T00:00:00Z"),
427+
new MockRelease("1.1.2-beta.1", "2024-03-01T00:00:00Z"))
428+
.AddPullRequestsResponse()
429+
.AddIssuesResponse()
430+
.AddTagsResponse(
431+
new MockTag("1.1.2-rc.1", "same-hash-123"),
432+
new MockTag("1.1.2-beta.2", "same-hash-123"),
433+
new MockTag("1.1.2-beta.1", "same-hash-123"));
434+
435+
using var mockHttpClient = new HttpClient(mockHandler);
436+
var connector = new MockableGitHubRepoConnector(mockHttpClient);
437+
438+
// Set up mock command responses
439+
connector.SetCommandResponse("git remote get-url origin", "https://github.com/test/repo.git");
440+
connector.SetCommandResponse("git rev-parse --abbrev-ref HEAD", "main");
441+
connector.SetCommandResponse("git rev-parse HEAD", "same-hash-123");
442+
connector.SetCommandResponse("gh auth token", "test-token");
443+
444+
// Act - Process 1.1.2-rc.1
445+
var buildInfo = await connector.GetBuildInformationAsync(Version.Create("1.1.2-rc.1"));
446+
447+
// Assert
448+
Assert.IsNotNull(buildInfo);
449+
Assert.AreEqual("1.1.2-rc.1", buildInfo.CurrentVersionTag.VersionInfo.FullVersion);
450+
Assert.AreEqual("same-hash-123", buildInfo.CurrentVersionTag.CommitHash);
451+
452+
// Should have null baseline since all previous versions are on the same hash
453+
Assert.IsNull(buildInfo.BaselineVersionTag);
454+
}
268455
}

0 commit comments

Comments
 (0)