diff --git a/docs/cli/release/changelog-render.md b/docs/cli/release/changelog-render.md index f394a82f7..128778251 100644 --- a/docs/cli/release/changelog-render.md +++ b/docs/cli/release/changelog-render.md @@ -35,6 +35,13 @@ docs-builder changelog render [options...] [-h|--help] : Defaults to false. `--hide-private-links` -: Optional: Hide private links by commenting them out in markdown output. +: Optional: Hide private links by commenting them out in the markdown output. : This option is useful when rendering changelog bundles in private repositories. : Defaults to false. + +`--hide-features ` +: Optional: Filter by feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. +: Each occurrence can be either comma-separated feature IDs (e.g., `--hide-features "feature:new-search-api,feature:enhanced-analytics"`) or a file path (e.g., `--hide-features /path/to/file.txt`). +: When specifying feature IDs directly, provide comma-separated values. +: When specifying a file path, provide a single value that points to a newline-delimited file. The file should contain one feature ID per line. +: Entries with matching `feature-id` values will be commented out in the markdown output and a warning will be emitted. diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 9d4cf2b4c..723cd1e35 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -259,7 +259,8 @@ Options: --output Optional: Output directory for rendered markdown files. Defaults to current directory [Default: null] --title Optional: Title to use for section headers in output markdown files. Defaults to version from first bundle [Default: null] --subsections Optional: Group entries by area/component in subsections. Defaults to false - --hide-private-links Optional: Hide private links by commenting them out in markdown output. Defaults to false + --hide-private-links Optional: Hide private links by commenting them out in the markdown output. Defaults to false + --hide-features Filter by feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. Entries with matching feature-id values will be commented out in the markdown output. [Default: null] ``` Before you can use this command you must create changelog files and collect them into bundles. @@ -314,3 +315,6 @@ For example, the `index.md` output file contains information derived from the ch ``` To comment out the pull request and issue links, for example if they relate to a private repository, use the `--hide-private-links` option. + +If you have changelogs with `feature-id` values and you want them to be omitted from the output, use the `--hide-features` option. +For more information, refer to [](/cli/release/changelog-render.md). diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogRenderInput.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogRenderInput.cs index b0c596464..2daa7d569 100644 --- a/src/services/Elastic.Documentation.Services/Changelog/ChangelogRenderInput.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogRenderInput.cs @@ -14,5 +14,6 @@ public class ChangelogRenderInput public string? Title { get; set; } public bool Subsections { get; set; } public bool HidePrivateLinks { get; set; } + public string[]? HideFeatures { get; set; } } diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index c67f8b632..dd5916418 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -1452,17 +1452,131 @@ Cancel ctx // Convert title to slug format for folder names and anchors (lowercase, dashes instead of spaces) var titleSlug = TitleToSlug(title); + // Load feature IDs to hide - check if --hide-features contains a file path or a list of feature IDs + var featureIdsToHide = new HashSet(StringComparer.OrdinalIgnoreCase); + if (input.HideFeatures is { Length: > 0 }) + { + // If there's exactly one value, check if it's a file path + if (input.HideFeatures.Length == 1) + { + var singleValue = input.HideFeatures[0]; + + if (_fileSystem.File.Exists(singleValue)) + { + // File exists, read feature IDs from it + var featureIdsFileContent = await _fileSystem.File.ReadAllTextAsync(singleValue, ctx); + var featureIdsFromFile = featureIdsFileContent + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(f => !string.IsNullOrWhiteSpace(f)) + .ToArray(); + + foreach (var featureId in featureIdsFromFile) + { + _ = featureIdsToHide.Add(featureId); + } + } + else + { + // Check if it looks like a file path + var looksLikeFilePath = singleValue.Contains(_fileSystem.Path.DirectorySeparatorChar) || + singleValue.Contains(_fileSystem.Path.AltDirectorySeparatorChar) || + _fileSystem.Path.HasExtension(singleValue); + + if (looksLikeFilePath) + { + // File path doesn't exist + collector.EmitError(singleValue, $"File does not exist: {singleValue}"); + return false; + } + else + { + // Doesn't look like a file path, treat as feature ID + _ = featureIdsToHide.Add(singleValue); + } + } + } + else + { + // Multiple values - process all values first, then check for errors + var nonExistentFiles = new List(); + foreach (var value in input.HideFeatures) + { + if (_fileSystem.File.Exists(value)) + { + // File exists, read feature IDs from it + var featureIdsFileContent = await _fileSystem.File.ReadAllTextAsync(value, ctx); + var featureIdsFromFile = featureIdsFileContent + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(f => !string.IsNullOrWhiteSpace(f)) + .ToArray(); + + foreach (var featureId in featureIdsFromFile) + { + _ = featureIdsToHide.Add(featureId); + } + } + else + { + // Check if it looks like a file path + var looksLikeFilePath = value.Contains(_fileSystem.Path.DirectorySeparatorChar) || + value.Contains(_fileSystem.Path.AltDirectorySeparatorChar) || + _fileSystem.Path.HasExtension(value); + + if (looksLikeFilePath) + { + // Track non-existent files to check later + nonExistentFiles.Add(value); + } + else + { + // Doesn't look like a file path, treat as feature ID + _ = featureIdsToHide.Add(value); + } + } + } + + // Report errors for non-existent files + if (nonExistentFiles.Count > 0) + { + foreach (var filePath in nonExistentFiles) + { + collector.EmitError(filePath, $"File does not exist: {filePath}"); + } + return false; + } + } + } + + // Track hidden entries for warnings + var hiddenEntries = new List<(string title, string featureId)>(); + foreach (var (entry, _) in allResolvedEntries) + { + if (!string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId)) + { + hiddenEntries.Add((entry.Title ?? "Unknown", entry.FeatureId)); + } + } + + // Emit warnings for hidden entries + if (hiddenEntries.Count > 0) + { + foreach (var (entryTitle, featureId) in hiddenEntries) + { + collector.EmitWarning(string.Empty, $"Changelog entry '{entryTitle}' with feature-id '{featureId}' will be commented out in markdown output"); + } + } + // Render markdown files (use first repo found, or default) var repoForRendering = allResolvedEntries.Count > 0 ? allResolvedEntries[0].repo : defaultRepo; // Render index.md (features, enhancements, bug fixes, security) - await RenderIndexMarkdown(collector, outputDir, title, titleSlug, repoForRendering, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, input.HidePrivateLinks, ctx); + await RenderIndexMarkdown(collector, outputDir, title, titleSlug, repoForRendering, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, input.HidePrivateLinks, featureIdsToHide, ctx); // Render breaking-changes.md - await RenderBreakingChangesMarkdown(collector, outputDir, title, titleSlug, repoForRendering, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, input.HidePrivateLinks, ctx); + await RenderBreakingChangesMarkdown(collector, outputDir, title, titleSlug, repoForRendering, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, input.HidePrivateLinks, featureIdsToHide, ctx); // Render deprecations.md - await RenderDeprecationsMarkdown(collector, outputDir, title, titleSlug, repoForRendering, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, input.HidePrivateLinks, ctx); + await RenderDeprecationsMarkdown(collector, outputDir, title, titleSlug, repoForRendering, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, input.HidePrivateLinks, featureIdsToHide, ctx); _logger.LogInformation("Rendered changelog markdown files to {OutputDir}", outputDir); @@ -1501,6 +1615,7 @@ private async Task RenderIndexMarkdown( Dictionary> entriesByType, bool subsections, bool hidePrivateLinks, + HashSet featureIdsToHide, Cancel ctx ) { @@ -1548,7 +1663,7 @@ Cancel ctx { sb.AppendLine(CultureInfo.InvariantCulture, $"### Features and enhancements [{repo}-{titleSlug}-features-enhancements]"); var combined = features.Concat(enhancements).ToList(); - RenderEntriesByArea(sb, combined, repo, subsections, hidePrivateLinks); + RenderEntriesByArea(sb, combined, repo, subsections, hidePrivateLinks, featureIdsToHide); } if (security.Count > 0 || bugFixes.Count > 0) @@ -1556,7 +1671,7 @@ Cancel ctx sb.AppendLine(); sb.AppendLine(CultureInfo.InvariantCulture, $"### Fixes [{repo}-{titleSlug}-fixes]"); var combined = security.Concat(bugFixes).ToList(); - RenderEntriesByArea(sb, combined, repo, subsections, hidePrivateLinks); + RenderEntriesByArea(sb, combined, repo, subsections, hidePrivateLinks, featureIdsToHide); } } else @@ -1586,6 +1701,7 @@ private async Task RenderBreakingChangesMarkdown( Dictionary> entriesByType, bool subsections, bool hidePrivateLinks, + HashSet featureIdsToHide, Cancel ctx ) { @@ -1608,7 +1724,13 @@ Cancel ctx foreach (var entry in areaGroup) { + var shouldHide = !string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId); + sb.AppendLine(); + if (shouldHide) + { + sb.AppendLine(""); + } } } } @@ -1698,6 +1824,7 @@ private async Task RenderDeprecationsMarkdown( Dictionary> entriesByType, bool subsections, bool hidePrivateLinks, + HashSet featureIdsToHide, Cancel ctx ) { @@ -1720,7 +1847,13 @@ Cancel ctx foreach (var entry in areaGroup) { + var shouldHide = !string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId); + sb.AppendLine(); + if (shouldHide) + { + sb.AppendLine(""); + } } } } @@ -1799,7 +1936,7 @@ Cancel ctx } [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0058:Expression value is never used", Justification = "StringBuilder methods return builder for chaining")] - private void RenderEntriesByArea(StringBuilder sb, List entries, string repo, bool subsections, bool hidePrivateLinks) + private void RenderEntriesByArea(StringBuilder sb, List entries, string repo, bool subsections, bool hidePrivateLinks, HashSet featureIdsToHide) { var groupedByArea = entries.GroupBy(e => GetComponent(e)).ToList(); foreach (var areaGroup in groupedByArea) @@ -1813,6 +1950,12 @@ private void RenderEntriesByArea(StringBuilder sb, List entries, foreach (var entry in areaGroup) { + var shouldHide = !string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId); + + if (shouldHide) + { + sb.Append("% "); + } sb.Append("* "); sb.Append(Beautify(entry.Title)); @@ -1823,6 +1966,10 @@ private void RenderEntriesByArea(StringBuilder sb, List entries, if (!string.IsNullOrWhiteSpace(entry.Pr)) { sb.AppendLine(); + if (shouldHide) + { + sb.Append("% "); + } sb.Append(" "); sb.Append(FormatPrLink(entry.Pr, repo, hidePrivateLinks)); hasCommentedLinks = true; @@ -1833,6 +1980,10 @@ private void RenderEntriesByArea(StringBuilder sb, List entries, foreach (var issue in entry.Issues) { sb.AppendLine(); + if (shouldHide) + { + sb.Append("% "); + } sb.Append(" "); sb.Append(FormatIssueLink(issue, repo, hidePrivateLinks)); hasCommentedLinks = true; @@ -1877,7 +2028,20 @@ private void RenderEntriesByArea(StringBuilder sb, List entries, sb.AppendLine(); } var indented = Indent(entry.Description); - sb.AppendLine(indented); + if (shouldHide) + { + // Comment out each line of the description + var indentedLines = indented.Split('\n'); + foreach (var line in indentedLines) + { + sb.Append("% "); + sb.AppendLine(line); + } + } + else + { + sb.AppendLine(indented); + } } else { diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index b6bf11aa9..c50f1f0e0 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -181,7 +181,8 @@ async static (s, collector, state, ctx) => await s.BundleChangelogs(collector, s /// Optional: Output directory for rendered markdown files. Defaults to current directory /// Optional: Title to use for section headers in output markdown files. Defaults to version from first bundle /// Optional: Group entries by area/component in subsections. Defaults to false - /// Optional: Hide private links by commenting them out in markdown output. Defaults to false + /// Optional: Hide private links by commenting them out in the markdown output. Defaults to false + /// Filter by feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. Entries with matching feature-id values will be commented out in the markdown output. /// [Command("render")] public async Task Render( @@ -190,6 +191,7 @@ public async Task Render( string? title = null, bool subsections = false, bool hidePrivateLinks = false, + string[]? hideFeatures = null, Cancel ctx = default ) { @@ -197,13 +199,39 @@ public async Task Render( var service = new ChangelogService(logFactory, configurationContext, null); + // Process each --hide-features occurrence: each can be comma-separated feature IDs or a file path + var allFeatureIds = new List(); + if (hideFeatures is { Length: > 0 }) + { + foreach (var hideFeaturesValue in hideFeatures) + { + if (string.IsNullOrWhiteSpace(hideFeaturesValue)) + continue; + + // Check if it contains commas - if so, split and add each as a feature ID + if (hideFeaturesValue.Contains(',')) + { + var commaSeparatedFeatureIds = hideFeaturesValue + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(f => !string.IsNullOrWhiteSpace(f)); + allFeatureIds.AddRange(commaSeparatedFeatureIds); + } + else + { + // Single value - pass as-is (will be handled by service layer as file path or feature ID) + allFeatureIds.Add(hideFeaturesValue); + } + } + } + var renderInput = new ChangelogRenderInput { Bundles = input ?? [], Output = output, Title = title, Subsections = subsections, - HidePrivateLinks = hidePrivateLinks + HidePrivateLinks = hidePrivateLinks, + HideFeatures = allFeatureIds.Count > 0 ? allFeatureIds.ToArray() : null }; serviceInvoker.AddCommand(service, renderInput, diff --git a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs index 2b0f22464..7257f1fee 100644 --- a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs +++ b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs @@ -2466,6 +2466,448 @@ public async Task RenderChangelogs_WithTitleAndNoTargets_NoWarning() d.Message.Contains("No --title option provided")); } + [Fact] + public async Task RenderChangelogs_WithHideFeatures_CommentsOutMatchingEntries() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create changelog with feature-id + var changelog1 = """ + title: Hidden feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + feature_id: feature:hidden-api + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This feature should be hidden + """; + + // Create changelog without feature-id (should not be hidden) + var changelog2 = """ + title: Visible feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + pr: https://github.com/elastic/elasticsearch/pull/101 + description: This feature should be visible + """; + + var changelogFile1 = fileSystem.Path.Combine(changelogDir, "1755268130-hidden.yaml"); + var changelogFile2 = fileSystem.Path.Combine(changelogDir, "1755268140-visible.yaml"); + await fileSystem.File.WriteAllTextAsync(changelogFile1, changelog1, TestContext.Current.CancellationToken); + await fileSystem.File.WriteAllTextAsync(changelogFile2, changelog2, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = fileSystem.Path.Combine(bundleDir, "bundle.yaml"); + var bundleContent = $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: 1755268130-hidden.yaml + checksum: {ComputeSha1(changelog1)} + - file: + name: 1755268140-visible.yaml + checksum: {ComputeSha1(changelog2)} + """; + await fileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new ChangelogRenderInput + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0", + HideFeatures = ["feature:hidden-api"] + }; + + // Act + var result = await service.RenderChangelogs(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + _collector.Warnings.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Warning && + d.Message.Contains("Hidden feature") && + d.Message.Contains("feature:hidden-api") && + d.Message.Contains("will be commented out")); + + var indexFile = fileSystem.Path.Combine(outputDir, "9.2.0", "index.md"); + fileSystem.File.Exists(indexFile).Should().BeTrue(); + + var indexContent = await fileSystem.File.ReadAllTextAsync(indexFile, TestContext.Current.CancellationToken); + // Hidden entry should be commented out with % prefix + indexContent.Should().Contain("% * Hidden feature"); + // Visible entry should not be commented + indexContent.Should().Contain("* Visible feature"); + indexContent.Should().NotContain("% * Visible feature"); + } + + [Fact] + public async Task RenderChangelogs_WithHideFeatures_BreakingChange_UsesBlockComments() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + var changelog = """ + title: Hidden breaking change + type: breaking-change + products: + - product: elasticsearch + target: 9.2.0 + feature_id: feature:hidden-breaking + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This breaking change should be hidden + impact: Users will be affected + action: Update your code + """; + + var changelogFile = fileSystem.Path.Combine(changelogDir, "1755268130-breaking.yaml"); + await fileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); + + var bundleDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = fileSystem.Path.Combine(bundleDir, "bundle.yaml"); + var bundleContent = $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: 1755268130-breaking.yaml + checksum: {ComputeSha1(changelog)} + """; + await fileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new ChangelogRenderInput + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0", + HideFeatures = ["feature:hidden-breaking"] + }; + + // Act + var result = await service.RenderChangelogs(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + var breakingFile = fileSystem.Path.Combine(outputDir, "9.2.0", "breaking-changes.md"); + fileSystem.File.Exists(breakingFile).Should().BeTrue(); + + var breakingContent = await fileSystem.File.ReadAllTextAsync(breakingFile, TestContext.Current.CancellationToken); + // Should use block comments + breakingContent.Should().Contain(""); + breakingContent.Should().Contain("Hidden breaking change"); + // Entry should be between comment markers + var commentStart = breakingContent.IndexOf("", StringComparison.Ordinal); + commentStart.Should().BeLessThan(commentEnd); + breakingContent.Substring(commentStart, commentEnd - commentStart).Should().Contain("Hidden breaking change"); + } + + [Fact] + public async Task RenderChangelogs_WithHideFeatures_Deprecation_UsesBlockComments() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + var changelog = """ + title: Hidden deprecation + type: deprecation + products: + - product: elasticsearch + target: 9.2.0 + feature_id: feature:hidden-deprecation + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This deprecation should be hidden + """; + + var changelogFile = fileSystem.Path.Combine(changelogDir, "1755268130-deprecation.yaml"); + await fileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); + + var bundleDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = fileSystem.Path.Combine(bundleDir, "bundle.yaml"); + var bundleContent = $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: 1755268130-deprecation.yaml + checksum: {ComputeSha1(changelog)} + """; + await fileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new ChangelogRenderInput + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0", + HideFeatures = ["feature:hidden-deprecation"] + }; + + // Act + var result = await service.RenderChangelogs(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + var deprecationsFile = fileSystem.Path.Combine(outputDir, "9.2.0", "deprecations.md"); + fileSystem.File.Exists(deprecationsFile).Should().BeTrue(); + + var deprecationsContent = await fileSystem.File.ReadAllTextAsync(deprecationsFile, TestContext.Current.CancellationToken); + // Should use block comments + deprecationsContent.Should().Contain(""); + deprecationsContent.Should().Contain("Hidden deprecation"); + } + + [Fact] + public async Task RenderChangelogs_WithHideFeatures_CommaSeparated_CommentsOutMatchingEntries() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + var changelog1 = """ + title: First hidden feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + feature_id: feature:first + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + + var changelog2 = """ + title: Second hidden feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + feature_id: feature:second + pr: https://github.com/elastic/elasticsearch/pull/101 + """; + + var changelog3 = """ + title: Visible feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + pr: https://github.com/elastic/elasticsearch/pull/102 + """; + + var changelogFile1 = fileSystem.Path.Combine(changelogDir, "1755268130-first.yaml"); + var changelogFile2 = fileSystem.Path.Combine(changelogDir, "1755268140-second.yaml"); + var changelogFile3 = fileSystem.Path.Combine(changelogDir, "1755268150-visible.yaml"); + await fileSystem.File.WriteAllTextAsync(changelogFile1, changelog1, TestContext.Current.CancellationToken); + await fileSystem.File.WriteAllTextAsync(changelogFile2, changelog2, TestContext.Current.CancellationToken); + await fileSystem.File.WriteAllTextAsync(changelogFile3, changelog3, TestContext.Current.CancellationToken); + + var bundleDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = fileSystem.Path.Combine(bundleDir, "bundle.yaml"); + var bundleContent = $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: 1755268130-first.yaml + checksum: {ComputeSha1(changelog1)} + - file: + name: 1755268140-second.yaml + checksum: {ComputeSha1(changelog2)} + - file: + name: 1755268150-visible.yaml + checksum: {ComputeSha1(changelog3)} + """; + await fileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new ChangelogRenderInput + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0", + HideFeatures = ["feature:first", "feature:second"] + }; + + // Act + var result = await service.RenderChangelogs(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + var indexFile = fileSystem.Path.Combine(outputDir, "9.2.0", "index.md"); + var indexContent = await fileSystem.File.ReadAllTextAsync(indexFile, TestContext.Current.CancellationToken); + indexContent.Should().Contain("% * First hidden feature"); + indexContent.Should().Contain("% * Second hidden feature"); + indexContent.Should().Contain("* Visible feature"); + indexContent.Should().NotContain("% * Visible feature"); + } + + [Fact] + public async Task RenderChangelogs_WithHideFeatures_FromFile_CommentsOutMatchingEntries() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + var changelog = """ + title: Hidden feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + feature_id: feature:from-file + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + + var changelogFile = fileSystem.Path.Combine(changelogDir, "1755268130-hidden.yaml"); + await fileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); + + var bundleDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = fileSystem.Path.Combine(bundleDir, "bundle.yaml"); + var bundleContent = $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: 1755268130-hidden.yaml + checksum: {ComputeSha1(changelog)} + """; + await fileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + // Create feature IDs file + var featureIdsFile = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "feature-ids.txt"); + fileSystem.Directory.CreateDirectory(fileSystem.Path.GetDirectoryName(featureIdsFile)!); + await fileSystem.File.WriteAllTextAsync(featureIdsFile, "feature:from-file\nfeature:another", TestContext.Current.CancellationToken); + + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new ChangelogRenderInput + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0", + HideFeatures = [featureIdsFile] + }; + + // Act + var result = await service.RenderChangelogs(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + var indexFile = fileSystem.Path.Combine(outputDir, "9.2.0", "index.md"); + var indexContent = await fileSystem.File.ReadAllTextAsync(indexFile, TestContext.Current.CancellationToken); + indexContent.Should().Contain("% * Hidden feature"); + } + + [Fact] + public async Task RenderChangelogs_WithHideFeatures_CaseInsensitive_MatchesFeatureIds() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + var changelog = """ + title: Hidden feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + feature_id: Feature:UpperCase + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + + var changelogFile = fileSystem.Path.Combine(changelogDir, "1755268130-hidden.yaml"); + await fileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); + + var bundleDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = fileSystem.Path.Combine(bundleDir, "bundle.yaml"); + var bundleContent = $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: 1755268130-hidden.yaml + checksum: {ComputeSha1(changelog)} + """; + await fileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new ChangelogRenderInput + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0", + HideFeatures = ["feature:uppercase"] // Different case + }; + + // Act + var result = await service.RenderChangelogs(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + var indexFile = fileSystem.Path.Combine(outputDir, "9.2.0", "index.md"); + var indexContent = await fileSystem.File.ReadAllTextAsync(indexFile, TestContext.Current.CancellationToken); + // Should match case-insensitively + indexContent.Should().Contain("% * Hidden feature"); + } + private static string ComputeSha1(string content) { var bytes = System.Text.Encoding.UTF8.GetBytes(content);