diff --git a/config/changelog.example.yml b/config/changelog.example.yml index fafd3c6b3..02463e4e9 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -124,3 +124,7 @@ bundle: # serverless-release: # products: "cloud-serverless {version} *" # output: "serverless-{version}.yaml" + # # Feature IDs to hide when bundling with this profile + # hide_features: + # - feature-flag-1 + # - feature-flag-2 diff --git a/docs/cli/release/changelog-bundle.md b/docs/cli/release/changelog-bundle.md index a6bd09faa..0daa666d6 100644 --- a/docs/cli/release/changelog-bundle.md +++ b/docs/cli/release/changelog-bundle.md @@ -8,19 +8,42 @@ For details and examples, go to [](/contribute/changelog.md). ## Usage ```sh -docs-builder changelog bundle [options...] [-h|--help] +docs-builder changelog bundle [arguments...] [options...] [-h|--help] ``` +## Arguments + +You can use either profile-based bundling (for example, `bundle elasticsearch-release 9.2.0`) or raw flags (`bundle --all`). +These arguments apply to profile-based bundling: + +`[0] ` +: Profile name from `bundle.profiles` in the changelog configuration file. +: For example, "elasticsearch-release". +: When it's specified, the second argument is the version or promotion report URL. + +`[1] ` +: Version number or promotion report URL or path. +: For example, "9.2.0" or "https://buildkite.../promotion-report.html". + ## Options `--all` : Include all changelogs from the directory. : Only one filter option can be specified: `--all`, `--input-products`, or `--prs`. +`--config ` +: Optional: Path to the changelog.yml configuration file. +: Defaults to `docs/changelog.yml`. + `--directory ` : Optional: The directory that contains the changelog YAML files. : Defaults to the current directory. +`--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. +: Entries with matching `feature-id` values will be commented out when the bundle is rendered (by the `changelog render` command or `{changelog}` directive). + `--input-products ?>` : Filter by products in format "product target lifecycle, ..." : Only one filter option can be specified: `--all`, `--input-products`, or `--prs`. @@ -32,6 +55,9 @@ docs-builder changelog bundle [options...] [-h|--help] - `"* 9.3.* *"` - match any product with target starting with "9.3." - `"* * *"` - match all changelogs (equivalent to `--all`) +`--no-resolve` +: Optional: Explicitly turn off the `resolve` option if it's specified in the changelog configuration file. + `--output ` : Optional: The output path for the bundle. : Can be either (1) a directory path, in which case `changelog-bundle.yaml` is created in that directory, or (2) a file path ending in `.yml` or `.yaml`. diff --git a/docs/cli/release/changelog-render.md b/docs/cli/release/changelog-render.md index 833b2b33c..b6a270147 100644 --- a/docs/cli/release/changelog-render.md +++ b/docs/cli/release/changelog-render.md @@ -16,7 +16,7 @@ docs-builder changelog render [options...] [-h|--help] `--config ` : Optional: Path to the changelog.yml configuration file. : Defaults to `docs/changelog.yml`. -: This configuration file is where the command looks `block ... publish` definitions. +: This configuration file is where the command looks for `block ... publish` definitions. `--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. @@ -24,6 +24,7 @@ docs-builder changelog render [options...] [-h|--help] : 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 output and a warning will be emitted. +: If the bundle contains `hide-features` values (that is to say, it was created with the `--hide-features` option), those values are merged with this list and are also hidden. `--input ` : One or more bundle input files. diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index d0eda3d10..9b233047a 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -353,12 +353,13 @@ Bundle changelogs Options: --all Include all changelogs in the directory. Only one filter option can be specified: `--all`, `--input-products`, or `--prs`. --directory Optional: Directory containing changelog YAML files. Defaults to current directory [Default: null] + --hide-features Optional: Feature IDs to mark as hidden in the bundle output (comma-separated), or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. When the bundle is rendered (by CLI render or {changelog} directive), entries with matching feature-id values will be commented out. [Default: null] --input-products ?> Filter by products in format "product target lifecycle, ..." (e.g., "cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta"). When specified, all three parts (product, target, lifecycle) are required but can be wildcards (*). Examples: "elasticsearch * *" matches all elasticsearch changelogs, "cloud-serverless 2025-12-02 *" matches cloud-serverless 2025-12-02 with any lifecycle, "* 9.3.* *" matches any product with target starting with "9.3.", "* * *" matches all changelogs (equivalent to --all). Only one filter option can be specified: `--all`, `--input-products`, or `--prs`. [Default: null] --output Optional: Output path for the bundled changelog. Can be either (1) a directory path, in which case 'changelog-bundle.yaml' is created in that directory, or (2) a file path ending in .yml or .yaml. Defaults to 'changelog-bundle.yaml' in the input directory [Default: null] --output-products ?> Optional: Explicitly set the products array in the output file in format "product target lifecycle, ...". Overrides any values from changelogs. [Default: null] --owner GitHub repository owner (required only when PRs are specified as numbers) [Default: null] --prs Filter by pull request URLs or numbers (comma-separated), or a path to a newline-delimited file containing PR URLs or numbers. Can be specified multiple times. Only one filter option can be specified: `--all`, `--input-products`, or `--prs`. [Default: null] - --repo GitHub repository name (required only when PRs are specified as numbers) [Default: null] + --repo GitHub repository name. When specified, this value is stored in the bundle's product metadata and used to generate correct PR/issue links during rendering. Required when PRs are specified as numbers. [Default: null] --resolve Optional: Copy the contents of each changelog file into the entries array. By default, the bundle contains only the file names and checksums. ``` @@ -538,6 +539,96 @@ The `--output` option supports two formats: If you specify a file path with a different extension (not `.yml` or `.yaml`), the command returns an error. +### Hide features in bundles [changelog-bundle-hide-features] + +You can use the `--hide-features` option to embed feature IDs that should be hidden when the bundle is rendered. This is useful for features that are not yet ready for public documentation. + +```sh +docs-builder changelog bundle \ + --input-products "elasticsearch 9.3.0 *" \ + --hide-features "feature:hidden-api,feature:experimental" \ <1> + --output /path/to/bundles/9.3.0.yaml +``` + +1. Feature IDs to hide. Entries with matching `feature-id` values will be commented out when rendered. + +The bundle output will include a `hide-features` field: + +```yaml +products: +- product: elasticsearch + target: 9.3.0 +hide-features: + - feature:hidden-api + - feature:experimental +entries: +- file: + name: 1765495972-new-feature.yaml + checksum: 6c3243f56279b1797b5dfff6c02ebf90b9658464 +``` + +When this bundle is rendered (either via the `changelog render` command or the `{changelog}` directive), entries with `feature-id` values matching any of the listed features will be commented out in the output. + +:::{note} +The `--hide-features` option on the `render` command and the `hide-features` field in bundles are **combined**. If you specify `--hide-features` on both the `bundle` and `render` commands, all specified features are hidden. The `{changelog}` directive automatically reads `hide-features` from all loaded bundles and applies them. +::: + +### Repository name in bundles [changelog-bundle-repo] + +When you specify the `--repo` option, the repository name is stored in the bundle's product metadata. This ensures that PR and issue links are generated correctly when the bundle is rendered. + +```sh +docs-builder changelog bundle \ + --input-products "cloud-serverless 2025-12-02 *" \ + --repo cloud \ <1> + --output /path/to/bundles/2025-12-02.yaml +``` + +1. The GitHub repository name. This is stored in each product entry in the bundle. + +The bundle output will include a `repo` field in each product: + +```yaml +products: +- product: cloud-serverless + target: 2025-12-02 + repo: cloud +entries: +- file: + name: 1765495972-new-feature.yaml + checksum: 6c3243f56279b1797b5dfff6c02ebf90b9658464 +``` + +When rendering, PR/issue links will use `https://github.com/elastic/cloud/...` instead of the product ID in the URL. + +:::{note} +If the `repo` field is not specified, the product ID is used as a fallback for link generation. This may result in broken links if the product ID doesn't match the GitHub repository name (e.g., `cloud-serverless` vs `cloud`). +::: + +### Amend bundles [changelog-bundle-amend] + +When you need to add entries to an existing bundle without modifying the original file, you can create amend bundles. Amend bundles follow a specific naming convention: `{parent-bundle-name}.amend-{N}.yaml` where `{N}` is a sequence number. + +For example, if you have a bundle named `9.3.0.yaml`, you can create amend files: +- `9.3.0.amend-1.yaml` +- `9.3.0.amend-2.yaml` + +Amend bundles contain only the additional entries: + +```yaml +# 9.3.0.amend-1.yaml +entries: +- file: + name: late-addition.yaml + checksum: abc123def456 +``` + +When bundles are loaded (either via the `changelog render` command or the `{changelog}` directive), amend files are **automatically merged** with their parent bundles. The entries from all matching amend files are combined with the parent bundle's entries, and the result is rendered as a single release. + +:::{note} +Amend bundles do not need to include `products` or `hide-features` fields—they inherit these from their parent bundle. If an amend bundle is found without a matching parent bundle, it remains standalone. +::: + ## Create documentation ### Include changelogs inline [changelog-directive] @@ -626,8 +717,7 @@ 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, add `hide-links` to the `--input` option for that bundle. This allows you to selectively hide links per bundle when merging changelogs from multiple repositories. -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). +If you have changelogs with `feature-id` values and you want them to be omitted from the output, use the `--hide-features` option. Feature IDs specified via `--hide-features` are **merged** with any `hide-features` already present in the bundle files. This means both CLI-specified and bundle-embedded features are hidden in the output. To create an asciidoc file instead of markdown files, add the `--file-type asciidoc` option: diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index bb37163ff..f262a6da6 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -25,6 +25,7 @@ The directive supports the following options: | `:type: value` | Filter entries by type | Excludes separated types | | `:subsections:` | Group entries by area/component | false | | `:config: path` | Path to changelog.yml configuration | auto-discover | +| `:product: id` | Product ID for product-specific publish blockers | auto from docset | ### Example with options @@ -32,6 +33,7 @@ The directive supports the following options: :::{changelog} /path/to/bundles :type: all :subsections: +:product: kibana ::: ``` @@ -102,9 +104,65 @@ Explicit path to a `changelog.yml` configuration file. If not specified, the dir The configuration can include publish blockers to filter entries by type or area. +#### `:product:` + +Product ID for loading product-specific publish blockers from `changelog.yml`. The directive resolves the product ID in this order: + +1. **Explicit `:product:` option** - if specified, uses that product ID +2. **Docset's single product** - if the docset has exactly one product configured in `docset.yml`, uses that product ID automatically +3. **Global fallback** - uses the global `block.publish` configuration + +This automatic fallback means most single-product docsets don't need to specify `:product:` explicitly - the directive will automatically use the docset's product for publish blocker lookup. + +**Example docset with single product:** + +```yaml +# docset.yml +products: + - id: kibana +toc: + - file: release-notes.md +``` + +```yaml +# changelog.yml +block: + product: + kibana: + publish: + types: + - docs + areas: + - "Elastic Observability solution" + - "Elastic Security solution" +``` + +With this configuration, the directive will automatically use the `kibana` product blockers: + +```markdown +:::{changelog} +::: +``` + +**Explicit override:** + +You can override the automatic product detection by specifying `:product:` explicitly: + +```markdown +:::{changelog} +:product: elasticsearch +::: +``` + +This is useful when: +- The docset has multiple products and you want a specific one +- You want to use a different product's blockers than the docset default + +The product ID matching is case-insensitive. + ## Filtering entries with publish blockers -You can filter changelog entries from the rendered output using the `block.publish` configuration in your `changelog.yml` file. This is useful for hiding entries that shouldn't appear in public documentation, such as internal changes or documentation-only updates. +You can filter changelog entries from the rendered output using the `block.publish` or `block.product.{productId}.publish` configuration in your `changelog.yml` file. This is useful for hiding entries that shouldn't appear in public documentation, such as internal changes or documentation-only updates. ### Configuration syntax @@ -112,6 +170,7 @@ Create a `changelog.yml` file in your docset root (or `docs/changelog.yml`): ```yaml block: + # Global publish blocker (applies to all products) publish: types: - docs # Hide documentation entries @@ -119,6 +178,30 @@ block: areas: - Internal # Hide entries with "Internal" area - Experimental # Hide entries with "Experimental" area + + # Product-specific blockers (override global blockers) + product: + kibana: + publish: + types: + - docs + areas: + - "Elastic Observability solution" + - "Elastic Security solution" + cloud-serverless: + publish: + types: + - docs + areas: + - "Snapshot and restore" +``` + +Product-specific blockers are applied automatically when your docset has a single product configured. For docsets with multiple products or to override the automatic detection, specify the `:product:` option: + +```markdown +:::{changelog} +:product: kibana +::: ``` ### Filtering by type @@ -195,6 +278,28 @@ block: - known-issue # Known issues shown on dedicated page ``` +## Feature hiding from bundles + +When bundles contain a `hide-features` field, entries with matching `feature-id` values are automatically filtered out from the rendered output. This allows you to hide unreleased or experimental features without modifying the bundle at render time. + +```yaml +# Example bundle with hide-features +products: + - product: elasticsearch + target: 9.3.0 +hide-features: + - feature:hidden-api + - feature:experimental +entries: + - file: + name: new-feature.yaml + checksum: abc123 +``` + +When the directive loads multiple bundles, `hide-features` from **all bundles are aggregated** and applied to all entries. This means if bundle A hides `feature:x` and bundle B hides `feature:y`, both features are hidden in the combined output. + +To add `hide-features` to a bundle, use the `--hide-features` option when running `changelog bundle`. For more details, see [Hide features in bundles](../contribute/changelog.md#changelog-bundle-hide-features). + ## Private repository link hiding PR and issue links are automatically hidden (commented out) for bundles from private repositories. This is determined by checking the `assembler.yml` configuration: @@ -207,6 +312,21 @@ PR and issue links are automatically hidden (commented out) for bundles from pri Bundles with the same target version/date are automatically merged into a single section. This is useful for Cloud Serverless releases where multiple repositories (e.g., Elasticsearch, Kibana) contribute to a single dated release like `2025-08-05`. +### Amend bundle merging + +Bundles can have associated **amend files** that follow the naming pattern `{bundle-name}.amend-{N}.yaml` (e.g., `9.3.0.amend-1.yaml`). When loading bundles, the directive automatically discovers and merges amend files with their parent bundles. + +This allows you to add late additions to a release without modifying the original bundle file: + +``` +bundles/ +├── 9.3.0.yaml # Parent bundle +├── 9.3.0.amend-1.yaml # First amend (auto-merged with parent) +└── 9.3.0.amend-2.yaml # Second amend (auto-merged with parent) +``` + +All entries from the parent and amend bundles are rendered together as a single release section. The parent bundle's metadata (products, hide-features, repo) is preserved. + ## Default folder structure The directive expects bundles in `changelog/bundles/` relative to the docset root: diff --git a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs index 3560a74db..f51f2722c 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs @@ -56,4 +56,10 @@ public record BundleProfile /// - "serverless-{version}.yaml" /// public string? Output { get; init; } + + /// + /// Feature IDs to mark as hidden in the bundle output. + /// When the bundle is rendered, entries with matching feature-id values will be commented out. + /// + public IReadOnlyList? HideFeatures { get; init; } } diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs index 53fa62542..0483fcb15 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs @@ -13,6 +13,12 @@ namespace Elastic.Documentation.Configuration.ReleaseNotes; public sealed record BundleDto { public List? Products { get; set; } + /// + /// Feature IDs that should be hidden when rendering this bundle. + /// Entries with matching feature-id values will be commented out in the output. + /// + [YamlMember(Alias = "hide-features", ApplyNamingConventions = false)] + public List? HideFeatures { get; set; } public List? Entries { get; set; } } @@ -24,6 +30,11 @@ public sealed record BundledProductDto public string? Product { get; set; } public string? Target { get; set; } public string? Lifecycle { get; set; } + /// + /// GitHub repository name for generating PR/issue links. + /// If not specified, falls back to Product ID. + /// + public string? Repo { get; set; } } /// diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs index 4bb4b524e..67e1aba7e 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using System.Text.RegularExpressions; using Elastic.Documentation.ReleaseNotes; using YamlDotNet.Core; @@ -11,7 +12,7 @@ namespace Elastic.Documentation.Configuration.ReleaseNotes; /// /// Service for loading, resolving, filtering, and merging changelog bundles. /// -public class BundleLoader(IFileSystem fileSystem) +public partial class BundleLoader(IFileSystem fileSystem) { /// /// Loads all changelog bundles from a folder. @@ -37,9 +38,7 @@ public IReadOnlyList LoadBundles( continue; var version = GetVersionFromBundle(bundleData) ?? fileSystem.Path.GetFileNameWithoutExtension(bundleFile); - var repo = bundleData.Products.Count > 0 - ? bundleData.Products[0].ProductId - : "elastic"; + var repo = GetRepoFromBundle(bundleData); // Bundle directory is the directory containing the bundle file var bundleDirectory = fileSystem.Path.GetDirectoryName(bundleFile) ?? bundlesFolderPath; @@ -51,6 +50,9 @@ public IReadOnlyList LoadBundles( loadedBundles.Add(new LoadedBundle(version, repo, bundleData, bundleFile, entries)); } + // Merge amend files with their parent bundles + loadedBundles = MergeAmendFiles(loadedBundles); + return loadedBundles; } @@ -164,6 +166,22 @@ public IReadOnlyList MergeBundlesByTarget(IReadOnlyList bundledData.Products.Count > 0 ? bundledData.Products[0].Target : null; + /// + /// Gets the repository name from a bundle's first product. + /// Uses the explicit Repo field if set, otherwise falls back to ProductId. + /// + private static string GetRepoFromBundle(Bundle bundledData) + { + if (bundledData.Products.Count == 0) + return "elastic"; + + var firstProduct = bundledData.Products[0]; + // Use explicit Repo if provided, otherwise fall back to ProductId + return !string.IsNullOrWhiteSpace(firstProduct.Repo) + ? firstProduct.Repo + : firstProduct.ProductId; + } + /// /// Merges a group of bundles with the same target version into a single bundle. /// @@ -192,4 +210,100 @@ private static LoadedBundle MergeBundleGroup(IGrouping gro ); } + /// + /// Merges amend files with their parent bundles. + /// Amend files follow the naming pattern: {baseName}.amend-{N}.yaml + /// + /// The list of loaded bundles including amend files. + /// A list of bundles with amend file entries merged into their parent bundles. + private List MergeAmendFiles(List bundles) + { + if (bundles.Count <= 1) + return bundles; + + // Build a lookup of bundles by their file path for quick access + var bundlesByPath = bundles.ToDictionary(b => b.FilePath, StringComparer.OrdinalIgnoreCase); + + // Identify amend files and their parent bundles + var amendBundles = bundles.Where(b => IsAmendFile(b.FilePath)).ToList(); + + if (amendBundles.Count == 0) + return bundles; + + // Track which bundles to remove (amend files that were merged) + var mergedAmendPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Track parent bundles with their merged entries + var mergedParents = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var amendBundle in amendBundles) + { + var parentPath = GetParentBundlePath(amendBundle.FilePath); + + if (parentPath == null || !bundlesByPath.TryGetValue(parentPath, out var parentBundle)) + continue; // No parent found, amend file will remain standalone + + // Get or create the merged parent entry + if (!mergedParents.TryGetValue(parentPath, out var mergedParent)) + mergedParent = parentBundle; + + // Merge the amend entries into the parent + var combinedEntries = mergedParent.Entries.Concat(amendBundle.Entries).ToList(); + + mergedParents[parentPath] = new LoadedBundle( + mergedParent.Version, + mergedParent.Repo, + mergedParent.Data, + mergedParent.FilePath, + combinedEntries + ); + + _ = mergedAmendPaths.Add(amendBundle.FilePath); + } + + // Build the final result: replace parent bundles with merged versions, exclude merged amend files + var result = bundles + .Where(bundle => !mergedAmendPaths.Contains(bundle.FilePath)) + .Select(bundle => + mergedParents.TryGetValue(bundle.FilePath, out var mergedBundle) + ? mergedBundle + : bundle) + .ToList(); + + return result; + } + + /// + /// Determines if a file path represents an amend file. + /// + /// The file path to check. + /// True if the file is an amend file. + private static bool IsAmendFile(string filePath) => + AmendFileRegex().IsMatch(filePath); + + /// + /// Gets the parent bundle path from an amend file path. + /// + /// The amend file path. + /// The parent bundle path, or null if not an amend file. + private string? GetParentBundlePath(string amendFilePath) + { + var match = AmendFileRegex().Match(amendFilePath); + if (!match.Success) + return null; + + // Replace the ".amend-N" part with just the extension + var directory = fileSystem.Path.GetDirectoryName(amendFilePath) ?? string.Empty; + var fileName = fileSystem.Path.GetFileName(amendFilePath); + var extension = fileSystem.Path.GetExtension(amendFilePath); + + // Remove the .amend-N part from the filename + var parentFileName = AmendFileRegex().Replace(fileName, extension); + + return fileSystem.Path.Combine(directory, parentFileName); + } + + [GeneratedRegex(@"\.amend-\d+\.ya?ml$", RegexOptions.IgnoreCase)] + private static partial Regex AmendFileRegex(); + } diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs index 7c1d1dc1e..ed9871133 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs @@ -144,6 +144,7 @@ public static string SerializeBundle(Bundle bundle) private static Bundle ToBundle(BundleDto dto) => new() { Products = dto.Products?.Select(ToBundledProduct).ToList() ?? [], + HideFeatures = dto.HideFeatures ?? [], Entries = dto.Entries?.Select(ToBundledEntry).ToList() ?? [] }; @@ -151,7 +152,8 @@ public static string SerializeBundle(Bundle bundle) { ProductId = dto.Product ?? "", Target = dto.Target, - Lifecycle = ParseLifecycle(dto.Lifecycle) + Lifecycle = ParseLifecycle(dto.Lifecycle), + Repo = dto.Repo }; private static BundledEntry ToBundledEntry(BundledEntryDto dto) => new() @@ -245,6 +247,7 @@ private static ChangelogEntryType ParseEntryType(string? value) private static BundleDto ToDto(Bundle bundle) => new() { Products = bundle.Products.Select(ToDto).ToList(), + HideFeatures = bundle.HideFeatures.Count > 0 ? bundle.HideFeatures.ToList() : null, Entries = bundle.Entries.Select(ToDto).ToList() }; @@ -252,7 +255,8 @@ private static ChangelogEntryType ParseEntryType(string? value) { Product = product.ProductId, Target = product.Target, - Lifecycle = LifecycleToString(product.Lifecycle) + Lifecycle = LifecycleToString(product.Lifecycle), + Repo = product.Repo }; private static BundledEntryDto ToDto(BundledEntry entry) => new() @@ -316,8 +320,9 @@ public static string NormalizeYaml(string yaml) /// /// The file system to read from. /// The path to the changelog.yml configuration file. + /// Optional product ID to load product-specific blocker. If specified, checks block.product.{productId}.publish first, then falls back to block.publish. /// The publish blocker configuration, or null if not found or not configured. - public static PublishBlocker? LoadPublishBlocker(IFileSystem fileSystem, string configPath) + public static PublishBlocker? LoadPublishBlocker(IFileSystem fileSystem, string configPath, string? productId = null) { if (!fileSystem.File.Exists(configPath)) return null; @@ -327,9 +332,25 @@ public static string NormalizeYaml(string yaml) return null; var yamlConfig = IgnoreUnmatchedDeserializer.Deserialize(yamlContent); - if (yamlConfig?.Block?.Publish is null) + if (yamlConfig.Block is null) return null; + // Check product-specific blocker first if productId is specified + if (!string.IsNullOrWhiteSpace(productId) && yamlConfig.Block.Product is { Count: > 0 }) + { + // Try exact match first, then fall back to case-insensitive match + if (!yamlConfig.Block.Product.TryGetValue(productId, out var productBlockers)) + { + var found = yamlConfig.Block.Product.FirstOrDefault(kvp => + kvp.Key.Equals(productId, StringComparison.OrdinalIgnoreCase)); + productBlockers = found.Value; + } + + if (productBlockers?.Publish != null) + return ParsePublishBlocker(productBlockers.Publish); + } + + // Fall back to global publish blocker return ParsePublishBlocker(yamlConfig.Block.Publish); } @@ -372,7 +393,20 @@ public sealed class ChangelogConfigMinimalDto /// public sealed class BlockConfigMinimalDto { - /// Publish blocker configuration. + /// Global publish blocker configuration. + public PublishBlockerMinimalDto? Publish { get; set; } + + /// Per-product blocker overrides. + public Dictionary? Product { get; set; } +} + +/// +/// Minimal DTO for product-specific blockers. +/// Registered with YamlStaticContext for source-generated deserialization. +/// +public sealed class ProductBlockersMinimalDto +{ + /// Publish blocker configuration for this product. public PublishBlockerMinimalDto? Publish { get; set; } } diff --git a/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs b/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs index 49587b0fa..c6f58bdb9 100644 --- a/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs +++ b/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs @@ -55,4 +55,5 @@ namespace Elastic.Documentation.Configuration.Serialization; [YamlSerializable(typeof(ChangelogConfigMinimalDto))] [YamlSerializable(typeof(BlockConfigMinimalDto))] [YamlSerializable(typeof(PublishBlockerMinimalDto))] +[YamlSerializable(typeof(ProductBlockersMinimalDto))] public partial class YamlStaticContext; diff --git a/src/Elastic.Documentation/ReleaseNotes/Bundle.cs b/src/Elastic.Documentation/ReleaseNotes/Bundle.cs index aacccb9e4..3f1d3da37 100644 --- a/src/Elastic.Documentation/ReleaseNotes/Bundle.cs +++ b/src/Elastic.Documentation/ReleaseNotes/Bundle.cs @@ -13,6 +13,12 @@ public record Bundle /// Products included in this bundle. public IReadOnlyList Products { get; init; } = []; + /// + /// Feature IDs that should be hidden when rendering this bundle. + /// Entries with matching feature-id values will be commented out in the output. + /// + public IReadOnlyList HideFeatures { get; init; } = []; + /// Changelog entries in this bundle. public IReadOnlyList Entries { get; init; } = []; } diff --git a/src/Elastic.Documentation/ReleaseNotes/BundledProduct.cs b/src/Elastic.Documentation/ReleaseNotes/BundledProduct.cs index 9598cad84..7ed4e984e 100644 --- a/src/Elastic.Documentation/ReleaseNotes/BundledProduct.cs +++ b/src/Elastic.Documentation/ReleaseNotes/BundledProduct.cs @@ -20,11 +20,12 @@ public BundledProduct() { } /// Constructor with all parameters. /// [SetsRequiredMembers] - public BundledProduct(string productId, string? target = null, Lifecycle? lifecycle = null) + public BundledProduct(string productId, string? target = null, Lifecycle? lifecycle = null, string? repo = null) { ProductId = productId; Target = target; Lifecycle = lifecycle; + Repo = repo; } /// The product identifier. @@ -35,4 +36,10 @@ public BundledProduct(string productId, string? target = null, Lifecycle? lifecy /// The lifecycle stage of the feature for this product. public Lifecycle? Lifecycle { get; init; } + + /// + /// GitHub repository name for generating PR/issue links. + /// If not specified, falls back to ProductId. + /// + public string? Repo { get; init; } } diff --git a/src/Elastic.Documentation/ReleaseNotes/LoadedBundle.cs b/src/Elastic.Documentation/ReleaseNotes/LoadedBundle.cs index 2a7c4edb3..1f3fea7d9 100644 --- a/src/Elastic.Documentation/ReleaseNotes/LoadedBundle.cs +++ b/src/Elastic.Documentation/ReleaseNotes/LoadedBundle.cs @@ -26,4 +26,10 @@ public record LoadedBundle( Entries .GroupBy(e => e.Type) .ToDictionary(g => g.Key, g => (IReadOnlyCollection)g.ToList().AsReadOnly()); + + /// + /// Feature IDs that should be hidden when rendering this bundle. + /// Entries with matching feature-id values will be commented out in the output. + /// + public IReadOnlyList HideFeatures => Data.HideFeatures; } diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index 8e5de07d9..e7fa4fc67 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs @@ -106,6 +106,15 @@ public class ChangelogBlock(DirectiveBlockParser parser, ParserContext context) /// public string? ConfigPath { get; private set; } + /// + /// Product ID for product-specific publish blocker configuration. + /// Resolution order: + /// 1. Explicit :product: option if specified + /// 2. Docset's single product ID (when exactly one product is configured) + /// 3. Falls back to global block.publish if no product can be determined + /// + public string? ProductId { get; private set; } + /// /// The loaded publish blocker configuration used to filter entries. /// If null, no publish filtering is applied. @@ -136,6 +145,13 @@ public class ChangelogBlock(DirectiveBlockParser parser, ParserContext context) /// public HashSet PrivateRepositories { get; private set; } = new(StringComparer.OrdinalIgnoreCase); + /// + /// Feature IDs that should be hidden when rendering changelog entries. + /// Combined from all loaded bundles' hide-features fields. + /// Entries with matching feature-id values will be excluded from the output. + /// + public HashSet HideFeatures { get; private set; } = new(StringComparer.OrdinalIgnoreCase); + /// /// Returns all anchors that will be generated by this directive during rendering. /// @@ -151,6 +167,7 @@ public override void FinalizeAndValidate(ParserContext context) ExtractBundlesFolderPath(); Subsections = PropBool("subsections"); ConfigPath = Prop("config"); + ProductId = Prop("product"); TypeFilter = ParseTypeFilter(); LoadConfiguration(); LoadPrivateRepositories(); @@ -252,7 +269,27 @@ private void LoadConfiguration() if (string.IsNullOrWhiteSpace(configPath)) return; - PublishBlocker = ReleaseNotesSerialization.LoadPublishBlocker(fileSystem, configPath); + // Resolve product ID: explicit option > single docset product > null (global fallback) + var resolvedProductId = ResolveProductId(); + + PublishBlocker = ReleaseNotesSerialization.LoadPublishBlocker(fileSystem, configPath, resolvedProductId); + } + + /// + /// Resolves the product ID for publish blocker lookup. + /// Priority: explicit :product: option > single docset product > null. + /// + private string? ResolveProductId() + { + // Use explicit :product: option if specified + if (!string.IsNullOrWhiteSpace(ProductId)) + return ProductId; + + // Fall back to docset's single product if available + var docsetProducts = Context.Configuration.Products; + return docsetProducts.Count == 1 ? docsetProducts.First().Id : + // No product could be determined - will use global blocker + null; } /// @@ -296,6 +333,13 @@ private void LoadAndCacheBundles() // Always merge bundles with the same target version // (e.g., Cloud Serverless with multiple repos contributing to a single dated release) LoadedBundles = loader.MergeBundlesByTarget(sortedBundles); + + // Collect hide-features from all loaded bundles + foreach (var bundle in LoadedBundles) + { + foreach (var featureId in bundle.HideFeatures) + _ = HideFeatures.Add(featureId); + } } private IEnumerable ComputeGeneratedAnchors() @@ -305,10 +349,8 @@ private IEnumerable ComputeGeneratedAnchors() var titleSlug = ChangelogTextUtilities.TitleToSlug(bundle.Version); var repo = bundle.Repo; - // Group entries by type to determine which sections will exist - var entriesByType = bundle.Entries - .GroupBy(e => e.Type) - .ToDictionary(g => g.Key, g => g.Count()); + // Group filtered entries by type to determine which sections will exist + var entriesByType = GetFilteredEntryCounts(bundle); // Apply type filter to determine which sections to include var shouldInclude = CreateTypeFilterPredicate(); @@ -363,6 +405,27 @@ private Func CreateTypeFilterPredicate() => _ => type => !SeparatedTypes.Contains(type) // Default: exclude separated types }; + /// + /// Returns entry counts by type after applying publish blocker and hide-features filters. + /// This ensures the TOC and generated anchors match what the renderer actually outputs. + /// + private Dictionary GetFilteredEntryCounts(LoadedBundle bundle) + { + IEnumerable entries = bundle.Entries; + + // Filter by publish blocker + if (PublishBlocker is { HasBlockingRules: true }) + entries = entries.Where(e => !PublishBlocker.ShouldBlock(e)); + + // Filter by hide-features + if (HideFeatures.Count > 0) + entries = entries.Where(e => string.IsNullOrWhiteSpace(e.FeatureId) || !HideFeatures.Contains(e.FeatureId)); + + return entries + .GroupBy(e => e.Type) + .ToDictionary(g => g.Key, g => g.Count()); + } + private IEnumerable ComputeTableOfContent() { foreach (var bundle in LoadedBundles) @@ -378,10 +441,8 @@ private IEnumerable ComputeTableOfContent() Level = 2 }; - // Group entries by type to determine which sections will exist - var entriesByType = bundle.Entries - .GroupBy(e => e.Type) - .ToDictionary(g => g.Key, g => g.Count()); + // Group filtered entries by type to determine which sections will exist + var entriesByType = GetFilteredEntryCounts(bundle); // Apply type filter to determine which sections to include var shouldInclude = CreateTypeFilterPredicate(); diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs index 4cde10f78..749986cd2 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs @@ -35,6 +35,7 @@ public static class ChangelogInlineRenderer block.Subsections, block.PublishBlocker, block.PrivateRepositories, + block.HideFeatures, typeFilter); _ = sb.Append(bundleMarkdown); @@ -49,6 +50,7 @@ private static string RenderSingleBundle( bool subsections, PublishBlocker? publishBlocker, HashSet privateRepositories, + HashSet hideFeatures, ChangelogTypeFilter typeFilter) { var titleSlug = ChangelogTextUtilities.TitleToSlug(bundle.Version); @@ -56,6 +58,9 @@ private static string RenderSingleBundle( // Filter entries based on publish blocker configuration var filteredEntries = FilterEntries(bundle.Entries, publishBlocker); + // Filter entries based on hide-features (from bundle metadata) + filteredEntries = FilterEntriesByHideFeatures(filteredEntries, hideFeatures); + // Apply type filter filteredEntries = FilterEntriesByType(filteredEntries, typeFilter); @@ -68,7 +73,7 @@ private static string RenderSingleBundle( // contains any private repositories - if so, hide links for this bundle var hideLinks = ShouldHideLinksForRepo(bundle.Repo, privateRepositories); - return GenerateMarkdown(bundle.Version, titleSlug, bundle.Repo, entriesByType, subsections, hideLinks); + return GenerateMarkdown(bundle.Version, titleSlug, bundle.Repo, entriesByType, subsections, hideLinks, typeFilter); } /// @@ -85,6 +90,22 @@ private static IReadOnlyList FilterEntriesByType( _ => entries.Where(e => !ChangelogBlock.SeparatedTypes.Contains(e.Type)).ToList() // Default: exclude separated types }; + /// + /// Filters entries based on hide-features configuration from bundle metadata. + /// Entries with matching feature-id values are excluded from the output. + /// + private static IReadOnlyList FilterEntriesByHideFeatures( + IReadOnlyList entries, + HashSet hideFeatures) + { + if (hideFeatures.Count == 0) + return entries; + + return entries + .Where(e => string.IsNullOrWhiteSpace(e.FeatureId) || !hideFeatures.Contains(e.FeatureId)) + .ToList(); + } + /// /// Determines if links should be hidden for a bundle based on its repository. /// For merged bundles (e.g., "elasticsearch+kibana+private-repo"), returns true @@ -121,7 +142,8 @@ private static string GenerateMarkdown( string repo, Dictionary> entriesByType, bool subsections, - bool hideLinks) + bool hideLinks, + ChangelogTypeFilter typeFilter) { var sb = new StringBuilder(); @@ -146,7 +168,7 @@ private static string GenerateMarkdown( if (!hasAnyContent) { - _ = sb.AppendLine("_No new features, enhancements, or fixes._"); + _ = sb.AppendLine(GetEmptyMessage(typeFilter)); return sb.ToString(); } @@ -384,4 +406,17 @@ private static void RenderDetailedEntryLinks(StringBuilder sb, ChangelogEntry en private static string GetComponent(ChangelogEntry entry) => entry.Areas is { Count: > 0 } ? entry.Areas[0] : string.Empty; + + /// + /// Gets the appropriate empty message based on the type filter. + /// Matches messages used by CLI renderers for consistency. + /// + private static string GetEmptyMessage(ChangelogTypeFilter typeFilter) => + typeFilter switch + { + ChangelogTypeFilter.BreakingChange => "_There are no breaking changes associated with this release._", + ChangelogTypeFilter.Deprecation => "_There are no deprecations associated with this release._", + ChangelogTypeFilter.KnownIssue => "_There are no known issues associated with this release._", + _ => "_No new features, enhancements, or fixes._" + }; } diff --git a/src/services/Elastic.Changelog/Bundling/BundleBuilder.cs b/src/services/Elastic.Changelog/Bundling/BundleBuilder.cs index d50a8c227..1101bf0cb 100644 --- a/src/services/Elastic.Changelog/Bundling/BundleBuilder.cs +++ b/src/services/Elastic.Changelog/Bundling/BundleBuilder.cs @@ -16,14 +16,22 @@ public class BundleBuilder /// /// Builds the bundled changelog data from matched entries. /// + /// The diagnostics collector. + /// Matched changelog files to bundle. + /// Optional explicit products to set in the output. + /// Whether to resolve changelog file contents into entries. + /// Optional GitHub repository name to set on products for link generation. + /// Optional feature IDs to mark as hidden in the bundle. public BundleBuildResult BuildBundle( IDiagnosticsCollector collector, IReadOnlyList entries, IReadOnlyList? outputProducts, - bool resolve) + bool resolve, + string? repo = null, + HashSet? hideFeatures = null) { // Build products list - var bundledProducts = BuildProducts(collector, entries, outputProducts); + var bundledProducts = BuildProducts(collector, entries, outputProducts, repo); // Build entries list var bundledEntries = resolve @@ -42,6 +50,7 @@ public BundleBuildResult BuildBundle( var bundledData = new Bundle { Products = bundledProducts, + HideFeatures = hideFeatures?.Count > 0 ? hideFeatures.ToList() : [], Entries = bundledEntries }; @@ -55,7 +64,8 @@ public BundleBuildResult BuildBundle( private static List BuildProducts( IDiagnosticsCollector collector, IReadOnlyList entries, - IReadOnlyList? outputProducts) + IReadOnlyList? outputProducts, + string? repo) { List bundledProducts; @@ -69,7 +79,8 @@ private static List BuildProducts( { ProductId = p.Product ?? "", Target = p.Target == "*" ? null : p.Target, - Lifecycle = ParseLifecycle(p.Lifecycle == "*" ? null : p.Lifecycle) + Lifecycle = ParseLifecycle(p.Lifecycle == "*" ? null : p.Lifecycle), + Repo = repo }) .ToList(); } @@ -94,7 +105,8 @@ private static List BuildProducts( .Select(pv => new BundledProduct( pv.product, string.IsNullOrWhiteSpace(pv.version) ? null : pv.version, - pv.lifecycle)) + pv.lifecycle, + repo)) .ToList(); } else diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs index 265b43fc4..bdb7900b1 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs @@ -182,13 +182,7 @@ private string GenerateAmendFilePath(string bundlePath, int amendNumber) } // Parse the changelog file and include full entry data - // Filter out comment lines - var yamlLines = content.Split('\n'); - var yamlWithoutComments = string.Join('\n', yamlLines.Where(line => !line.TrimStart().StartsWith('#'))); - - // Normalize "version:" to "target:" in products section - var normalizedYaml = ChangelogBundlingService.VersionToTargetRegex().Replace(yamlWithoutComments, "$1target:"); - + var normalizedYaml = ReleaseNotesSerialization.NormalizeYaml(content); var entry = ReleaseNotesSerialization.DeserializeEntry(normalizedYaml); return new BundledEntry diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 8e0dd4e19..7733852b6 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -7,6 +7,7 @@ using System.Text; using System.Text.RegularExpressions; using Elastic.Changelog.Configuration; +using Elastic.Changelog.Rendering; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Configuration.ReleaseNotes; @@ -52,6 +53,13 @@ public record BundleChangelogsArguments /// Path to the changelog.yml configuration file /// public string? Config { get; init; } + + /// + /// Feature IDs to mark as hidden in the bundle output. + /// When the bundle is rendered (by CLI render or {changelog} directive), + /// entries with matching feature-id values will be commented out. + /// + public string[]? HideFeatures { get; init; } } /// @@ -136,9 +144,22 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle return false; } + // Load feature IDs to hide + var featureHidingLoader = new FeatureHidingLoader(_fileSystem); + var featureHidingResult = await featureHidingLoader.LoadFeatureIdsAsync(collector, input.HideFeatures, ctx); + if (!featureHidingResult.IsValid) + return false; + // Build bundle var bundleBuilder = new BundleBuilder(); - var buildResult = bundleBuilder.BuildBundle(collector, matchResult.Entries, input.OutputProducts, input.Resolve); + var buildResult = bundleBuilder.BuildBundle( + collector, + matchResult.Entries, + input.OutputProducts, + input.Resolve, + input.Repo, + featureHidingResult.FeatureIdsToHide + ); if (!buildResult.IsValid || buildResult.Data == null) return false; @@ -229,16 +250,33 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle outputPath = _fileSystem.Path.Combine(outputDir, outputPattern); } + // Merge profile hide-features with any CLI-provided hide-features + var mergedHideFeatures = MergeHideFeatures(input.HideFeatures, profile.HideFeatures); + // If we have PRs from a promotion report, use those; otherwise use input products filter return input with { InputProducts = prsFromReport == null ? inputProducts : null, Prs = prsFromReport, All = false, - Output = outputPath ?? input.Output + Output = outputPath ?? input.Output, + HideFeatures = mergedHideFeatures }; } + private static string[]? MergeHideFeatures(string[]? cliHideFeatures, IReadOnlyList? profileHideFeatures) + { + if (cliHideFeatures is not { Length: > 0 } && profileHideFeatures is not { Count: > 0 }) + return null; + + var merged = new HashSet(cliHideFeatures ?? [], StringComparer.OrdinalIgnoreCase); + + if (profileHideFeatures is { Count: > 0 }) + merged.UnionWith(profileHideFeatures); + + return merged.Count > 0 ? [.. merged] : null; + } + private static List ParseProfileProducts(string pattern) { // Parse pattern like "elasticsearch {version} ga" or "cloud-serverless {version} *" diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs b/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs index 6833a7a2b..7e1938d9e 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs @@ -68,13 +68,8 @@ public async Task MatchChangelogsAsync( // Compute checksum (SHA1) var checksum = ChangelogBundlingService.ComputeSha1(fileContent); - // Deserialize YAML (skip comment lines) - var yamlLines = fileContent.Split('\n'); - var yamlWithoutComments = string.Join('\n', yamlLines.Where(line => !line.TrimStart().StartsWith('#'))); - - // Normalize "version:" to "target:" in products section for compatibility - var normalizedYaml = ChangelogBundlingService.VersionToTargetRegex().Replace(yamlWithoutComments, "$1target:"); - + // Deserialize YAML + var normalizedYaml = ReleaseNotesSerialization.NormalizeYaml(fileContent); var yamlDto = deserializer.Deserialize(normalizedYaml); // Check for duplicates (using checksum) diff --git a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs index eb441f561..4308b8969 100644 --- a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs +++ b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs @@ -375,7 +375,8 @@ private static BundleConfiguration ParseBundleConfiguration(BundleConfigurationY kvp => new BundleProfile { Products = kvp.Value.Products, - Output = kvp.Value.Output + Output = kvp.Value.Output, + HideFeatures = kvp.Value.HideFeatures }); } diff --git a/src/services/Elastic.Changelog/Rendering/BundleDataResolver.cs b/src/services/Elastic.Changelog/Rendering/BundleDataResolver.cs index 5c05cf1e8..ee1021b33 100644 --- a/src/services/Elastic.Changelog/Rendering/BundleDataResolver.cs +++ b/src/services/Elastic.Changelog/Rendering/BundleDataResolver.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using Elastic.Changelog.Bundling; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.ReleaseNotes; @@ -87,13 +86,8 @@ private async Task ResolveEntryAsync( var filePath = fileSystem.Path.Combine(bundleDirectory, entry.File!.Name); var fileContent = await fileSystem.File.ReadAllTextAsync(filePath, ctx); - // Deserialize YAML (skip comment lines) - var yamlLines = fileContent.Split('\n'); - var yamlWithoutComments = string.Join('\n', yamlLines.Where(line => !line.TrimStart().StartsWith('#'))); - - // Normalize "version:" to "target:" in products section - var normalizedYaml = ChangelogBundlingService.VersionToTargetRegex().Replace(yamlWithoutComments, "$1target:"); - + // Deserialize YAML + var normalizedYaml = ReleaseNotesSerialization.NormalizeYaml(fileContent); return ReleaseNotesSerialization.DeserializeEntry(normalizedYaml); } } diff --git a/src/services/Elastic.Changelog/Rendering/BundleValidationService.cs b/src/services/Elastic.Changelog/Rendering/BundleValidationService.cs index 1231ea202..0afab918c 100644 --- a/src/services/Elastic.Changelog/Rendering/BundleValidationService.cs +++ b/src/services/Elastic.Changelog/Rendering/BundleValidationService.cs @@ -253,13 +253,8 @@ private async Task ValidateFileReferenceEntryAsync( if (checksum != entry.File.Checksum) collector.EmitWarning(bundleFile, $"Checksum mismatch for file {entry.File.Name}. Expected {entry.File.Checksum}, got {checksum}"); - // Deserialize YAML (skip comment lines) to validate structure - var yamlLines = fileContent.Split('\n'); - var yamlWithoutComments = string.Join('\n', yamlLines.Where(line => !line.TrimStart().StartsWith('#'))); - - // Normalize "version:" to "target:" in products section - var normalizedYaml = ChangelogBundlingService.VersionToTargetRegex().Replace(yamlWithoutComments, "$1target:"); - + // Deserialize YAML to validate structure + var normalizedYaml = ReleaseNotesSerialization.NormalizeYaml(fileContent); var entryData = ReleaseNotesSerialization.DeserializeEntry(normalizedYaml); // Validate required fields in changelog file diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 27a617e7a..b51e04d69 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -115,17 +115,25 @@ Cancel ctx return false; } - // Load feature IDs to hide + // Load feature IDs to hide from CLI var featureHidingLoader = new FeatureHidingLoader(_fileSystem); var featureHidingResult = await featureHidingLoader.LoadFeatureIdsAsync(collector, input.HideFeatures, ctx); if (!featureHidingResult.IsValid) return false; + // Collect hide-features from bundles and merge with CLI hide-features + var combinedHideFeatures = new HashSet(featureHidingResult.FeatureIdsToHide, StringComparer.OrdinalIgnoreCase); + foreach (var bundle in validationResult.Bundles) + { + foreach (var featureId in bundle.Data.HideFeatures) + _ = combinedHideFeatures.Add(featureId); + } + // Emit warnings for hidden entries - EmitHiddenEntryWarnings(collector, resolvedResult.Entries, featureHidingResult.FeatureIdsToHide); + EmitHiddenEntryWarnings(collector, resolvedResult.Entries, combinedHideFeatures); // Build render context (needed for block checking) - var context = BuildRenderContext(input, outputSetup, resolvedResult, featureHidingResult.FeatureIdsToHide, config); + var context = BuildRenderContext(input, outputSetup, resolvedResult, combinedHideFeatures, config); // Emit warnings for blocked entries EmitBlockedEntryWarnings(collector, resolvedResult.Entries, context); diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs index 13461b5f9..12e16ee2f 100644 --- a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs +++ b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs @@ -198,6 +198,11 @@ internal record BundleProfileYaml /// Supports {version} placeholder. /// public string? Output { get; set; } + + /// + /// Feature IDs to mark as hidden in the bundle output. + /// + public List? HideFeatures { get; set; } } /// diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 246d6a03e..e5dccb4b4 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -174,12 +174,13 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// Include all changelogs in the directory. Only one filter option can be specified: `--all`, `--input-products`, or `--prs`. /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' /// Optional: Directory containing changelog YAML files. Uses config bundle.directory or defaults to current directory + /// 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 when the bundle is rendered (by CLI render or {changelog} directive). /// Filter by products in format "product target lifecycle, ..." (e.g., "cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta"). When specified, all three parts (product, target, lifecycle) are required but can be wildcards (*). Examples: "elasticsearch * *" matches all elasticsearch changelogs, "cloud-serverless 2025-12-02 *" matches cloud-serverless 2025-12-02 with any lifecycle, "* 9.3.* *" matches any product with target starting with "9.3.", "* * *" matches all changelogs (equivalent to --all). Only one filter option can be specified: `--all`, `--input-products`, or `--prs`. /// Optional: Output path for the bundled changelog. Can be either (1) a directory path, in which case 'changelog-bundle.yaml' is created in that directory, or (2) a file path ending in .yml or .yaml. Uses config bundle.output_directory or defaults to 'changelog-bundle.yaml' in the input directory /// Optional: Explicitly set the products array in the output file in format "product target lifecycle, ...". Overrides any values from changelogs. /// GitHub repository owner (required only when PRs are specified as numbers) /// Filter by pull request URLs or numbers (comma-separated), or a path to a newline-delimited file containing PR URLs or numbers. Can be specified multiple times. Only one filter option can be specified: `--all`, `--input-products`, or `--prs`. - /// GitHub repository name (required only when PRs are specified as numbers) + /// GitHub repository name. Used for PR filtering when PRs are specified as numbers, and also sets the repo field in the bundle output for generating correct PR/issue links. If not specified, the product ID is used as the repo name in links. /// Optional: Copy the contents of each changelog file into the entries array. Uses config bundle.resolve or defaults to false. /// Optional: Explicitly turn off resolve (overrides config). /// @@ -190,6 +191,7 @@ public async Task Bundle( bool all = false, string? config = null, string? directory = null, + string[]? hideFeatures = null, [ProductInfoParser] List? inputProducts = null, string? output = null, [ProductInfoParser] List? outputProducts = null, @@ -356,6 +358,28 @@ public async Task Bundle( // Determine resolve: CLI --no-resolve takes precedence, then CLI --resolve, then config default var shouldResolve = noResolve ? false : resolve; + // Process each --hide-features occurrence: each can be comma-separated feature IDs or a file path + var allFeatureIdsForBundle = new List(); + if (hideFeatures is { Length: > 0 }) + { + foreach (var hideFeaturesValue in hideFeatures.Where(v => !string.IsNullOrWhiteSpace(v))) + { + // 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)); + allFeatureIdsForBundle.AddRange(commaSeparatedFeatureIds); + } + else + { + // Single value - pass as-is (will be handled by service layer as file path or feature ID) + allFeatureIdsForBundle.Add(hideFeaturesValue); + } + } + } + var input = new BundleChangelogsArguments { Directory = directory ?? Directory.GetCurrentDirectory(), @@ -369,7 +393,8 @@ public async Task Bundle( Repo = repo, Profile = profile, ProfileArgument = profileArg, - Config = config + Config = config, + HideFeatures = allFeatureIdsForBundle.Count > 0 ? allFeatureIdsForBundle.ToArray() : null }; serviceInvoker.AddCommand(service, input, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 2d653fd35..256c857ee 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -11,11 +11,13 @@ namespace Elastic.Changelog.Tests.Changelogs; public class BundleChangelogsTests : ChangelogTestBase { private ChangelogBundlingService Service { get; } + private ChangelogBundlingService ServiceWithConfig { get; } private readonly string _changelogDir; public BundleChangelogsTests(ITestOutputHelper output) : base(output) { Service = new(LoggerFactory, null, FileSystem); + ServiceWithConfig = new(LoggerFactory, ConfigurationContext, FileSystem); _changelogDir = CreateChangelogDir(); } @@ -1557,4 +1559,465 @@ public async Task BundleChangelogs_WithResolveAndInvalidProduct_ReturnsError() Collector.Errors.Should().BeGreaterThan(0); Collector.Diagnostics.Should().Contain(d => d.Message.Contains("product entry missing required field: product")); } + + [Fact] + public async Task BundleChangelogs_WithHideFeaturesOption_IncludesHideFeaturesInBundle() + { + // Arrange - Test that --hide-features option writes feature IDs to the bundle output + + // language=yaml + var changelog1 = + """ + title: Feature with hidden flag + type: feature + feature-id: feature:hidden-api + products: + - product: elasticsearch + target: 9.2.0 + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + HideFeatures = ["feature:hidden-api", "feature:another-hidden"], + Output = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var bundleContent = await FileSystem.File.ReadAllTextAsync(input.Output, TestContext.Current.CancellationToken); + // Verify that hide-features field is included in the bundle output + bundleContent.Should().Contain("hide-features:"); + bundleContent.Should().Contain("- feature:hidden-api"); + bundleContent.Should().Contain("- feature:another-hidden"); + } + + [Fact] + public async Task BundleChangelogs_WithoutHideFeaturesOption_OmitsHideFeaturesFieldInOutput() + { + // Arrange - Test that without --hide-features option, no hide-features field is written + + // language=yaml + var changelog1 = + """ + title: Regular feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + // No HideFeatures + Output = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var bundleContent = await FileSystem.File.ReadAllTextAsync(input.Output, TestContext.Current.CancellationToken); + // Verify that hide-features field is NOT written when not specified + bundleContent.Should().NotContain("hide-features:"); + } + + [Fact] + public async Task BundleChangelogs_WithHideFeaturesFromFile_IncludesHideFeaturesInBundle() + { + // Arrange - Test that --hide-features can read feature IDs from a file + + // language=yaml + var changelog1 = + """ + title: Feature with hidden flag + type: feature + feature-id: feature:from-file + products: + - product: elasticsearch + target: 9.2.0 + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, 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 input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + HideFeatures = [featureIdsFile], + Output = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var bundleContent = await FileSystem.File.ReadAllTextAsync(input.Output, TestContext.Current.CancellationToken); + // Verify that hide-features field contains feature IDs from the file + bundleContent.Should().Contain("hide-features:"); + bundleContent.Should().Contain("- feature:from-file"); + bundleContent.Should().Contain("- feature:another"); + } + + [Fact] + public async Task BundleChangelogs_WithRepoOption_IncludesRepoInBundleProducts() + { + // Arrange - Test that --repo option sets the repo field in the bundle output + + // language=yaml + var changelog1 = + """ + title: Serverless feature + type: feature + products: + - product: cloud-serverless + target: 2025-12-02 + pr: https://github.com/elastic/cloud/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-serverless-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + Repo = "cloud", // Set repo to "cloud" - different from product ID "cloud-serverless" + Output = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var bundleContent = await FileSystem.File.ReadAllTextAsync(input.Output, TestContext.Current.CancellationToken); + // Verify that repo field is included in the bundle output + bundleContent.Should().Contain("product: cloud-serverless"); + bundleContent.Should().Contain("repo: cloud"); + } + + [Fact] + public async Task BundleChangelogs_WithoutRepoOption_OmitsRepoFieldInOutput() + { + // Arrange - Test that without --repo option, no repo field is written to the bundle + + // language=yaml + var changelog1 = + """ + title: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-es-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + // No --repo option + Output = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var bundleContent = await FileSystem.File.ReadAllTextAsync(input.Output, TestContext.Current.CancellationToken); + // Verify that no repo field is written when not specified + bundleContent.Should().Contain("product: elasticsearch"); + bundleContent.Should().NotContain("repo:"); + } + + [Fact] + public async Task BundleChangelogs_WithOutputProductsAndRepo_IncludesRepoInAllProducts() + { + // Arrange - Test that --repo option works with --output-products + + // language=yaml + var changelog1 = + """ + title: Feature for serverless + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: https://github.com/elastic/cloud/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + Repo = "cloud", + OutputProducts = + [ + new ProductArgument { Product = "cloud-serverless", Target = "2025-12-02", Lifecycle = "ga" }, + new ProductArgument { Product = "elasticsearch-serverless", Target = "2025-12-02", Lifecycle = "ga" } + ], + Output = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var bundleContent = await FileSystem.File.ReadAllTextAsync(input.Output, TestContext.Current.CancellationToken); + // Verify that repo field is included for all products + bundleContent.Should().Contain("product: cloud-serverless"); + bundleContent.Should().Contain("product: elasticsearch-serverless"); + // The repo field should appear for each product (or at least once) + bundleContent.Should().Contain("repo: cloud"); + } + + [Fact] + public async Task BundleChangelogs_WithProfileHideFeatures_IncludesHideFeaturesInBundle() + { + // Arrange - Test that hide_features in a profile config are written to the bundle output + + // language=yaml + var configContent = + """ + bundle: + profiles: + es-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{version}.yaml" + hide_features: + - feature:profile-hidden + - feature:another-profile-hidden + """; + + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), "config", "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelog1 = + """ + title: Elasticsearch feature + type: feature + feature-id: feature:profile-hidden + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Profile = "es-release", + ProfileArgument = "9.2.0", + Config = configPath, + OutputDirectory = outputDir + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + // Find the output file + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().NotBeEmpty("Expected an output file to be created"); + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + + // Verify that hide-features from the profile are written to the bundle + bundleContent.Should().Contain("hide-features:"); + bundleContent.Should().Contain("- feature:profile-hidden"); + bundleContent.Should().Contain("- feature:another-profile-hidden"); + } + + [Fact] + public async Task BundleChangelogs_WithProfileAndCliHideFeatures_MergesBothSources() + { + // Arrange - Test that CLI --hide-features and profile hide_features are merged + + // language=yaml + var configContent = + """ + bundle: + profiles: + es-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{version}.yaml" + hide_features: + - feature:from-profile + """; + + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), "config2", "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelog1 = + """ + title: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Profile = "es-release", + ProfileArgument = "9.2.0", + Config = configPath, + OutputDirectory = outputDir, + HideFeatures = ["feature:from-cli"] + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + // Find the output file + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().NotBeEmpty("Expected an output file to be created"); + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + + // Verify that hide-features from BOTH sources are present + bundleContent.Should().Contain("hide-features:"); + bundleContent.Should().Contain("- feature:from-profile"); + bundleContent.Should().Contain("- feature:from-cli"); + } + + [Fact] + public async Task BundleChangelogs_WithProfileAndCliHideFeatures_DeduplicatesFeatureIds() + { + // Arrange - Test that duplicate feature IDs from CLI and profile are deduplicated + + // language=yaml + var configContent = + """ + bundle: + profiles: + es-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{version}.yaml" + hide_features: + - feature:shared + - feature:profile-only + """; + + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), "config3", "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelog1 = + """ + title: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Profile = "es-release", + ProfileArgument = "9.2.0", + Config = configPath, + OutputDirectory = outputDir, + HideFeatures = ["feature:shared", "feature:cli-only"] // "feature:shared" overlaps with profile + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().NotBeEmpty("Expected an output file to be created"); + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + + // Verify all unique features are present + bundleContent.Should().Contain("- feature:shared"); + bundleContent.Should().Contain("- feature:profile-only"); + bundleContent.Should().Contain("- feature:cli-only"); + + // Count occurrences of "feature:shared" - should appear exactly once (deduplicated) + var sharedCount = bundleContent.Split("feature:shared").Length - 1; + sharedCount.Should().Be(1, "Duplicate feature IDs should be deduplicated"); + } } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs index 7918f35d0..d00830a9e 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs @@ -498,6 +498,620 @@ public void MergeBundlesByTarget_WithDateVersions_SortsCorrectly() #endregion + #region Amend File Merging Tests + + [Fact] + public void LoadBundles_WithAmendFile_MergesEntriesIntoParentBundle() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // language=yaml + var parentBundle = + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Original feature + type: feature + """; + // language=yaml + var amendBundle = + """ + products: [] + entries: + - title: Late addition + type: enhancement + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.yaml", parentBundle); + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.amend-1.yaml", amendBundle); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Version.Should().Be("9.3.0"); + bundles[0].Entries.Should().HaveCount(2); + bundles[0].Entries.Select(e => e.Title).Should().Contain(["Original feature", "Late addition"]); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void LoadBundles_WithMultipleAmendFiles_MergesAllIntoParent() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // language=yaml + var parentBundle = + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Original feature + type: feature + """; + // language=yaml + var amendBundle1 = + """ + products: [] + entries: + - title: First amendment + type: enhancement + """; + // language=yaml + var amendBundle2 = + """ + products: [] + entries: + - title: Second amendment + type: bug-fix + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.yaml", parentBundle); + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.amend-1.yaml", amendBundle1); + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.amend-2.yaml", amendBundle2); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Version.Should().Be("9.3.0"); + bundles[0].Entries.Should().HaveCount(3); + bundles[0].Entries.Select(e => e.Title).Should().Contain(["Original feature", "First amendment", "Second amendment"]); + } + + [Fact] + public void LoadBundles_AmendFileWithoutParent_RemainsStandalone() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // No parent bundle, only amend file + // language=yaml + var amendBundle = + """ + products: [] + entries: + - title: Orphan entry + type: feature + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.amend-1.yaml", amendBundle); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Version.Should().Be("9.3.0.amend-1"); // Falls back to filename without parent + bundles[0].Entries.Should().HaveCount(1); + } + + [Fact] + public void LoadBundles_WithYmlExtension_AmendFileMergesCorrectly() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // language=yaml + var parentBundle = + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Original feature + type: feature + """; + // language=yaml + var amendBundle = + """ + products: [] + entries: + - title: Amendment + type: enhancement + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.yml", parentBundle); + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.amend-1.yml", amendBundle); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Version.Should().Be("9.3.0"); + bundles[0].Entries.Should().HaveCount(2); + } + + [Fact] + public void LoadBundles_MixedExtensions_AmendFileMergesWithMatchingParent() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // language=yaml + var parentBundle = + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Original + type: feature + """; + // language=yaml + var amendBundle = + """ + products: [] + entries: + - title: Amendment + type: enhancement + """; + // Parent uses .yaml, amend uses .yml - they share the same base name + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.yaml", parentBundle); + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.amend-1.yaml", amendBundle); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Entries.Should().HaveCount(2); + } + + [Fact] + public void LoadBundles_AmendPreservesParentMetadata() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // language=yaml + var parentBundle = + """ + products: + - product: elasticsearch + target: 9.3.0 + lifecycle: ga + entries: + - title: Original + type: feature + """; + // language=yaml + var amendBundle = + """ + products: [] + entries: + - title: Amendment + type: enhancement + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.yaml", parentBundle); + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.amend-1.yaml", amendBundle); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Version.Should().Be("9.3.0"); + bundles[0].Repo.Should().Be("elasticsearch"); + bundles[0].FilePath.Should().EndWith("9.3.0.yaml"); + bundles[0].Data.Products.Should().HaveCount(1); + } + + [Fact] + public void LoadBundles_MultipleBundlesWithAmends_MergesCorrectly() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // language=yaml + var parent930 = + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature 9.3.0 + type: feature + """; + // language=yaml + var amend930 = + """ + products: [] + entries: + - title: Amendment 9.3.0 + type: enhancement + """; + // language=yaml + var parent920 = + """ + products: + - product: elasticsearch + target: 9.2.0 + entries: + - title: Feature 9.2.0 + type: feature + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.yaml", parent930); + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.amend-1.yaml", amend930); + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.2.0.yaml", parent920); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(2); + var bundle930 = bundles.Single(b => b.Version == "9.3.0"); + var bundle920 = bundles.Single(b => b.Version == "9.2.0"); + + bundle930.Entries.Should().HaveCount(2); + bundle930.Entries.Select(e => e.Title).Should().Contain(["Feature 9.3.0", "Amendment 9.3.0"]); + + bundle920.Entries.Should().HaveCount(1); + bundle920.Entries[0].Title.Should().Be("Feature 9.2.0"); + } + + [Fact] + public void LoadBundles_DateBasedBundleWithAmend_MergesCorrectly() + { + // Arrange - serverless-style date-based bundles + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // language=yaml + var parentBundle = + """ + products: + - product: cloud-serverless + target: 2025-01-28 + entries: + - title: Serverless feature + type: feature + """; + // language=yaml + var amendBundle = + """ + products: [] + entries: + - title: Late serverless fix + type: bug-fix + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/2025-01-28.yaml", parentBundle); + _fileSystem.File.WriteAllText($"{bundlesFolder}/2025-01-28.amend-1.yaml", amendBundle); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Version.Should().Be("2025-01-28"); + bundles[0].Entries.Should().HaveCount(2); + } + + #endregion + + #region Repository Field Tests + + [Fact] + public void LoadBundles_WithExplicitRepoField_UsesRepoInsteadOfProductId() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // Bundle with explicit repo field that differs from product ID + // language=yaml + var bundleContent = + """ + products: + - product: cloud-serverless + target: 2025-01-28 + repo: cloud + entries: + - title: Test feature + type: feature + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/2025-01-28.yaml", bundleContent); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Version.Should().Be("2025-01-28"); + // Repo should be "cloud" from explicit field, not "cloud-serverless" from ProductId + bundles[0].Repo.Should().Be("cloud"); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void LoadBundles_WithoutRepoField_FallsBackToProductId() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // Bundle without repo field - should fall back to product ID + // language=yaml + var bundleContent = + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Test feature + type: feature + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.yaml", bundleContent); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Version.Should().Be("9.3.0"); + // Repo should fall back to ProductId "elasticsearch" + bundles[0].Repo.Should().Be("elasticsearch"); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void LoadBundles_WithEmptyRepoField_FallsBackToProductId() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // Bundle with empty repo field - should fall back to product ID + // language=yaml + var bundleContent = + """ + products: + - product: elasticsearch + target: 9.3.0 + repo: "" + entries: + - title: Test feature + type: feature + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.yaml", bundleContent); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + // Repo should fall back to ProductId when repo is empty + bundles[0].Repo.Should().Be("elasticsearch"); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void LoadBundles_RepoFieldSerializesAndDeserializesCorrectly() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // Bundle with repo field + // language=yaml + var bundleContent = + """ + products: + - product: elasticsearch-serverless + target: 2025-02-01 + lifecycle: ga + repo: elasticsearch + entries: + - title: Test feature + type: feature + pr: https://github.com/elastic/elasticsearch/pull/123 + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/2025-02-01.yaml", bundleContent); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Repo.Should().Be("elasticsearch"); + bundles[0].Data.Products.Should().HaveCount(1); + bundles[0].Data.Products[0].Repo.Should().Be("elasticsearch"); + bundles[0].Data.Products[0].ProductId.Should().Be("elasticsearch-serverless"); + _warnings.Should().BeEmpty(); + } + + #endregion + + #region HideFeatures Tests + + [Fact] + public void LoadBundles_WithHideFeaturesField_LoadsHideFeatures() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // Bundle with hide-features field + // language=yaml + var bundleContent = + """ + products: + - product: elasticsearch + target: 9.3.0 + hide-features: + - feature:hidden-api + - feature:another-hidden + entries: + - title: Test feature + type: feature + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.yaml", bundleContent); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].HideFeatures.Should().HaveCount(2); + bundles[0].HideFeatures.Should().Contain("feature:hidden-api"); + bundles[0].HideFeatures.Should().Contain("feature:another-hidden"); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void LoadBundles_WithoutHideFeaturesField_ReturnsEmptyHideFeatures() + { + // Arrange + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // Bundle without hide-features field + // language=yaml + var bundleContent = + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Test feature + type: feature + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.yaml", bundleContent); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].HideFeatures.Should().BeEmpty(); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void LoadBundles_HideFeaturesSerializesAndDeserializesCorrectly() + { + // Arrange - Test round-trip serialization of hide-features field + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + var originalBundle = new Bundle + { + Products = + [ + new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" } + ], + HideFeatures = ["feature:first", "feature:second", "feature:third"], + Entries = + [ + new BundledEntry + { + Title = "Test feature", + Type = ChangelogEntryType.Feature, + File = new BundledFile { Name = "test.yaml", Checksum = "abc123" } + } + ] + }; + + var serializedYaml = ReleaseNotesSerialization.SerializeBundle(originalBundle); + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.yaml", serializedYaml); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].HideFeatures.Should().HaveCount(3); + bundles[0].HideFeatures.Should().ContainInOrder("feature:first", "feature:second", "feature:third"); + bundles[0].Data.HideFeatures.Should().BeEquivalentTo(originalBundle.HideFeatures); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void LoadedBundle_HideFeatures_ExposedFromBundleData() + { + // Arrange - Verify that LoadedBundle.HideFeatures properly exposes Data.HideFeatures + var bundleData = new Bundle + { + Products = [], + HideFeatures = ["feature:a", "feature:b"], + Entries = [] + }; + var entries = new List(); + var bundle = new LoadedBundle("9.3.0", "elasticsearch", bundleData, "/path/to/bundle.yaml", entries); + + // Act + var hideFeatures = bundle.HideFeatures; + + // Assert + hideFeatures.Should().HaveCount(2); + hideFeatures.Should().Contain("feature:a"); + hideFeatures.Should().Contain("feature:b"); + hideFeatures.Should().BeSameAs(bundleData.HideFeatures); + } + + #endregion + #region EntriesByType Tests [Fact] diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/HideFeaturesTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/HideFeaturesTests.cs index 11461178f..2da04da70 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/HideFeaturesTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/HideFeaturesTests.cs @@ -470,4 +470,190 @@ public async Task RenderChangelogs_WithHideFeatures_CaseInsensitive_MatchesFeatu // Should match case-insensitively indexContent.Should().Contain("% * Hidden feature"); } + + [Fact] + public async Task RenderChangelogs_WithBundleHideFeatures_CommentsOutMatchingEntries() + { + // Arrange - Test that hide-features from bundle metadata are used to hide entries + var changelogDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // language=yaml + var changelog1 = + """ + title: Hidden from bundle + type: feature + products: + - product: elasticsearch + target: 9.2.0 + feature-id: feature:from-bundle + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + + // language=yaml + var changelog2 = + """ + title: Visible feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + pr: https://github.com/elastic/elasticsearch/pull/101 + """; + + 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); + + var bundleDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = FileSystem.Path.Combine(bundleDir, "bundle.yaml"); + // Bundle with hide-features field + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + hide-features: + - feature:from-bundle + 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 RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0" + // No CLI --hide-features - relying on bundle metadata + }; + + // 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); + // Entry from bundle hide-features should be commented out + indexContent.Should().Contain("% * Hidden from bundle"); + // Visible entry should not be commented + indexContent.Should().Contain("* Visible feature"); + indexContent.Should().NotContain("% * Visible feature"); + } + + [Fact] + public async Task RenderChangelogs_MergesCLIAndBundleHideFeatures() + { + // Arrange - Test that CLI and bundle hide-features are merged + var changelogDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // language=yaml + var changelog1 = + """ + title: Hidden from CLI + type: feature + products: + - product: elasticsearch + target: 9.2.0 + feature-id: feature:cli-hidden + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + + // language=yaml + var changelog2 = + """ + title: Hidden from bundle + type: feature + products: + - product: elasticsearch + target: 9.2.0 + feature-id: feature:bundle-hidden + pr: https://github.com/elastic/elasticsearch/pull/101 + """; + + // language=yaml + 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-cli.yaml"); + var changelogFile2 = FileSystem.Path.Combine(changelogDir, "1755268140-bundle.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"); + // Bundle with hide-features for one entry + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + hide-features: + - feature:bundle-hidden + entries: + - file: + name: 1755268130-cli.yaml + checksum: {ComputeSha1(changelog1)} + - file: + name: 1755268140-bundle.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 RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0", + HideFeatures = ["feature:cli-hidden"] // CLI hides different feature + }; + + // 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); + // Both CLI and bundle hidden entries should be commented + indexContent.Should().Contain("% * Hidden from CLI"); + indexContent.Should().Contain("% * Hidden from bundle"); + // Visible entry should not be commented + indexContent.Should().Contain("* Visible feature"); + indexContent.Should().NotContain("% * Visible feature"); + } } diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/LoadPublishBlockerTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/LoadPublishBlockerTests.cs index b9e97053e..b9ef356f4 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/LoadPublishBlockerTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/LoadPublishBlockerTests.cs @@ -164,4 +164,124 @@ public void LoadPublishBlocker_IgnoresOtherProperties() result!.Types.Should().HaveCount(1).And.Contain("deprecation"); result.Areas.Should().BeNull(); } + + [Fact] + public void LoadPublishBlocker_LoadsProductSpecificBlocker_WhenProductIdSpecified() + { + // language=yaml + var yaml = """ + block: + publish: + types: + - regression + product: + kibana: + publish: + types: + - docs + areas: + - "Elastic Security solution" + """; + _fileSystem.AddFile("/docs/changelog.yml", new MockFileData(yaml)); + + var result = ReleaseNotesSerialization.LoadPublishBlocker(_fileSystem, "/docs/changelog.yml", "kibana"); + + result.Should().NotBeNull(); + result!.Types.Should().HaveCount(1).And.Contain("docs"); + result.Areas.Should().HaveCount(1).And.Contain("Elastic Security solution"); + } + + [Fact] + public void LoadPublishBlocker_FallsBackToGlobal_WhenProductNotFound() + { + // language=yaml + var yaml = """ + block: + publish: + types: + - regression + product: + kibana: + publish: + types: + - docs + """; + _fileSystem.AddFile("/docs/changelog.yml", new MockFileData(yaml)); + + var result = ReleaseNotesSerialization.LoadPublishBlocker(_fileSystem, "/docs/changelog.yml", "elasticsearch"); + + result.Should().NotBeNull(); + result!.Types.Should().HaveCount(1).And.Contain("regression"); + } + + [Fact] + public void LoadPublishBlocker_FallsBackToGlobal_WhenProductIdNotSpecified() + { + // language=yaml + var yaml = """ + block: + publish: + types: + - regression + product: + kibana: + publish: + types: + - docs + """; + _fileSystem.AddFile("/docs/changelog.yml", new MockFileData(yaml)); + + var result = ReleaseNotesSerialization.LoadPublishBlocker(_fileSystem, "/docs/changelog.yml"); + + result.Should().NotBeNull(); + result!.Types.Should().HaveCount(1).And.Contain("regression"); + } + + [Fact] + public void LoadPublishBlocker_ProductIdIsCaseInsensitive() + { + // language=yaml + var yaml = """ + block: + product: + kibana: + publish: + types: + - docs + """; + _fileSystem.AddFile("/docs/changelog.yml", new MockFileData(yaml)); + + var result = ReleaseNotesSerialization.LoadPublishBlocker(_fileSystem, "/docs/changelog.yml", "KIBANA"); + + result.Should().NotBeNull(); + result!.Types.Should().HaveCount(1).And.Contain("docs"); + } + + [Fact] + public void LoadPublishBlocker_ProductOnly_NoGlobalFallback() + { + // language=yaml + var yaml = """ + block: + product: + kibana: + publish: + types: + - docs + areas: + - "Elastic Observability solution" + - "Elastic Security solution" + """; + _fileSystem.AddFile("/docs/changelog.yml", new MockFileData(yaml)); + + // With product specified, should get product-specific blocker + var resultWithProduct = ReleaseNotesSerialization.LoadPublishBlocker(_fileSystem, "/docs/changelog.yml", "kibana"); + resultWithProduct.Should().NotBeNull(); + resultWithProduct!.Types.Should().Contain("docs"); + resultWithProduct.Areas.Should().Contain("Elastic Observability solution"); + + // Without product, should get null (no global blocker) + var resultWithoutProduct = ReleaseNotesSerialization.LoadPublishBlocker(_fileSystem, "/docs/changelog.yml"); + resultWithoutProduct.Should().BeNull(); + } } diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogConfigTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogConfigTests.cs index b5b6014c4..5469baafb 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogConfigTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogConfigTests.cs @@ -472,3 +472,239 @@ public void RendersNonBlockedEntries() Html.Should().Contain("Bug fix"); } } + +public class ChangelogProductFallbackSingleProductTests(ITestOutputHelper output) : DirectiveTest(output, + // language=markdown + """ + :::{changelog} + ::: + """) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: kibana + target: 9.3.0 + entries: + - title: Regular Kibana feature + type: feature + products: + - product: kibana + target: 9.3.0 + pr: "111111" + - title: Internal feature + type: feature + products: + - product: kibana + target: 9.3.0 + areas: + - Internal + pr: "222222" + - title: Observability feature + type: feature + products: + - product: kibana + target: 9.3.0 + areas: + - Elastic Observability + pr: "333333" + """)); + + // Config with product-specific blocker for kibana + fileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + block: + publish: + areas: + - Global Area + product: + kibana: + publish: + areas: + - Internal + - Elastic Observability + """)); + } + + protected override IReadOnlyList? GetDocsetProducts() => ["kibana"]; + + [Fact] + public void UsesProductSpecificBlockerWhenDocsetHasSingleProduct() => + Block!.PublishBlocker.Should().NotBeNull(); + + [Fact] + public void ProductBlockerHasCorrectAreas() + { + Block!.PublishBlocker!.Areas.Should().NotBeNull(); + Block!.PublishBlocker!.Areas.Should().Contain("Internal"); + Block!.PublishBlocker!.Areas.Should().Contain("Elastic Observability"); + // Should NOT contain global area - product-specific blocker takes precedence + Block!.PublishBlocker!.Areas.Should().NotContain("Global Area"); + } + + [Fact] + public void FiltersEntriesMatchingProductBlockedAreas() + { + Html.Should().Contain("Regular Kibana feature"); + Html.Should().NotContain("Internal feature"); + Html.Should().NotContain("Observability feature"); + } +} + +public class ChangelogProductFallbackMultipleProductsTests(ITestOutputHelper output) : DirectiveTest(output, + // language=markdown + """ + :::{changelog} + ::: + """) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Regular feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Internal feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Internal + pr: "222222" + """)); + + // Config with product-specific blockers + fileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + block: + publish: + areas: + - Global Area + product: + elasticsearch: + publish: + areas: + - Internal + """)); + } + + // Docset with multiple products - should fall back to global blocker + protected override IReadOnlyList? GetDocsetProducts() => ["elasticsearch", "kibana"]; + + [Fact] + public void FallsBackToGlobalBlockerWhenMultipleProducts() => + Block!.PublishBlocker.Should().NotBeNull(); + + [Fact] + public void GlobalBlockerHasCorrectAreas() + { + Block!.PublishBlocker!.Areas.Should().NotBeNull(); + Block!.PublishBlocker!.Areas.Should().Contain("Global Area"); + // Should NOT contain product-specific area + Block!.PublishBlocker!.Areas.Should().NotContain("Internal"); + } + + [Fact] + public void RendersAllEntriesWithGlobalBlocker() + { + // Global blocker only blocks "Global Area", not "Internal" + Html.Should().Contain("Regular feature"); + Html.Should().Contain("Internal feature"); + } +} + +public class ChangelogProductExplicitOptionOverridesDocsetTests(ITestOutputHelper output) : DirectiveTest(output, + // language=markdown + """ + :::{changelog} + :product: elasticsearch + ::: + """) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Regular feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: ES Internal feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + areas: + - ES Internal + pr: "222222" + - title: Kibana Internal feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Kibana Internal + pr: "333333" + """)); + + // Config with different blockers for different products + fileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + block: + product: + elasticsearch: + publish: + areas: + - ES Internal + kibana: + publish: + areas: + - Kibana Internal + """)); + } + + // Docset has kibana as single product, but directive explicitly requests elasticsearch + protected override IReadOnlyList? GetDocsetProducts() => ["kibana"]; + + [Fact] + public void ExplicitProductOptionIsSet() => + Block!.ProductId.Should().Be("elasticsearch"); + + [Fact] + public void UsesExplicitProductBlockerNotDocsetProduct() + { + Block!.PublishBlocker!.Areas.Should().Contain("ES Internal"); + Block!.PublishBlocker!.Areas.Should().NotContain("Kibana Internal"); + } + + [Fact] + public void FiltersCorrectAreas() + { + Html.Should().Contain("Regular feature"); + Html.Should().NotContain("ES Internal feature"); + Html.Should().Contain("Kibana Internal feature"); + } +} diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogTocFilteringTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogTocFilteringTests.cs new file mode 100644 index 000000000..696f8dcda --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogTocFilteringTests.cs @@ -0,0 +1,609 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions.TestingHelpers; +using Elastic.Markdown.Myst.Directives.Changelog; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.Directives; + +/// +/// Tests that publish blockers correctly filter the right-hand navigation (TOC) and generated anchors. +/// When all entries of a certain type are blocked, the corresponding section heading should not +/// appear in the TOC or generated anchors. +/// +public class ChangelogPublishBlockerFiltersTocTests : DirectiveTest +{ + public ChangelogPublishBlockerFiltersTocTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Docs update + type: docs + products: + - product: elasticsearch + target: 9.3.0 + pr: "222222" + - title: Other stuff + type: other + products: + - product: elasticsearch + target: 9.3.0 + pr: "333333" + """)); + + // Block docs and other types via publish blocker + FileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + block: + publish: + types: + - docs + - other + """)); + } + + [Fact] + public void TocExcludesBlockedDocumentationSection() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().NotContain(t => t.Heading == "Documentation"); + } + + [Fact] + public void TocExcludesBlockedOtherSection() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().NotContain(t => t.Heading == "Other changes"); + } + + [Fact] + public void TocRetainsNonBlockedFeaturesSection() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().Contain(t => t.Heading == "Features and enhancements"); + } + + [Fact] + public void TocRetainsVersionHeader() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().Contain(t => t.Heading == "9.3.0" && t.Level == 2); + } + + [Fact] + public void AnchorsExcludeBlockedDocumentationSection() + { + var anchors = Block!.GeneratedAnchors.ToList(); + anchors.Should().NotContain(a => a.Contains("docs")); + } + + [Fact] + public void AnchorsExcludeBlockedOtherSection() + { + var anchors = Block!.GeneratedAnchors.ToList(); + anchors.Should().NotContain(a => a.EndsWith("-other")); + } + + [Fact] + public void AnchorsRetainNonBlockedFeaturesSection() + { + var anchors = Block!.GeneratedAnchors.ToList(); + anchors.Should().Contain(a => a.Contains("features-enhancements")); + } + + [Fact] + public void HtmlDoesNotContainBlockedSections() + { + Html.Should().NotContain("Documentation"); + Html.Should().NotContain("Other changes"); + Html.Should().Contain("Features and enhancements"); + } +} + +/// +/// Tests that hide-features correctly filter the TOC and generated anchors. +/// When all entries of a certain type are hidden via feature-id, the corresponding section +/// should not appear in the TOC or anchors. +/// +public class ChangelogHideFeaturesFiltersTocTests : DirectiveTest +{ + public ChangelogHideFeaturesFiltersTocTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => + // Bundle with hide-features that filters out all "other" entries + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + hide-features: + - hidden-feature-1 + - hidden-feature-2 + entries: + - title: Visible feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Hidden other change 1 + type: other + feature-id: hidden-feature-1 + products: + - product: elasticsearch + target: 9.3.0 + pr: "222222" + - title: Hidden other change 2 + type: other + feature-id: hidden-feature-2 + products: + - product: elasticsearch + target: 9.3.0 + pr: "333333" + """)); + + [Fact] + public void TocExcludesHiddenOtherSection() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().NotContain(t => t.Heading == "Other changes"); + } + + [Fact] + public void TocRetainsVisibleFeaturesSection() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().Contain(t => t.Heading == "Features and enhancements"); + } + + [Fact] + public void AnchorsExcludeHiddenOtherSection() + { + var anchors = Block!.GeneratedAnchors.ToList(); + anchors.Should().NotContain(a => a.EndsWith("-other")); + } + + [Fact] + public void AnchorsRetainVisibleFeaturesSection() + { + var anchors = Block!.GeneratedAnchors.ToList(); + anchors.Should().Contain(a => a.Contains("features-enhancements")); + } + + [Fact] + public void HtmlMatchesTocFiltering() + { + Html.Should().NotContain("Other changes"); + Html.Should().NotContain("Hidden other change"); + Html.Should().Contain("Features and enhancements"); + Html.Should().Contain("Visible feature"); + } +} + +/// +/// Tests that partial filtering retains the section when some (but not all) entries are filtered. +/// +public class ChangelogPartialFilterRetainsTocTests : DirectiveTest +{ + public ChangelogPartialFilterRetainsTocTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + hide-features: + - hidden-feature + entries: + - title: Visible other change + type: other + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Hidden other change + type: other + feature-id: hidden-feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "222222" + """)); + + [Fact] + public void TocRetainsSectionWhenSomeEntriesRemain() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().Contain(t => t.Heading == "Other changes"); + } + + [Fact] + public void AnchorsRetainSectionWhenSomeEntriesRemain() + { + var anchors = Block!.GeneratedAnchors.ToList(); + anchors.Should().Contain(a => a.EndsWith("-other")); + } + + [Fact] + public void HtmlShowsVisibleEntryOnly() + { + Html.Should().Contain("Visible other change"); + Html.Should().NotContain("Hidden other change"); + } +} + +/// +/// Tests that publish blocker and hide-features work together to filter the TOC. +/// +public class ChangelogCombinedFiltersFilterTocTests : DirectiveTest +{ + public ChangelogCombinedFiltersFilterTocTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + hide-features: + - hidden-feature + entries: + - title: Visible feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Docs entry blocked by type + type: docs + products: + - product: elasticsearch + target: 9.3.0 + pr: "222222" + - title: Bug fix hidden by feature + type: bug-fix + feature-id: hidden-feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "333333" + """)); + + // Block docs type via publish blocker + FileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + block: + publish: + types: + - docs + """)); + } + + [Fact] + public void TocExcludesPublishBlockedSection() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().NotContain(t => t.Heading == "Documentation"); + } + + [Fact] + public void TocExcludesHideFeatureFilteredSection() + { + // The only bug-fix entry is hidden by feature-id, so the Fixes section should not appear + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().NotContain(t => t.Heading == "Fixes"); + } + + [Fact] + public void TocRetainsUnfilteredSection() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().Contain(t => t.Heading == "Features and enhancements"); + } + + [Fact] + public void AnchorsExcludeBothFilteredSections() + { + var anchors = Block!.GeneratedAnchors.ToList(); + anchors.Should().NotContain(a => a.Contains("-docs")); + anchors.Should().NotContain(a => a.Contains("-fixes")); + } + + [Fact] + public void HtmlMatchesTocAndAnchors() + { + Html.Should().Contain("Features and enhancements"); + Html.Should().Contain("Visible feature"); + Html.Should().NotContain("Documentation"); + Html.Should().NotContain("Docs entry blocked by type"); + Html.Should().NotContain(">Fixes<"); + Html.Should().NotContain("Bug fix hidden by feature"); + } +} + +/// +/// Tests area-based publish blocker filtering on TOC and anchors. +/// When all entries of a type are blocked by area, the section should not appear. +/// +public class ChangelogPublishBlockerAreaFiltersTocTests : DirectiveTest +{ + public ChangelogPublishBlockerAreaFiltersTocTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Regular feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Internal docs + type: docs + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Internal + pr: "222222" + - title: Internal other change + type: other + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Internal + pr: "333333" + """)); + + FileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + block: + publish: + areas: + - Internal + """)); + } + + [Fact] + public void TocExcludesAreaBlockedDocumentationSection() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().NotContain(t => t.Heading == "Documentation"); + } + + [Fact] + public void TocExcludesAreaBlockedOtherSection() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().NotContain(t => t.Heading == "Other changes"); + } + + [Fact] + public void TocRetainsNonBlockedFeaturesSection() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().Contain(t => t.Heading == "Features and enhancements"); + } + + [Fact] + public void AnchorsMatchToc() + { + var anchors = Block!.GeneratedAnchors.ToList(); + anchors.Should().NotContain(a => a.Contains("-docs")); + anchors.Should().NotContain(a => a.EndsWith("-other")); + anchors.Should().Contain(a => a.Contains("features-enhancements")); + } +} + +/// +/// Tests that when ALL entries across ALL types in a bundle are filtered out, +/// the version header still appears in the TOC but no section headers do. +/// +public class ChangelogAllEntriesFilteredTocTests : DirectiveTest +{ + public ChangelogAllEntriesFilteredTocTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Internal feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Internal + pr: "111111" + - title: Internal bug fix + type: bug-fix + products: + - product: elasticsearch + target: 9.3.0 + areas: + - Internal + pr: "222222" + """)); + + FileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + block: + publish: + areas: + - Internal + """)); + } + + [Fact] + public void TocContainsVersionHeaderOnly() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().ContainSingle(t => t.Level == 2 && t.Heading == "9.3.0"); + tocItems.Should().NotContain(t => t.Level == 3); + } + + [Fact] + public void NoAnchorsGenerated() + { + var anchors = Block!.GeneratedAnchors.ToList(); + anchors.Should().BeEmpty(); + } +} + +/// +/// Tests TOC filtering across multiple bundles. Each bundle should independently +/// filter its sections based on which entries survive filtering. +/// +public class ChangelogMultipleBundlesTocFilteringTests : DirectiveTest +{ + public ChangelogMultipleBundlesTocFilteringTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) + { + // 9.3.0 has docs entries that will be blocked + FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature in 9.3.0 + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + - title: Docs in 9.3.0 + type: docs + products: + - product: elasticsearch + target: 9.3.0 + pr: "222222" + """)); + + // 9.2.0 only has docs entries (all will be blocked) + FileSystem.AddFile("docs/changelog/bundles/9.2.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.2.0 + entries: + - title: Docs in 9.2.0 + type: docs + products: + - product: elasticsearch + target: 9.2.0 + pr: "333333" + """)); + + FileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + block: + publish: + types: + - docs + """)); + } + + [Fact] + public void BothVersionHeadersAppearInToc() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().Contain(t => t.Heading == "9.3.0" && t.Level == 2); + tocItems.Should().Contain(t => t.Heading == "9.2.0" && t.Level == 2); + } + + [Fact] + public void NeitherVersionHasDocumentationInToc() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().NotContain(t => t.Heading == "Documentation"); + } + + [Fact] + public void FirstVersionRetainsFeaturesInToc() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + tocItems.Should().Contain(t => t.Heading == "Features and enhancements"); + } + + [Fact] + public void SecondVersionHasNoSectionHeaders() + { + var tocItems = Block!.GeneratedTableOfContent.ToList(); + + // 9.2.0 is the second version header; find its index and check no h3 follows until end + var versionIndices = tocItems + .Select((t, i) => (t, i)) + .Where(x => x.t.Level == 2) + .Select(x => x.i) + .ToList(); + + // Should have 2 versions + versionIndices.Should().HaveCount(2); + + // Items after the second version header should all be version-level (none at section level) + var lastVersionIdx = versionIndices.Last(); + var itemsAfterLastVersion = tocItems.Skip(lastVersionIdx + 1).ToList(); + itemsAfterLastVersion.Should().NotContain(t => t.Level == 3); + } +} diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogTypeFilterTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogTypeFilterTests.cs index 130341655..7d46ba8b1 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogTypeFilterTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogTypeFilterTests.cs @@ -659,10 +659,11 @@ public void TableOfContentsRespectTypeFilter() /// /// Tests that empty result shows appropriate message when type filter excludes all entries. +/// For known-issue filter, we should show known-issue-specific message. /// -public class ChangelogTypeFilterEmptyResultTests : DirectiveTest +public class ChangelogTypeFilterEmptyKnownIssueTests : DirectiveTest { - public ChangelogTypeFilterEmptyResultTests(ITestOutputHelper output) : base(output, + public ChangelogTypeFilterEmptyKnownIssueTests(ITestOutputHelper output) : base(output, // language=markdown """ :::{changelog} @@ -684,10 +685,146 @@ public ChangelogTypeFilterEmptyResultTests(ITestOutputHelper output) : base(outp """)); [Fact] - public void ShowsNoEntriesMessageWhenFilterExcludesAll() + public void ShowsKnownIssueSpecificEmptyMessage() { // When filtering to known-issue but bundle only has features, - // should show "no entries" message + // should show known-issue-specific message + Html.Should().Contain("There are no known issues associated with this release"); + } +} + +/// +/// Tests that empty result shows breaking-change-specific message when using breaking-change filter. +/// +public class ChangelogTypeFilterEmptyBreakingChangeTests : DirectiveTest +{ + public ChangelogTypeFilterEmptyBreakingChangeTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: breaking-change + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + """)); + + [Fact] + public void ShowsBreakingChangeSpecificEmptyMessage() + { + // When filtering to breaking-change but bundle only has features, + // should show breaking-change-specific message + Html.Should().Contain("There are no breaking changes associated with this release"); + } +} + +/// +/// Tests that empty result shows deprecation-specific message when using deprecation filter. +/// +public class ChangelogTypeFilterEmptyDeprecationTests : DirectiveTest +{ + public ChangelogTypeFilterEmptyDeprecationTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: deprecation + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + pr: "111111" + """)); + + [Fact] + public void ShowsDeprecationSpecificEmptyMessage() + { + // When filtering to deprecation but bundle only has features, + // should show deprecation-specific message + Html.Should().Contain("There are no deprecations associated with this release"); + } +} + +/// +/// Tests that empty result shows generic message when using default filter. +/// +public class ChangelogTypeFilterEmptyDefaultTests : DirectiveTest +{ + public ChangelogTypeFilterEmptyDefaultTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Breaking change only + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: API changed. + impact: Users must update. + action: Follow guide. + pr: "111111" + """)); + + [Fact] + public void ShowsGenericEmptyMessageForDefaultFilter() + { + // When using default filter but bundle only has breaking changes (which are excluded by default), + // should show the generic "no features, enhancements, or fixes" message + Html.Should().Contain("No new features, enhancements, or fixes"); + } +} + +/// +/// Tests that empty result shows generic message when using "all" filter with empty bundle. +/// +public class ChangelogTypeFilterEmptyAllTests : DirectiveTest +{ + public ChangelogTypeFilterEmptyAllTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: all + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: [] + """)); + + [Fact] + public void ShowsGenericEmptyMessageForAllFilter() + { + // When using "all" filter with empty entries, + // should show the generic message (not type-specific since All includes everything) Html.Should().Contain("No new features, enhancements, or fixes"); } } diff --git a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs index 447364f1e..848f05d6c 100644 --- a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs @@ -65,7 +65,8 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown") AddToFileSystem(FileSystem); var root = FileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs/")); - FileSystem.GenerateDocSetYaml(root); + // ReSharper disable once VirtualMemberCallInConstructor + FileSystem.GenerateDocSetYaml(root, products: GetDocsetProducts()); Collector = new TestDiagnosticsCollector(output); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); @@ -79,6 +80,12 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown") protected virtual void AddToFileSystem(MockFileSystem fileSystem) { } + /// + /// Override to specify products for the docset configuration. + /// Returns null by default (no products configured). + /// + protected virtual IReadOnlyList? GetDocsetProducts() => null; + public virtual async ValueTask InitializeAsync() { _ = Collector.StartAsync(TestContext.Current.CancellationToken); diff --git a/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs b/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs index 6986e38cb..34dedcd82 100644 --- a/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs +++ b/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs @@ -9,10 +9,23 @@ namespace Elastic.Markdown.Tests; public static class MockFileSystemExtensions { - public static void GenerateDocSetYaml(this MockFileSystem fileSystem, IDirectoryInfo root, Dictionary? globalVariables = null) + public static void GenerateDocSetYaml( + this MockFileSystem fileSystem, + IDirectoryInfo root, + Dictionary? globalVariables = null, + IReadOnlyList? products = null) { // language=yaml var yaml = new StringWriter(); + + // Add products section if provided + if (products is { Count: > 0 }) + { + yaml.WriteLine("products:"); + foreach (var productId in products) + yaml.WriteLine($" - id: {productId}"); + } + yaml.WriteLine("cross_links:"); yaml.WriteLine(" - docs-content"); yaml.WriteLine(" - kibana"); diff --git a/tests/Elastic.Markdown.Tests/TestHelpers.cs b/tests/Elastic.Markdown.Tests/TestHelpers.cs index 9ea9ee3a2..23c72e38c 100644 --- a/tests/Elastic.Markdown.Tests/TestHelpers.cs +++ b/tests/Elastic.Markdown.Tests/TestHelpers.cs @@ -43,6 +43,14 @@ public static IConfigurationContext CreateConfigurationContext(IFileSystem fileS VersioningSystem = versionsConfiguration.GetVersioningSystem(VersioningSystemId.Stack) } }, + { + "kibana", new Product + { + Id = "kibana", + DisplayName = "Kibana", + VersioningSystem = versionsConfiguration.GetVersioningSystem(VersioningSystemId.Stack) + } + }, { "apm", new Product {