diff --git a/README.md b/README.md index c838b6e..6b4f845 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ history and issue-tracking systems. It analyzes commits, pull requests, and issu notes, making it easy to integrate release documentation into your CI/CD pipelines and documentation workflows. For detailed documentation, see the [User Guide](https://github.com/demaconsulting/BuildMark/blob/main/docs/user_guide/introduction.md). +For command-line options, see the [CLI Reference](https://github.com/demaconsulting/BuildMark/blob/main/docs/user_guide/cli-reference.md). ## Features @@ -160,7 +161,7 @@ Changes, Bugs Fixed, and Dependency Updates sections with pre-wired routing rule common label and work-item patterns. For configuration details and examples, see the -[User Guide](https://github.com/demaconsulting/BuildMark/blob/main/docs/user_guide/introduction.md). +[Configuration Guide](https://github.com/demaconsulting/BuildMark/blob/main/docs/user_guide/configuration.md). ### Authentication @@ -173,7 +174,7 @@ from environment variables at runtime. `AZURE_DEVOPS_EXT_PAT`, then `SYSTEM_ACCESSTOKEN` (Azure Pipelines), then `az account get-access-token` (Azure CLI). -For more detail see the [User Guide](https://github.com/demaconsulting/BuildMark/blob/main/docs/user_guide/introduction.md). +For more detail see the [Authentication Guide](https://github.com/demaconsulting/BuildMark/blob/main/docs/user_guide/introduction.md#with-github-token). ## Self Validation @@ -209,8 +210,9 @@ Each test in the report proves: - **`BuildMark_KnownIssuesReporting`** - Known issues are correctly included when requested. - **`BuildMark_RulesRouting`** - Rules-based item routing assigns items to the correct report sections. -See the [User Guide](https://github.com/demaconsulting/BuildMark/blob/main/docs/user_guide/introduction.md) for more details -on the self-validation tests. +See the [CLI Reference][cli-ref] for more details on the self-validation tests. + +[cli-ref]: https://github.com/demaconsulting/BuildMark/blob/main/docs/user_guide/cli-reference.md#self-validation On validation failure the tool will exit with a non-zero exit code. @@ -220,7 +222,7 @@ BuildMark supports an optional `buildmark` code block in issue and pull request to control visibility, type classification, and affected-version ranges. Azure DevOps work items additionally support native custom fields for the same controls. -For details, see the [User Guide](https://github.com/demaconsulting/BuildMark/blob/main/docs/user_guide/introduction.md). +For details, see the [Item Controls Guide](https://github.com/demaconsulting/BuildMark/blob/main/docs/user_guide/item-controls.md). ## Report Format diff --git a/docs/design/build-mark/repo-connectors/azure-devops/azure-devops-repo-connector.md b/docs/design/build-mark/repo-connectors/azure-devops/azure-devops-repo-connector.md index 4e4742f..1ba1c85 100644 --- a/docs/design/build-mark/repo-connectors/azure-devops/azure-devops-repo-connector.md +++ b/docs/design/build-mark/repo-connectors/azure-devops/azure-devops-repo-connector.md @@ -123,8 +123,14 @@ Main entry point. Performs the following steps: 12. Fetch linked work items for each PR via `GET /git/repositories/{id}/pullrequests/{prId}/workitems` and batch-fetch work item details via `GET /wit/workitems?ids={ids}&$expand=all`. -13. Collect known issues (open bugs not resolved at the time of the build) via a - WIQL query, applying item controls from description bodies and custom fields. +13. Collect known issues from **all** bugs (resolved and unresolved), via a WIQL + query, applying item controls from description bodies and custom fields. + For each candidate bug: + - If `AffectedVersions` is declared, the bug is a known issue if and only if + `AffectedVersions.Contains(toVersion)` is true, regardless of resolved + state. This covers resolved bugs that were never back-ported to older + branches (LTS back-port gap). + - If no `AffectedVersions` is declared, only unresolved bugs are included. 14. If routing rules are configured, call `ApplyRules` (inherited from `RepoConnectorBase`) to distribute all collected items into the configured report sections and populate `BuildInformation.RoutedSections`. If no rules diff --git a/docs/design/build-mark/repo-connectors/github/github-repo-connector.md b/docs/design/build-mark/repo-connectors/github/github-repo-connector.md index df20e8e..4af463c 100644 --- a/docs/design/build-mark/repo-connectors/github/github-repo-connector.md +++ b/docs/design/build-mark/repo-connectors/github/github-repo-connector.md @@ -121,8 +121,14 @@ Main entry point. Performs the following steps: 8. Get all commits between the baseline and target. 9. Collect changes and bugs from pull requests merged in the commit range, applying item controls overrides from description bodies. -10. Collect known issues (open issues not included in this build), applying item - controls overrides from description bodies. +10. Collect known issues from **all** issues (open and closed) by querying GitHub + with `states: [OPEN, CLOSED]` and applying item controls overrides from + description bodies. For each candidate bug: + - If `AffectedVersions` is declared, the bug is a known issue if and only if + `AffectedVersions.Contains(toVersion)` is true, regardless of open/closed + state. This covers closed bugs that were fixed in a later release but were + never back-ported to older branches (LTS back-port gap). + - If no `AffectedVersions` is declared, only open bugs are included. 11. Sort all lists chronologically. 12. If routing rules are configured, call `ApplyRules` (inherited from `RepoConnectorBase`) to route all collected items into the configured report diff --git a/docs/design/build-mark/repo-connectors/mock/mock-repo-connector.md b/docs/design/build-mark/repo-connectors/mock/mock-repo-connector.md index 8efee2b..133feaa 100644 --- a/docs/design/build-mark/repo-connectors/mock/mock-repo-connector.md +++ b/docs/design/build-mark/repo-connectors/mock/mock-repo-connector.md @@ -31,6 +31,13 @@ determines the target and baseline versions, collects changes and known issues, and returns a fully populated `BuildInformation` record. The logic mirrors the production GitHubRepoConnector flow but operates entirely on in-memory data. +When collecting known issues, **all** issues (open and closed) are considered: + +- If `AffectedVersions` is non-null, the bug is included if and only if + `AffectedVersions.Contains(targetVersion)` is true, regardless of open/closed + state (models a closed bug never back-ported to an older branch). +- If `AffectedVersions` is null, only open bugs are included. + When routing rules have been configured via `Configure`, `GetBuildInformationAsync` collects all items and passes them to `ApplyRules` (inherited from `RepoConnectorBase`) to produce the `RoutedSections` list. If no rules are configured, the legacy diff --git a/docs/reqstream/build-mark/build-mark.yaml b/docs/reqstream/build-mark/build-mark.yaml index f704a6b..9380273 100644 --- a/docs/reqstream/build-mark/build-mark.yaml +++ b/docs/reqstream/build-mark/build-mark.yaml @@ -316,16 +316,34 @@ sections: - BuildMark-Program-Report - id: BuildMark-Report-KnownIssues - title: The tool shall support including known issues in build notes. + title: >- + The tool shall support including known issues in build notes, using + affected-versions metadata when available and falling back to open/closed + status when it is not. justification: | Disclosing known issues in release notes promotes transparency, helps users avoid - known pitfalls, and manages expectations about current limitations. This improves - user trust and reduces support burden. + known pitfalls, and manages expectations about current limitations. + + The following rules determine whether a bug is a known issue for a given build: + + 1. A closed bug with no declared affected-versions is NOT a known issue. + 2. An open bug with no declared affected-versions IS a known issue. + 3. A bug in any state (open or closed) whose declared affected-versions contain + the build version IS a known issue. + 4. A bug in any state (open or closed) whose declared affected-versions do NOT + contain the build version is NOT a known issue. + + This matters for LTS branches: a bug may be closed after being fixed in a later + release, but an LTS branch that was cut before the fix still needs to report the + bug as a known issue. The affected-versions field captures that scenario precisely. tests: - IntegrationTest_Report_IncludesKnownIssues_WhenFlagIsSet children: - BuildMark-BuildNotes-ReportModel - BuildMark-Program-Report + - BuildMark-RepoConnectors-GitHub + - BuildMark-RepoConnectors-AzureDevOps + - BuildMark-RepoConnectors-Mock - id: BuildMark-Report-VersionRange title: The tool shall support filtering build notes by version range. diff --git a/docs/reqstream/build-mark/repo-connectors/azure-devops/azure-devops-repo-connector.yaml b/docs/reqstream/build-mark/repo-connectors/azure-devops/azure-devops-repo-connector.yaml index fe580b3..9304ad9 100644 --- a/docs/reqstream/build-mark/repo-connectors/azure-devops/azure-devops-repo-connector.yaml +++ b/docs/reqstream/build-mark/repo-connectors/azure-devops/azure-devops-repo-connector.yaml @@ -45,6 +45,11 @@ sections: The primary purpose of the AzureDevOpsRepoConnector is to assemble BuildInformation from Azure DevOps repository data using the REST API, correctly identifying the current version tag, the baseline (previous) version tag, and the changes between them. + + Known-issues collection queries all bugs regardless of state. When affected-versions + is declared the item state is ignored; only the version range check determines + inclusion. This handles the LTS back-port gap scenario where a bug is resolved after + being fixed in a newer release but was never back-ported to an older branch. tests: - AzureDevOpsRepoConnector_GetBuildInformationAsync_WithMockedData_ReturnsValidBuildInformation - AzureDevOpsRepoConnector_GetBuildInformationAsync_WithMultipleVersions_SelectsCorrectPreviousVersion @@ -52,6 +57,8 @@ sections: - AzureDevOpsRepoConnector_GetBuildInformationAsync_WithOpenWorkItems_IdentifiesKnownIssues - AzureDevOpsRepoConnector_GetBuildInformationAsync_ReleaseVersion_SkipsAllPreReleases - AzureDevOpsRepoConnector_ImplementsInterface_ReturnsTrue + - AzureDevOpsRepoConnector_GetBuildInformationAsync_KnownIssues_FilteredByAffectedVersions + - AzureDevOpsRepoConnector_GetBuildInformationAsync_ClosedBugWithMatchingAffectedVersions_IsKnownIssue - id: BuildMark-AzureDevOps-ItemControls title: >- diff --git a/docs/reqstream/build-mark/repo-connectors/github/github-repo-connector.yaml b/docs/reqstream/build-mark/repo-connectors/github/github-repo-connector.yaml index 249c2a3..e489dd1 100644 --- a/docs/reqstream/build-mark/repo-connectors/github/github-repo-connector.yaml +++ b/docs/reqstream/build-mark/repo-connectors/github/github-repo-connector.yaml @@ -27,6 +27,11 @@ sections: from GitHub repository data. It must correctly identify the current version tag, the baseline (previous) version tag, and the changes between them, correctly handling pre-release tags and version selection edge cases. + + Known-issues collection considers all issues (open and closed). When + affected-versions is declared the issue state is ignored; only the version range + check determines inclusion. This handles the LTS back-port gap scenario where a + bug is closed after being fixed in a newer release but was never back-ported. tests: - GitHubRepoConnector_GetBuildInformationAsync_WithMockedData_ReturnsValidBuildInformation - GitHubRepoConnector_GetBuildInformationAsync_WithMultipleVersions_SelectsCorrectPreviousVersionAndGeneratesChangelogLink @@ -40,6 +45,8 @@ sections: - GitHubRepoConnector_ImplementsInterface_ReturnsTrue - GitHubRepoConnector_GetBuildInformationAsync_PrWithSubstringMatchLabel_NotClassifiedAsBug - GitHubRepoConnector_GetBuildInformationAsync_IssueWithSubstringMatchLabel_NotClassifiedAsKnownIssue + - GitHubRepoConnector_GetBuildInformationAsync_KnownIssues_FilteredByAffectedVersions + - GitHubRepoConnector_GetBuildInformationAsync_ClosedBugWithMatchingAffectedVersions_IsKnownIssue - id: BuildMark-GitHub-ItemControls title: >- diff --git a/docs/reqstream/build-mark/repo-connectors/mock/mock-repo-connector.yaml b/docs/reqstream/build-mark/repo-connectors/mock/mock-repo-connector.yaml index 577c747..0542d82 100644 --- a/docs/reqstream/build-mark/repo-connectors/mock/mock-repo-connector.yaml +++ b/docs/reqstream/build-mark/repo-connectors/mock/mock-repo-connector.yaml @@ -14,6 +14,11 @@ sections: Tests that exercise the report generation and self-validation logic must run without external dependencies. The MockRepoConnector provides a fixed, predictable dataset so tests can assert exact outcomes. + + The dataset includes a closed bug (issue 7) that carries an affected-versions + range, allowing tests to verify that the known-issues collection applies the + correct two-tier rule: version-range check for bugs with affected-versions, and + open/closed status fallback for bugs without. tests: - MockRepoConnector_Constructor_CreatesInstance - MockRepoConnector_ImplementsInterface @@ -24,3 +29,5 @@ sections: - MockRepoConnector_Configure_StoresRulesAndSections - MockRepoConnector_GetBuildInformationAsync_WithRules_ReturnsRoutedSections - MockRepoConnector_GetBuildInformationAsync_WithoutRules_ReturnsNullRoutedSections + - MockRepoConnector_GetBuildInformationAsync_KnownIssues_FilteredByAffectedVersions + - MockRepoConnector_GetBuildInformationAsync_ClosedBugWithMatchingAffectedVersions_IsKnownIssue diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/AzureDevOps/AzureDevOpsRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/AzureDevOps/AzureDevOpsRepoConnector.cs index 6d693ce..5c37975 100644 --- a/src/DemaConsulting.BuildMark/RepoConnectors/AzureDevOps/AzureDevOpsRepoConnector.cs +++ b/src/DemaConsulting.BuildMark/RepoConnectors/AzureDevOps/AzureDevOpsRepoConnector.cs @@ -123,7 +123,7 @@ public override async Task GetBuildInformationAsync(VersionTag lookupData); // Collect known issues via WIQL query - var knownIssues = await CollectKnownIssuesAsync(restClient, allChangeIds, lookupData); + var knownIssues = await CollectKnownIssuesAsync(restClient, allChangeIds, lookupData, toVersion); // Sort all lists by Index to ensure chronological order nonBugChanges.Sort((a, b) => a.Index.CompareTo(b.Index)); @@ -559,21 +559,26 @@ private static void ProcessPullRequestWithoutWorkItems( } /// - /// Collects known issues (open bugs not resolved) via a WIQL query. + /// Collects known issues via a WIQL query. + /// When a bug declares AffectedVersions, it is a known issue if and only if + /// AffectedVersions.Contains(targetVersion) is true, regardless of its state. + /// When no AffectedVersions are declared, only unresolved bugs are included. /// /// Azure DevOps REST client. - /// Set of all change IDs already processed. + /// Set of all change IDs already processed in this build. /// Lookup data structures. + /// The version being built, used for affected-versions filtering. /// List of known issues. private static async Task> CollectKnownIssuesAsync( AzureDevOpsRestClient restClient, HashSet allChangeIds, - LookupData lookupData) + LookupData lookupData, + VersionTag targetVersion) { - // Query for open bugs and issues + // Query all bugs and issues — state filtering is applied in code so that resolved + // bugs with a declared affected-versions range are still considered as known issues. const string wiql = "SELECT [System.Id] FROM workitems " + - "WHERE [System.WorkItemType] IN ('Bug', 'Issue') " + - "AND [System.State] NOT IN ('Done', 'Closed', 'Resolved')"; + "WHERE [System.WorkItemType] IN ('Bug', 'Issue')"; var queryResult = await restClient.QueryWorkItemsAsync(wiql); if (queryResult.WorkItems.Count == 0) @@ -590,21 +595,26 @@ private static async Task> CollectKnownIssuesAsync( { var workItemId = workItem.Id.ToString(CultureInfo.InvariantCulture); - // Skip items already included as changes + // Skip items already included as changes in this build if (allChangeIds.Contains(workItemId)) { continue; } - // Skip resolved work items (defense in depth) - if (WorkItemMapper.IsWorkItemResolved(workItem)) + var workItemUrl = BuildWorkItemUrl(lookupData.OrganizationUrl, lookupData.Project, workItem.Id); + var itemInfo = WorkItemMapper.MapWorkItemToItemInfo(workItem, workItemUrl, workItem.Id); + if (itemInfo == null || itemInfo.Type != "bug") { continue; } - var workItemUrl = BuildWorkItemUrl(lookupData.OrganizationUrl, lookupData.Project, workItem.Id); - var itemInfo = WorkItemMapper.MapWorkItemToItemInfo(workItem, workItemUrl, workItem.Id); - if (itemInfo != null && itemInfo.Type == "bug") + // With affected-versions: include if version matches, regardless of state. + // Without affected-versions: only unresolved bugs are included. + var isKnownIssue = itemInfo.AffectedVersions != null + ? itemInfo.AffectedVersions.Contains(targetVersion) + : !WorkItemMapper.IsWorkItemResolved(workItem); + + if (isKnownIssue) { knownIssues.Add(itemInfo); } diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs index 2402b94..6447a64 100644 --- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs +++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs @@ -511,7 +511,7 @@ public async Task> GetAllIssuesAsync( Query = @" query($owner: String!, $repo: String!, $after: String) { repository(owner: $owner, name: $repo) { - issues(first: 100, after: $after) { + issues(first: 100, states: [OPEN, CLOSED], after: $after) { nodes { number title diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs index 5cd3466..308f5b9 100644 --- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs +++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs @@ -156,7 +156,7 @@ public override async Task GetBuildInformationAsync(VersionTag repo); // Collect known issues - var knownIssues = CollectKnownIssues(gitHubData.Issues, allChangeIds); + var knownIssues = CollectKnownIssues(gitHubData.Issues, allChangeIds, toVersion); // Sort all lists by Index to ensure chronological order nonBugChanges.Sort((a, b) => a.Index.CompareTo(b.Index)); @@ -730,21 +730,50 @@ private static void ProcessPullRequestWithoutIssues( } /// - /// Collects known issues (open bugs not fixed in this build). + /// Collects known issues from the full issue list. + /// When a bug declares AffectedVersions, it is a known issue if and only if + /// AffectedVersions.Contains(targetVersion) is true, regardless of its open/closed + /// state. When no AffectedVersions are declared, only open bugs are included. /// - /// All issues from GitHub. - /// Set of all change IDs already processed. + /// All issues from GitHub (open and closed). + /// Set of all change IDs already processed in this build. + /// The version being built, used for affected-versions filtering. /// List of known issues. - private static List CollectKnownIssues(IReadOnlyList issues, HashSet allChangeIds) + private static List CollectKnownIssues( + IReadOnlyList issues, + HashSet allChangeIds, + VersionTag targetVersion) { - return issues - .Where(i => i.State == "OPEN") - .Select(issue => (issue, issueId: issue.Number.ToString(CultureInfo.InvariantCulture))) - .Where(tuple => !allChangeIds.Contains(tuple.issueId)) - .Select(tuple => CreateItemInfoFromIssue(tuple.issue, tuple.issue.Number)) - .OfType() - .Where(itemInfo => itemInfo.Type == "bug") - .ToList(); + List knownIssues = []; + + foreach (var issue in issues) + { + // Skip issues already addressed in this build + var issueId = issue.Number.ToString(CultureInfo.InvariantCulture); + if (allChangeIds.Contains(issueId)) + { + continue; + } + + var itemInfo = CreateItemInfoFromIssue(issue, issue.Number); + if (itemInfo == null || itemInfo.Type != "bug") + { + continue; + } + + // With affected-versions: include if version matches, regardless of state. + // Without affected-versions: only open bugs are included. + var isKnownIssue = itemInfo.AffectedVersions != null + ? itemInfo.AffectedVersions.Contains(targetVersion) + : issue.State == "OPEN"; + + if (isKnownIssue) + { + knownIssues.Add(itemInfo); + } + } + + return knownIssues; } /// diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/Mock/MockRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/Mock/MockRepoConnector.cs index 95e3d10..312523c 100644 --- a/src/DemaConsulting.BuildMark/RepoConnectors/Mock/MockRepoConnector.cs +++ b/src/DemaConsulting.BuildMark/RepoConnectors/Mock/MockRepoConnector.cs @@ -39,7 +39,9 @@ public class MockRepoConnector : RepoConnectorBase { "2", "Fix bug in Y" }, { "3", "Update documentation" }, { "4", "Known bug A" }, - { "5", "Known bug B" } + { "5", "Known bug B" }, + { "6", "Known bug C" }, + { "7", "Known bug D (closed, LTS back-port)" } }; /// @@ -51,7 +53,9 @@ public class MockRepoConnector : RepoConnectorBase { "2", "bug" }, { "3", "documentation" }, { "4", "bug" }, - { "5", "bug" } + { "5", "bug" }, + { "6", "bug" }, + { "7", "bug" } }; /// @@ -85,7 +89,20 @@ public class MockRepoConnector : RepoConnectorBase /// /// List of open issue IDs for testing. /// - private readonly List _openIssues = ["4", "5"]; + private readonly List _openIssues = ["4", "5", "6"]; + + /// + /// Mapping of issue IDs to their affected-version interval sets for testing. + /// Issues without an entry have no declared affected-versions (fallback to open status). + /// + private readonly Dictionary _issueAffectedVersions = new() + { + { "5", VersionIntervalSet.Parse("[5.0.0,)") }, + + // Issue 7 is deliberately closed (not in _openIssues) but still affects v1.0.0 exactly, + // modelling a bug that was fixed in a later release but never back-ported to the v1.0 branch. + { "7", VersionIntervalSet.Parse("[1.0.0,1.0.0]") } + }; /// /// Gets build information for a release. @@ -111,8 +128,9 @@ public override async Task GetBuildInformationAsync(VersionTag // Categorize changes into bugs and non-bug changes var (bugs, nonBugChanges, allChangeIds) = CategorizeChanges(changes); - // Collect known issues (open bugs not fixed in this build) - var knownIssues = await CollectKnownIssuesAsync(allChangeIds); + // Collect known issues applying the two-tier rule: version-range check for bugs + // with affected-versions, open/closed status fallback for bugs without + var knownIssues = await CollectKnownIssuesAsync(allChangeIds, toTagInfo); // Sort all lists by Index to ensure chronological order nonBugChanges.Sort((a, b) => a.Index.CompareTo(b.Index)); @@ -354,34 +372,59 @@ private static (List bugs, List nonBugChanges, HashSet - /// Collects known issues (open bugs not fixed in this build). + /// Collects known issues from all issues in the mock dataset. + /// When a bug declares AffectedVersions, it is a known issue if and only if + /// AffectedVersions.Contains(targetVersion) is true, regardless of its open/closed + /// state. When no AffectedVersions are declared, only open bugs are included. /// - /// Set of all change IDs already processed. + /// Set of all change IDs already processed in this build. + /// The version being built, used for affected-versions filtering. /// List of known issues. - private async Task> CollectKnownIssuesAsync(HashSet allChangeIds) + private Task> CollectKnownIssuesAsync(HashSet allChangeIds, VersionTag targetVersion) { - // Initialize collection for known issues - List knownIssues = new(); - var openIssues = await GetOpenIssuesAsync(); + List knownIssues = []; - // Process each open issue - foreach (var issue in openIssues) + // Iterate over every known issue ID, not just open ones — closed bugs with a matching + // affected-versions range must also appear as known issues (e.g. LTS back-port gaps). + foreach (var issueId in _issueTitles.Keys) { - // Skip issues already fixed in this build - if (allChangeIds.Contains(issue.Id)) + // Skip issues already addressed in this build + if (allChangeIds.Contains(issueId)) { continue; } - // Only include bugs in known issues list - if (issue.Type == "bug") + var type = _issueTypes.GetValueOrDefault(issueId, "other"); + if (type != "bug") + { + continue; + } + + var affectedVersions = _issueAffectedVersions.GetValueOrDefault(issueId); + var isOpen = _openIssues.Contains(issueId); + + // With affected-versions: include if version matches, regardless of state. + // Without affected-versions: only open bugs are included. + var isKnownIssue = affectedVersions != null + ? affectedVersions.Contains(targetVersion) + : isOpen; + + if (!isKnownIssue) { - knownIssues.Add(issue); + continue; } + + var title = _issueTitles.TryGetValue(issueId, out var t) ? t : $"Issue {issueId}"; + knownIssues.Add(new ItemInfo( + issueId, + title, + $"https://github.com/example/repo/issues/{issueId}", + type, + int.Parse(issueId, CultureInfo.InvariantCulture), + affectedVersions)); } - // Return collected known issues - return knownIssues; + return Task.FromResult(knownIssues); } /// @@ -536,26 +579,6 @@ private static void AddPullRequestAsChange(List changes, string pr) : _tagHashes.GetValueOrDefault(tag)); } - /// - /// Gets the list of open issues with their details. - /// - /// List of open issues with full information. - private Task> GetOpenIssuesAsync() - { - // Return predefined list of open issues with full details - var openIssuesData = _openIssues - .Select(issueId => new ItemInfo( - issueId, - _issueTitles.TryGetValue(issueId, out var title) ? title : $"Issue {issueId}", - $"https://github.com/example/repo/issues/{issueId}", - _issueTypes.GetValueOrDefault(issueId, "other"), - int.Parse(issueId, CultureInfo.InvariantCulture))) - .ToList(); - - // Return task with open issues data - return Task.FromResult(openIssuesData); - } - /// /// Generates a mock changelog link for testing. /// diff --git a/test/DemaConsulting.BuildMark.Tests/BuildNotes/BuildInformationTests.cs b/test/DemaConsulting.BuildMark.Tests/BuildNotes/BuildInformationTests.cs index a643462..6e2cfc7 100644 --- a/test/DemaConsulting.BuildMark.Tests/BuildNotes/BuildInformationTests.cs +++ b/test/DemaConsulting.BuildMark.Tests/BuildNotes/BuildInformationTests.cs @@ -178,10 +178,10 @@ public async Task BuildInformation_GetBuildInformationAsync_CollectsIssuesCorrec // Verify no bug issues for this version Assert.IsEmpty(buildInfo.Bugs); - // Verify known issues include open bugs + // Verify known issues include open bugs (issue 5 excluded by affected-versions [5.0.0,)) Assert.HasCount(2, buildInfo.KnownIssues); Assert.AreEqual("4", buildInfo.KnownIssues[0].Id); - Assert.AreEqual("5", buildInfo.KnownIssues[1].Id); + Assert.AreEqual("6", buildInfo.KnownIssues[1].Id); } /// @@ -287,7 +287,10 @@ public async Task BuildInformation_ToMarkdown_IncludesKnownIssuesWhenRequested() // Assert - verify known issues section is included Assert.Contains("## Known Issues", markdown); Assert.Contains("Known bug A", markdown); - Assert.Contains("Known bug B", markdown); + Assert.Contains("Known bug C", markdown); + + // Known bug B has affected-versions [5.0.0,) which does not include v2.0.0 + Assert.DoesNotContain("Known bug B", markdown); } /// diff --git a/test/DemaConsulting.BuildMark.Tests/BuildNotes/BuildNotesTests.cs b/test/DemaConsulting.BuildMark.Tests/BuildNotes/BuildNotesTests.cs index 9933566..f8cf017 100644 --- a/test/DemaConsulting.BuildMark.Tests/BuildNotes/BuildNotesTests.cs +++ b/test/DemaConsulting.BuildMark.Tests/BuildNotes/BuildNotesTests.cs @@ -68,7 +68,10 @@ public async Task BuildNotes_ReportModel_IncludesKnownIssues() // Assert: known issues section is present and contains expected items Assert.Contains("## Known Issues", markdown); Assert.Contains("Known bug A", markdown); - Assert.Contains("Known bug B", markdown); + Assert.Contains("Known bug C", markdown); + + // Known bug B has affected-versions [5.0.0,) which does not include v2.0.0 + Assert.DoesNotContain("Known bug B", markdown); } /// diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/AzureDevOps/AzureDevOpsRepoConnectorTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/AzureDevOps/AzureDevOpsRepoConnectorTests.cs index 9bf2f2a..b7e3cbd 100644 --- a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/AzureDevOps/AzureDevOpsRepoConnectorTests.cs +++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/AzureDevOps/AzureDevOpsRepoConnectorTests.cs @@ -1098,4 +1098,103 @@ private static AzureDevOpsWorkItem CreateWorkItem( return new AzureDevOpsWorkItem(id, fields); } + + /// + /// Verify that known issues are filtered by affected-versions (via Custom.AffectedVersions) + /// when the field is present on a work item. Bugs whose affected-versions do not contain + /// the build version are excluded; bugs with matching ranges or no field are included. + /// + [TestMethod] + public async Task AzureDevOpsRepoConnector_GetBuildInformationAsync_KnownIssues_FilteredByAffectedVersions() + { + // Arrange - three open bugs via WIQL: + // 401: Custom.AffectedVersions = [1.0.0,2.0.0) => includes v1.5.0 + // 402: Custom.AffectedVersions = [3.0.0,) => excludes v1.5.0 + // 403: no Custom.AffectedVersions => always included when open + using var mockHandler = new MockAzureDevOpsHttpMessageHandler() + .AddTagsResponse(new MockAdoTag("v1.5.0", "commit1")) + .AddCommitsResponse(new MockAdoCommit("commit1")) + .AddPullRequestsResponse() + .AddWiqlResponse(401, 402, 403) + .AddWorkItemsResponse( + new MockAdoWorkItem(401, "Bug affecting v1.x", "Bug", "Active", + CustomAffectedVersions: "[1.0.0,2.0.0)"), + new MockAdoWorkItem(402, "Bug affecting v3+", "Bug", "Active", + CustomAffectedVersions: "[3.0.0,)"), + new MockAdoWorkItem(403, "Bug with no versions", "Bug", "Active")); + + using var mockHttpClient = new HttpClient(mockHandler); + var connector = CreateMockConnector(mockHttpClient, "commit1"); + + // Act + var buildInfo = await connector.GetBuildInformationAsync(VersionTag.Create("v1.5.0")); + + // Assert + Assert.IsNotNull(buildInfo); + Assert.IsNotNull(buildInfo.KnownIssues); + + // Bug 401 should be included (v1.5.0 is in [1.0.0,2.0.0)) + Assert.IsTrue( + buildInfo.KnownIssues.Exists(i => i.Id == "401"), + "Bug 401 with Custom.AffectedVersions [1.0.0,2.0.0) should be a known issue for v1.5.0"); + + // Bug 402 should be excluded (v1.5.0 is NOT in [3.0.0,)) + Assert.IsFalse( + buildInfo.KnownIssues.Exists(i => i.Id == "402"), + "Bug 402 with Custom.AffectedVersions [3.0.0,) should NOT be a known issue for v1.5.0"); + + // Bug 403 should be included (no affected-versions, fallback to open status) + Assert.IsTrue( + buildInfo.KnownIssues.Exists(i => i.Id == "403"), + "Bug 403 with no Custom.AffectedVersions should be a known issue (open status fallback)"); + } + + /// + /// Verify that a RESOLVED/CLOSED bug with a Custom.AffectedVersions range that contains + /// the build version is reported as a known issue (LTS back-port gap scenario). + /// + [TestMethod] + public async Task AzureDevOpsRepoConnector_GetBuildInformationAsync_ClosedBugWithMatchingAffectedVersions_IsKnownIssue() + { + // Arrange - three resolved bugs: + // 404: Resolved, AV [1.0.0,2.0.0) - fixed in v2, LTS v1.5 branch never got the fix + // 405: Resolved, AV [3.0.0,) - does NOT affect v1.5.0 + // 406: Resolved, no AV - resolved bug with no AV is NOT a known issue (status fallback) + using var mockHandler = new MockAzureDevOpsHttpMessageHandler() + .AddTagsResponse(new MockAdoTag("v1.5.0", "commit1")) + .AddCommitsResponse(new MockAdoCommit("commit1")) + .AddPullRequestsResponse() + .AddWiqlResponse(404, 405, 406) + .AddWorkItemsResponse( + new MockAdoWorkItem(404, "Closed bug affecting v1.x", "Bug", "Resolved", + CustomAffectedVersions: "[1.0.0,2.0.0)"), + new MockAdoWorkItem(405, "Closed bug affecting v3+", "Bug", "Resolved", + CustomAffectedVersions: "[3.0.0,)"), + new MockAdoWorkItem(406, "Closed bug with no AV", "Bug", "Resolved")); + + using var mockHttpClient = new HttpClient(mockHandler); + var connector = CreateMockConnector(mockHttpClient, "commit1"); + + // Act + var buildInfo = await connector.GetBuildInformationAsync(VersionTag.Create("v1.5.0")); + + // Assert + Assert.IsNotNull(buildInfo); + Assert.IsNotNull(buildInfo.KnownIssues); + + // Bug 404 is Resolved but AV [1.0.0,2.0.0) contains v1.5.0 → IS a known issue + Assert.IsTrue( + buildInfo.KnownIssues.Exists(i => i.Id == "404"), + "Resolved bug 404 with AV [1.0.0,2.0.0) should be a known issue for v1.5.0 (LTS back-port gap)"); + + // Bug 405 is Resolved and AV [3.0.0,) does NOT contain v1.5.0 → NOT a known issue + Assert.IsFalse( + buildInfo.KnownIssues.Exists(i => i.Id == "405"), + "Resolved bug 405 with AV [3.0.0,) should NOT be a known issue for v1.5.0"); + + // Bug 406 is Resolved with no AV → NOT a known issue (resolved/unresolved fallback) + Assert.IsFalse( + buildInfo.KnownIssues.Exists(i => i.Id == "406"), + "Resolved bug 406 with no AV should NOT be a known issue (resolved, no AV)"); + } } diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubRepoConnectorTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubRepoConnectorTests.cs index 2bcfb94..be1187c 100644 --- a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubRepoConnectorTests.cs +++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubRepoConnectorTests.cs @@ -839,6 +839,146 @@ public async Task GitHubRepoConnector_GetBuildInformationAsync_WithConfiguredRul Assert.HasCount(1, bugsSection.Items); Assert.AreEqual("Bug PR", bugsSection.Items[0].Title); } + + /// + /// Verify that known issues are filtered by affected-versions when present. + /// A bug whose affected-versions do not contain the build version is excluded; + /// a bug whose affected-versions contain the build version is included; + /// a bug with no affected-versions is included (fallback to open status). + /// + [TestMethod] + public async Task GitHubRepoConnector_GetBuildInformationAsync_KnownIssues_FilteredByAffectedVersions() + { + // Arrange - three open bugs: + // 301: affected-versions [1.0.0,2.0.0) => includes v1.5.0, excludes v2.0.0 + // 302: affected-versions [3.0.0,) => excludes v1.5.0 + // 303: no affected-versions => always included when open + using var mockHandler = new MockGitHubGraphQLHttpMessageHandler() + .AddCommitsResponse("commit1") + .AddReleasesResponse(new MockRelease("v1.5.0", "2024-06-01T00:00:00Z")) + .AddPullRequestsResponse() + .AddIssuesResponse( + new MockIssue( + Number: 301, + Title: "Bug affecting v1.x", + Url: "https://github.com/test/repo/issues/301", + State: "OPEN", + Labels: ["bug"], + Body: "```buildmark\naffected-versions: [1.0.0,2.0.0)\n```"), + new MockIssue( + Number: 302, + Title: "Bug affecting v3+", + Url: "https://github.com/test/repo/issues/302", + State: "OPEN", + Labels: ["bug"], + Body: "```buildmark\naffected-versions: [3.0.0,)\n```"), + new MockIssue( + Number: 303, + Title: "Bug with no versions", + Url: "https://github.com/test/repo/issues/303", + State: "OPEN", + Labels: ["bug"])) + .AddTagsResponse(new MockTag("v1.5.0", "commit1")); + + using var mockHttpClient = new HttpClient(mockHandler); + var connector = new MockableGitHubRepoConnector(mockHttpClient); + connector.SetCommandResponse("git remote get-url origin", "https://github.com/test/repo.git"); + connector.SetCommandResponse("git rev-parse --abbrev-ref HEAD", "main"); + connector.SetCommandResponse("git rev-parse HEAD", "commit1"); + connector.SetCommandResponse("gh auth token", "test-token"); + + // Act + var buildInfo = await connector.GetBuildInformationAsync(VersionTag.Create("v1.5.0")); + + // Assert + Assert.IsNotNull(buildInfo); + Assert.IsNotNull(buildInfo.KnownIssues); + + // Bug 301 should be included (v1.5.0 is in [1.0.0,2.0.0)) + Assert.IsTrue( + buildInfo.KnownIssues.Exists(i => i.Id == "301"), + "Bug 301 with affected-versions [1.0.0,2.0.0) should be a known issue for v1.5.0"); + + // Bug 302 should be excluded (v1.5.0 is NOT in [3.0.0,)) + Assert.IsFalse( + buildInfo.KnownIssues.Exists(i => i.Id == "302"), + "Bug 302 with affected-versions [3.0.0,) should NOT be a known issue for v1.5.0"); + + // Bug 303 should be included (no affected-versions, fallback to open status) + Assert.IsTrue( + buildInfo.KnownIssues.Exists(i => i.Id == "303"), + "Bug 303 with no affected-versions should be a known issue (open status fallback)"); + } + + /// + /// Verify that a CLOSED bug with an affected-versions range that contains the build + /// version is reported as a known issue. This models the LTS back-port gap scenario: + /// a bug may be closed after being fixed in a newer release, yet still affect an older + /// branch from which LTS releases are cut. + /// + [TestMethod] + public async Task GitHubRepoConnector_GetBuildInformationAsync_ClosedBugWithMatchingAffectedVersions_IsKnownIssue() + { + // Arrange - three closed bugs: + // 304: CLOSED, AV [1.0.0,2.0.0) - fixed in v2 but v1.5.0 LTS branch never got the fix + // 305: CLOSED, AV [3.0.0,) - fixed in v3+; does NOT affect v1.5.0 + // 306: CLOSED, no AV - closed bug with no AV is NOT a known issue (status fallback) + using var mockHandler = new MockGitHubGraphQLHttpMessageHandler() + .AddCommitsResponse("commit1") + .AddReleasesResponse(new MockRelease("v1.5.0", "2024-06-01T00:00:00Z")) + .AddPullRequestsResponse() + .AddIssuesResponse( + new MockIssue( + Number: 304, + Title: "Closed bug affecting v1.x", + Url: "https://github.com/test/repo/issues/304", + State: "CLOSED", + Labels: ["bug"], + Body: "```buildmark\naffected-versions: [1.0.0,2.0.0)\n```"), + new MockIssue( + Number: 305, + Title: "Closed bug affecting v3+", + Url: "https://github.com/test/repo/issues/305", + State: "CLOSED", + Labels: ["bug"], + Body: "```buildmark\naffected-versions: [3.0.0,)\n```"), + new MockIssue( + Number: 306, + Title: "Closed bug with no AV", + Url: "https://github.com/test/repo/issues/306", + State: "CLOSED", + Labels: ["bug"])) + .AddTagsResponse(new MockTag("v1.5.0", "commit1")); + + using var mockHttpClient = new HttpClient(mockHandler); + var connector = new MockableGitHubRepoConnector(mockHttpClient); + connector.SetCommandResponse("git remote get-url origin", "https://github.com/test/repo.git"); + connector.SetCommandResponse("git rev-parse --abbrev-ref HEAD", "main"); + connector.SetCommandResponse("git rev-parse HEAD", "commit1"); + connector.SetCommandResponse("gh auth token", "test-token"); + + // Act + var buildInfo = await connector.GetBuildInformationAsync(VersionTag.Create("v1.5.0")); + + // Assert + Assert.IsNotNull(buildInfo); + Assert.IsNotNull(buildInfo.KnownIssues); + + // Bug 304 is CLOSED but has AV [1.0.0,2.0.0) which contains v1.5.0 → IS a known issue + Assert.IsTrue( + buildInfo.KnownIssues.Exists(i => i.Id == "304"), + "Closed bug 304 with AV [1.0.0,2.0.0) should be a known issue for v1.5.0 (LTS back-port gap)"); + + // Bug 305 is CLOSED and has AV [3.0.0,) which does NOT contain v1.5.0 → NOT a known issue + Assert.IsFalse( + buildInfo.KnownIssues.Exists(i => i.Id == "305"), + "Closed bug 305 with AV [3.0.0,) should NOT be a known issue for v1.5.0"); + + // Bug 306 is CLOSED with no AV → NOT a known issue (open/closed fallback applies) + Assert.IsFalse( + buildInfo.KnownIssues.Exists(i => i.Id == "306"), + "Closed bug 306 with no AV should NOT be a known issue (closed, no AV)"); + } } diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/Mock/MockRepoConnectorTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/Mock/MockRepoConnectorTests.cs index 83a505d..d98cdfd 100644 --- a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/Mock/MockRepoConnectorTests.cs +++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/Mock/MockRepoConnectorTests.cs @@ -245,6 +245,72 @@ public async Task MockRepoConnector_GetBuildInformationAsync_WithoutRules_Return // Assert - RoutedSections should be null (legacy mode) Assert.IsNull(buildInfo.RoutedSections, "RoutedSections should be null when no rules are configured"); } + + /// + /// Verify that MockRepoConnector filters known issues by affected-versions. + /// Issue 5 has affected-versions [5.0.0,) which excludes v2.0.0 but includes v5.0.0. + /// + /// + /// What is being tested: MockRepoConnector affected-versions filtering of known issues + /// What the assertions prove: Bug with out-of-range affected-versions is excluded; + /// building for an in-range version includes it + /// + [TestMethod] + public async Task MockRepoConnector_GetBuildInformationAsync_KnownIssues_FilteredByAffectedVersions() + { + // Arrange - Use version v2.0.0 (issue 5 has [5.0.0,) so it is excluded) + var connector = new MockRepoConnector(); + + // Act + var buildInfo = await connector.GetBuildInformationAsync(VersionTag.Create("v2.0.0")); + + // Assert - issue 4 (no affected-versions) and issue 6 (no affected-versions) are included + Assert.IsNotNull(buildInfo.KnownIssues); + Assert.IsTrue( + buildInfo.KnownIssues.Exists(i => i.Id == "4"), + "Bug 4 with no affected-versions should be a known issue"); + Assert.IsFalse( + buildInfo.KnownIssues.Exists(i => i.Id == "5"), + "Bug 5 with affected-versions [5.0.0,) should NOT be a known issue for v2.0.0"); + Assert.IsTrue( + buildInfo.KnownIssues.Exists(i => i.Id == "6"), + "Bug 6 with no affected-versions should be a known issue"); + } + + /// + /// Verify that issue 7 — a CLOSED bug with affected-versions [1.0.0,1.0.0] — is + /// reported as a known issue when building exactly v1.0.0, and is NOT reported for + /// a version outside that range (e.g. v2.0.0). + /// + /// + /// What is being tested: MockRepoConnector known-issues rule for closed bugs with AV + /// What the assertions prove: LTS back-port gap is modelled correctly — a closed bug + /// with a matching AV is included; the same bug is excluded for an unaffected version + /// + [TestMethod] + public async Task MockRepoConnector_GetBuildInformationAsync_ClosedBugWithMatchingAffectedVersions_IsKnownIssue() + { + // Arrange - issue 7 is closed and has AV [1.0.0,1.0.0] (only matches v1.0.0 exactly) + var connector = new MockRepoConnector(); + + // Act - build for v1.0.0 (issue 7 should be included) + var buildInfoV1 = await connector.GetBuildInformationAsync(VersionTag.Create("v1.0.0")); + + // Assert - issue 7 must be a known issue for v1.0.0 + Assert.IsNotNull(buildInfoV1.KnownIssues); + Assert.IsTrue( + buildInfoV1.KnownIssues.Exists(i => i.Id == "7"), + "Closed bug 7 with AV [1.0.0,1.0.0] should be a known issue for v1.0.0 (LTS back-port gap)"); + + // Act - build for v2.0.0 (issue 7 should NOT be included — AV doesn't cover v2) + var buildInfoV2 = await connector.GetBuildInformationAsync(VersionTag.Create("v2.0.0")); + + // Assert - issue 7 must NOT be a known issue for v2.0.0 + Assert.IsNotNull(buildInfoV2.KnownIssues); + Assert.IsFalse( + buildInfoV2.KnownIssues.Exists(i => i.Id == "7"), + "Closed bug 7 with AV [1.0.0,1.0.0] should NOT be a known issue for v2.0.0"); + } }