diff --git a/config/changelog.example.yml b/config/changelog.example.yml index cf7e0ca45..82c75e76c 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -212,6 +212,12 @@ bundle: output_directory: docs/releases # Whether to resolve (copy contents) by default resolve: true + # Optional: default description text for bundles. Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. + # Use YAML literal block scalar (|) for multiline descriptions. See docs/contribute/changelog.md for examples. + # description: | + # This release includes new features and bug fixes. + # + # For more information, see the [release notes](https://www.elastic.co/docs/release-notes/product#product-{version}). # changelog-init-bundle-seed # PR/issue link allowlist: when set (including []), only links to these owner/repo pairs are kept # in bundle output; others are rewritten to '# PRIVATE:' sentinels (requires resolve: true). @@ -245,6 +251,13 @@ bundle: # output: "elasticsearch-{version}.yaml" # # Optional: override the products array written to the bundle output. # # output_products: "elasticsearch {version}" + # # Optional: profile-specific description (overrides bundle.description) + # # description: | + # # Elasticsearch {version} includes: + # # - Performance improvements + # # - Bug fixes and stability enhancements + # # + # # Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version} # Example: GitHub release profile (fetches PR list directly from a GitHub release) # Use when you want to bundle or remove changelogs based on a published GitHub release. # elasticsearch-gh-release: diff --git a/docs/cli/changelog/bundle.md b/docs/cli/changelog/bundle.md index 6fe684316..2a62695af 100644 --- a/docs/cli/changelog/bundle.md +++ b/docs/cli/changelog/bundle.md @@ -78,6 +78,13 @@ You must choose one method for determining what's in the bundle (`--all`, `--inp : Optional: The directory that contains the changelog YAML files. : When not specified, falls back to `bundle.directory` from the changelog configuration, then the current working directory. See [Output files](#output-files) for the full resolution order. +`--description ` +: Optional: Bundle description text with placeholder support. +: Supports `{version}`, `{lifecycle}`, `{owner}`, and `{repo}` placeholders. Overrides `bundle.description` from config. +: When using `{version}` or `{lifecycle}` placeholders, predictable substitution values are required: +: - **Option-based mode**: Requires `--output-products` to be explicitly specified +: - **Profile-based mode**: Requires either a version argument OR `output_products` in the profile configuration + `--hide-features ` : Optional: A list of feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. : Can be specified multiple times. @@ -354,6 +361,33 @@ docs-builder changelog bundle \ By default all changelogs that match PRs in the GitHub release notes are included in the bundle. To apply additional filtering by the changelog type, areas, or products, add `rules.bundle` [filters](#changelog-bundle-rules). +### Bundle with description + +You can add a description to bundles using the `--description` option. For simple descriptions, use regular quotes: + +```sh +docs-builder changelog bundle \ + --all \ + --description "This release includes new features and bug fixes." +``` + +For multiline descriptions with multiple paragraphs, lists, and links, use ANSI-C quoting (`$'...'`) with `\n` for line breaks: + +```sh +docs-builder changelog bundle \ + --all \ + --description $'This release includes significant improvements:\n\n- Enhanced performance\n- Bug fixes and stability improvements\n\nFor security updates, go to [security announcements](https://example.com/docs).' +``` + +When using placeholders in option-based mode, you must explicitly specify `--output-products` for predictable substitution: + +```sh +docs-builder changelog bundle \ + --all \ + --output-products "elasticsearch 9.1.0 ga" \ + --description "Elasticsearch {version} includes performance improvements. Download: https://github.com/{owner}/{repo}/releases/tag/v{version}" +``` + ## Profile-based examples When the changelog configuration file defines `bundle.profiles`, you can use those profiles with the `changelog bundle` command. @@ -442,6 +476,29 @@ docs-builder changelog bundle elasticsearch-gh-release 9.2.0 docs-builder changelog bundle elasticsearch-gh-release latest ``` +:::{warning} +**Placeholder validation**: If your profile uses `{version}` or `{lifecycle}` placeholders in the description, you must ensure predictable substitution values: + +```sh +# ✅ Good: Version provided for placeholder substitution +docs-builder changelog bundle elasticsearch-release 9.2.0 ./report.html + +# ❌ Bad: No version, placeholders will fail unless profile has output_products +docs-builder changelog bundle elasticsearch-release ./report.html +``` + +To fix the second case, either provide a version argument or add an `output_products` pattern to your profile: + +```yaml +bundle: + profiles: + elasticsearch-release: + products: "elasticsearch * *" + output_products: "elasticsearch {version}" # Enables placeholder substitution + description: "Download: https://github.com/{owner}/{repo}/releases/tag/v{version}" +``` +::: + ### Bundle by product You can create profiles that are equivalent to the `--input-products` filter option, that is to say the bundle will contain only changelogs with matching `products`. diff --git a/docs/cli/changelog/gh-release.md b/docs/cli/changelog/gh-release.md index d89984f77..0c36ec80a 100644 --- a/docs/cli/changelog/gh-release.md +++ b/docs/cli/changelog/gh-release.md @@ -31,6 +31,10 @@ docs-builder changelog gh-release [version] [options...] [-h|--help] `--config ` : Optional: Path to the changelog.yml configuration file. Defaults to `docs/changelog.yml`. +`--description ` +: Optional: Bundle description text with placeholder support. +: Supports `{version}`, `{lifecycle}`, `{owner}`, and `{repo}` placeholders. Overrides `bundle.description` from config. + `--output ` : Optional: Output directory for the generated changelog files. Falls back to `bundle.directory` in `changelog.yml` when not specified. Defaults to `./changelogs`. @@ -86,6 +90,13 @@ docs-builder changelog gh-release elasticsearch v9.2.0 \ --config ./docs/changelog.yml ``` +### Add description with placeholders + +```sh +docs-builder changelog gh-release elasticsearch v9.2.0 \ + --description "Elasticsearch {version} includes new features and fixes. Download: https://github.com/{owner}/{repo}/releases/tag/v{version}" +``` + ### Strip component prefixes from titles ```sh diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 9d261dc0b..0bc27acfa 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -746,11 +746,23 @@ bundle: owner: elastic # The default repository owner for PR and issue links. directory: docs/changelog # The directory that contains changelog files. output_directory: docs/releases # The directory that contains changelog bundles. + # Optional: Default bundle description with placeholder support + description: | + This release includes new features and bug fixes. + + For more information, see the [release notes](https://www.elastic.co/docs/release-notes/elasticsearch#elasticsearch-{version}). profiles: elasticsearch-release: products: "elasticsearch {version} {lifecycle}" output: "elasticsearch/{version}.yaml" output_products: "elasticsearch {version}" + # Profile-specific description overrides bundle.description + description: | + Elasticsearch {version} includes: + - Performance improvements + - Bug fixes and stability enhancements + + Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version} hide_features: - feature:experimental-api serverless-release: @@ -800,6 +812,63 @@ The `{version}` placeholder is substituted with the clean base version extracted This differs from standard profiles, where `{lifecycle}` is inferred from the version string you type at the command line. +#### Bundle descriptions + +You can add introductory text to bundles using the `description` field. This text appears at the top of rendered changelogs, after the release heading but before the entry sections. + +**Configuration locations:** + +- `bundle.description`: Default description for all profiles +- `bundle.profiles..description`: Profile-specific description (overrides the default) + +**Placeholder support:** + +Bundle descriptions support these placeholders: + +- `{version}`: The resolved version string +- `{lifecycle}`: The resolved lifecycle (ga, beta, preview, etc.) +- `{owner}`: The GitHub repository owner +- `{repo}`: The GitHub repository name + +**Important**: When using `{version}` or `{lifecycle}` placeholders, you must ensure predictable substitution values: + +- **Option-based mode**: Requires `--output-products` when using placeholders +- **Profile-based mode**: Requires either a version argument (e.g., `bundle profile 9.2.0`) OR an `output_products` pattern in the profile configuration when using placeholders. If you invoke a profile with only a promotion report (e.g., `bundle profile ./report.html`), placeholders will fail unless `output_products` is configured. + +**Multiline descriptions in YAML:** + +For complex descriptions with multiple paragraphs, lists, and links, use YAML literal block scalars with the `|` (pipe) syntax: + +```yaml +bundle: + description: | + This release includes significant improvements: + + - Enhanced performance + - Bug fixes and stability improvements + - New features for better user experience + + For security updates, go to [security announcements](https://example.com/docs). + + Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version} +``` + +The `|` (pipe) preserves line breaks and is ideal for Markdown-formatted text. Avoid using `>` (greater than) for descriptions as it folds line breaks into spaces, making lists and paragraphs difficult to format correctly. + +**Command line usage:** + +For simple descriptions, use the `--description` option with regular quotes: + +```sh +docs-builder changelog bundle --all --description "This release includes new features." +``` + +For multiline descriptions on the command line, use ANSI-C quoting (`$'...'`) with `\n` for line breaks: + +```sh +docs-builder changelog bundle --all --description $'Enhanced release:\n\n- Performance improvements\n- Bug fixes' +``` + `output_products` is optional. When omitted, the bundle products array is derived from the matched changelog files' own `products` fields — the same fallback used by all other profile types. Set `output_products` when you want a single clean product entry that reflects the release identity rather than the diverse metadata across individual changelog files, or to hardcode a lifecycle that cannot be inferred from the tag format: ```yaml diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index a3086b735..dfada1db0 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -142,10 +142,14 @@ For full syntax, refer to the [rules for filtered bundles](/cli/changelog/bundle 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 +# Example bundle with description and hide-features products: - product: elasticsearch target: 9.3.0 +description: | + This release includes new features and bug fixes. + + For more information, see the [release notes](https://example.com/docs). hide-features: - feature:hidden-api - feature:experimental @@ -223,10 +227,15 @@ The version is extracted from the first product's `target` field in each bundle ## Rendered output -Each bundle renders as a `## {version}` section with subsections beneath: +Each bundle renders as a `## {version}` section with optional description and subsections beneath: ```markdown ## 0.100.0 + +This release includes new features and bug fixes. + +Download the release binaries: https://github.com/elastic/elasticsearch/releases/tag/v0.100.0 + ### Features and enhancements ... ### Fixes @@ -237,6 +246,8 @@ Each bundle renders as a `## {version}` section with subsections beneath: ... ``` +Bundle descriptions are rendered when present in the bundle YAML file. The description appears immediately after the version heading but before any entry sections. Descriptions support Markdown formatting including links, lists, and multiple paragraphs. + ### Section types | Section | Entry type | Rendering | diff --git a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs index 7f3717d82..5282420b3 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs @@ -27,6 +27,12 @@ public record BundleConfiguration /// public bool Resolve { get; init; } = true; + /// + /// Default bundle description used when no profile-specific description is provided. + /// Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. + /// + public string? Description { get; init; } + /// /// Default GitHub repository name applied to all profiles that do not specify their own. /// Used for generating correct PR/issue links when the product ID differs from the repo name. @@ -81,6 +87,12 @@ public record BundleProfile /// public string? OutputProducts { get; init; } + /// + /// Profile-specific bundle description. When provided, overrides the bundle.description default. + /// Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. + /// + public string? Description { get; init; } + /// /// GitHub repository name stored on each product in the bundle output. /// Used for generating correct PR/issue links when the product ID differs from the repo name. diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs index 3e6f48fca..a417ceabe 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs @@ -14,6 +14,10 @@ public sealed record BundleDto { public List? Products { get; set; } /// + /// Optional introductory description text for this bundle. + /// + public string? Description { 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. /// diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs index 444e8ffc9..b25a45e1a 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs @@ -217,11 +217,25 @@ private static LoadedBundle MergeBundleGroup(IGrouping gro // Use the first bundle's metadata as the base var first = bundlesList[0]; + var descriptions = bundlesList + .Select(b => b.Data?.Description) + .Where(d => !string.IsNullOrEmpty(d)) + .ToList(); + + var mergedDescription = descriptions.Count switch + { + 0 => null, + 1 => descriptions[0], + _ => string.Join("\n\n", descriptions) + }; + + var mergedData = first.Data with { Description = mergedDescription }; + return new LoadedBundle( first.Version, combinedRepo, first.Owner, - first.Data, + mergedData, first.FilePath, mergedEntries ); diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs index d861b383b..671c2d510 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs @@ -135,6 +135,7 @@ public static string SerializeBundle(Bundle bundle) private static Bundle ToBundle(BundleDto dto) => new() { Products = dto.Products?.Select(ToBundledProduct).ToList() ?? [], + Description = dto.Description, HideFeatures = dto.HideFeatures ?? [], Entries = dto.Entries?.Select(ToBundledEntry).ToList() ?? [] }; @@ -239,6 +240,7 @@ private static ChangelogEntryType ParseEntryType(string? value) private static BundleDto ToDto(Bundle bundle) => new() { Products = bundle.Products.Select(ToDto).ToList(), + Description = bundle.Description, HideFeatures = bundle.HideFeatures.Count > 0 ? bundle.HideFeatures.ToList() : null, Entries = bundle.Entries.Select(ToDto).ToList() }; diff --git a/src/Elastic.Documentation/ReleaseNotes/Bundle.cs b/src/Elastic.Documentation/ReleaseNotes/Bundle.cs index 060d6ef71..be5bf36ba 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; } = []; + /// + /// Optional introductory description text for this bundle. + /// Rendered as introductory content after the release heading. + /// + public string? Description { 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. diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs index 83be2c41e..cd055e9d7 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs @@ -79,7 +79,7 @@ private static string RenderSingleBundle( }; var displayVersion = VersionOrDate.FormatDisplayVersion(bundle.Version); - return GenerateMarkdown(displayVersion, titleSlug, bundle.Repo, bundle.Owner, entriesByType, subsections, hideLinks, typeFilter, publishBlocker); + return GenerateMarkdown(displayVersion, titleSlug, bundle.Repo, bundle.Owner, entriesByType, subsections, hideLinks, typeFilter, publishBlocker, bundle.Data?.Description); } /// @@ -152,7 +152,8 @@ private static string GenerateMarkdown( bool subsections, bool hideLinks, ChangelogTypeFilter typeFilter, - PublishBlocker? publishBlocker) + PublishBlocker? publishBlocker, + string? description = null) { var sb = new StringBuilder(); @@ -176,6 +177,13 @@ private static string GenerateMarkdown( _ = sb.AppendLine(CultureInfo.InvariantCulture, $"## {title}"); + // Add description if present + if (!string.IsNullOrEmpty(description)) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(description); + } + // Check if we have any content at all var hasAnyContent = features.Count > 0 || enhancements.Count > 0 || security.Count > 0 || bugFixes.Count > 0 || docs.Count > 0 || regressions.Count > 0 || other.Count > 0 || diff --git a/src/services/Elastic.Changelog/Bundling/BundleDescriptionSubstitution.cs b/src/services/Elastic.Changelog/Bundling/BundleDescriptionSubstitution.cs new file mode 100644 index 000000000..ec52f02b0 --- /dev/null +++ b/src/services/Elastic.Changelog/Bundling/BundleDescriptionSubstitution.cs @@ -0,0 +1,57 @@ +// 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 + +namespace Elastic.Changelog.Bundling; + +/// +/// Helper for performing placeholder substitution in bundle descriptions. +/// Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. +/// +internal static class BundleDescriptionSubstitution +{ + /// + /// Substitutes placeholders in a description string with provided values. + /// + /// The description string containing placeholders + /// Version value for {version} placeholder + /// Lifecycle value for {lifecycle} placeholder + /// Owner value for {owner} placeholder + /// Repository value for {repo} placeholder + /// If true, validates that all used placeholders can be resolved + /// Description with placeholders substituted + /// When validateResolvable is true and placeholders cannot be resolved + public static string SubstitutePlaceholders( + string description, + string? version, + string? lifecycle, + string? owner, + string? repo, + bool validateResolvable = false) + { + if (string.IsNullOrEmpty(description)) + return description; + + if (validateResolvable) + { + var missingValues = new List(); + if (description.Contains("{version}") && string.IsNullOrEmpty(version)) + missingValues.Add("version"); + if (description.Contains("{lifecycle}") && string.IsNullOrEmpty(lifecycle)) + missingValues.Add("lifecycle"); + if (description.Contains("{owner}") && string.IsNullOrEmpty(owner)) + missingValues.Add("owner"); + if (description.Contains("{repo}") && string.IsNullOrEmpty(repo)) + missingValues.Add("repo"); + + if (missingValues.Count > 0) + throw new InvalidOperationException($"Cannot resolve placeholders: {string.Join(", ", missingValues)}"); + } + + return description + .Replace("{version}", version ?? string.Empty) + .Replace("{lifecycle}", lifecycle ?? string.Empty) + .Replace("{owner}", owner ?? string.Empty) + .Replace("{repo}", repo ?? string.Empty); + } +} diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 5316cf208..2b4d130a3 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -9,6 +9,7 @@ using Elastic.Changelog.Configuration; using Elastic.Changelog.GitHub; using Elastic.Changelog.Rendering; +using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Changelog; @@ -84,6 +85,12 @@ public record BundleChangelogsArguments /// public string[]? HideFeatures { get; init; } + /// + /// Optional bundle description with placeholder substitution. + /// Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. + /// + public string? Description { get; init; } + /// /// When non-null (including empty), PR/issue links are filtered to this owner/repo allowlist (from changelog.yml bundle.link_allow_repos). /// @@ -177,6 +184,9 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle if (!ValidateInput(collector, input)) return false; + if (!ValidatePlaceholderUsage(collector, input)) + return false; + if (!ValidateLinkAllowlist(collector, input)) return false; @@ -321,6 +331,29 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle } } + // Apply description with placeholder substitution + if (!string.IsNullOrEmpty(input.Description)) + { + var version = (input.OutputProducts?.Count > 0 ? input.OutputProducts[0].Target : null) + ?? (bundleData.Products.Count > 0 ? bundleData.Products[0].Target : null); + var lifecycle = (input.OutputProducts?.Count > 0 ? input.OutputProducts[0].Lifecycle : null) + ?? (bundleData.Products.Count > 0 ? bundleData.Products[0].Lifecycle?.ToStringFast(true) : null); + var owner = input.Owner ?? "elastic"; + var repo = input.Repo ?? (bundleData.Products.Count > 0 ? bundleData.Products[0].ProductId : null) ?? "unknown"; + + try + { + var substitutedDescription = BundleDescriptionSubstitution.SubstitutePlaceholders( + input.Description, version, lifecycle, owner, repo, validateResolvable: true); + bundleData = bundleData with { Description = substitutedDescription }; + } + catch (InvalidOperationException ex) + { + collector.EmitError(string.Empty, $"Description placeholder substitution failed: {ex.Message}"); + return false; + } + } + // Write bundle file await WriteBundleFileAsync(bundleData, outputPath, ctx); @@ -355,12 +388,13 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle if (filterResult == null) return null; - // Resolve bundle-specific output path, output products, repo, owner, and hide-features from profile + // Resolve bundle-specific output path, output products, repo, owner, hide-features, and description from profile string? outputPath = null; IReadOnlyList? outputProducts = null; string? repo = null; string? owner = null; string[]? mergedHideFeatures = null; + string? profileDescription = null; if (config?.Bundle?.Profiles != null && config.Bundle.Profiles.TryGetValue(input.Profile!, out var profile)) { @@ -402,6 +436,37 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle repo = profile.Repo ?? config.Bundle.Repo; owner = profile.Owner ?? config.Bundle.Owner; mergedHideFeatures = profile.HideFeatures?.Count > 0 ? [.. profile.HideFeatures] : null; + + // Handle profile-specific description with placeholder substitution + var descriptionTemplate = profile.Description ?? config.Bundle.Description; + if (!string.IsNullOrEmpty(descriptionTemplate)) + { + // Validate placeholder usage in profile mode + var hasVersionPlaceholder = descriptionTemplate.Contains("{version}") || descriptionTemplate.Contains("{lifecycle}"); + var hasOwnerRepoPlaceholder = descriptionTemplate.Contains("{owner}") || descriptionTemplate.Contains("{repo}"); + + if (hasVersionPlaceholder && + filterResult.Version == "unknown" && + string.IsNullOrEmpty(profile.OutputProducts)) + { + collector.EmitError(string.Empty, + $"Profile '{input.Profile}' uses {{version}} or {{lifecycle}} placeholders in description but no version is available for substitution. " + + "Either provide a version argument, or add 'output_products' pattern to the profile configuration."); + return null; + } + + if (hasOwnerRepoPlaceholder && + (string.IsNullOrEmpty(owner) || string.IsNullOrEmpty(repo))) + { + collector.EmitError(string.Empty, + $"Profile '{input.Profile}' uses {{owner}} or {{repo}} placeholders in description but values are not resolvable. " + + "Ensure repository metadata is available in the configuration."); + return null; + } + + profileDescription = BundleDescriptionSubstitution.SubstitutePlaceholders( + descriptionTemplate, filterResult.Version, resolvedLifecycle, owner, repo); + } } return input with @@ -414,7 +479,8 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle OutputProducts = outputProducts, Repo = repo, Owner = owner, - HideFeatures = mergedHideFeatures + HideFeatures = mergedHideFeatures, + Description = profileDescription }; } @@ -438,6 +504,9 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments var repo = input.Repo ?? config.Bundle.Repo; var owner = input.Owner ?? config.Bundle.Owner; + // Apply description: CLI takes precedence; fall back to bundle-level config default + var description = input.Description ?? config.Bundle.Description; + return input with { Directory = directory, @@ -445,6 +514,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments Resolve = resolve, Repo = repo, Owner = owner, + Description = description, LinkAllowRepos = config.Bundle.LinkAllowRepos }; } @@ -557,6 +627,30 @@ private bool ValidateInput(IDiagnosticsCollector collector, BundleChangelogsArgu return true; } + private static bool ValidatePlaceholderUsage(IDiagnosticsCollector collector, BundleChangelogsArguments input) + { + if (!string.IsNullOrEmpty(input.Profile)) + return true; + + if (string.IsNullOrEmpty(input.Description)) + return true; + + var hasPlaceholders = input.Description.Contains("{version}") || + input.Description.Contains("{lifecycle}") || + input.Description.Contains("{owner}") || + input.Description.Contains("{repo}"); + + if (hasPlaceholders && (input.OutputProducts == null || input.OutputProducts.Count == 0)) + { + collector.EmitError(string.Empty, + "When using placeholders in bundle description in option-based mode, " + + "--output-products must be explicitly specified to ensure predictable substitution values."); + return false; + } + + return true; + } + private static bool ValidateLinkAllowlist(IDiagnosticsCollector collector, BundleChangelogsArguments input) { if (input.LinkAllowRepos == null) diff --git a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs index 797b7371c..f07b82a80 100644 --- a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs +++ b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs @@ -517,6 +517,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Products = kvp.Value.Products, Output = kvp.Value.Output, OutputProducts = kvp.Value.OutputProducts, + Description = kvp.Value.Description, Repo = kvp.Value.Repo, Owner = kvp.Value.Owner, HideFeatures = kvp.Value.HideFeatures?.Values, @@ -529,6 +530,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Directory = yaml.Directory, OutputDirectory = yaml.OutputDirectory, Resolve = yaml.Resolve ?? true, + Description = yaml.Description, Repo = yaml.Repo, Owner = yaml.Owner, LinkAllowRepos = linkAllowRepos, diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index 95d98324f..97344fc15 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs @@ -54,6 +54,12 @@ public record CreateChangelogsFromReleaseArguments /// public bool WarnOnTypeMismatch { get; init; } = true; + /// + /// Optional bundle description text with placeholder support. + /// Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. + /// + public string? Description { get; init; } + /// /// Whether to create a bundle file after creating individual changelog files. Defaults to true. /// Set to false when called from 'changelog add --release-version' to skip bundle creation. @@ -181,7 +187,7 @@ Cancel ctx // 8. Optionally create bundle file if changelogs were created if (input.CreateBundle && createdFiles.Count > 0) { - var bundlePath = await CreateBundleViaService(collector, outputDir, createdFiles, productInfo, owner, repo, input.Config, ctx); + var bundlePath = await CreateBundleViaService(collector, outputDir, createdFiles, productInfo, owner, repo, input, ctx); if (bundlePath != null) _logger.LogInformation("Created bundle file: {BundlePath}", bundlePath); } @@ -306,7 +312,7 @@ private static string GenerateYaml(ChangelogEntry data) => ProductArgument productInfo, string owner, string repo, - string? configPath, + CreateChangelogsFromReleaseArguments input, Cancel ctx) { // Build the bundles subfolder path (mirrors the previous CreateBundleFile convention) @@ -334,8 +340,9 @@ private static string GenerateYaml(ChangelogEntry data) => Prs = prUrls, Owner = owner, Repo = repo, - Config = configPath, - OutputProducts = [productInfo] + Config = input.Config, + OutputProducts = [productInfo], + Description = input.Description }; var success = await _bundlingService.BundleChangelogs(collector, bundleArgs, ctx); diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs index 45856b7b5..edce4ad1e 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs @@ -32,6 +32,13 @@ public async Task RenderAsciidoc(ChangelogRenderContext context, Cancel ctx) _ = sb.AppendLine(InvariantCulture, $"== {context.Title}"); _ = sb.AppendLine(); + // Add description if present + if (!string.IsNullOrEmpty(context.BundleDescription)) + { + _ = sb.AppendLine(context.BundleDescription); + _ = sb.AppendLine(); + } + // Group entries by type var entriesByType = context.EntriesByType; var security = entriesByType.GetValueOrDefault(Security, []); diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs index 0e61ad942..a2f0d5633 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs @@ -26,4 +26,8 @@ public record ChangelogRenderContext public required Dictionary EntryToOwner { get; init; } public required Dictionary EntryToHideLinks { get; init; } public ChangelogConfiguration? Configuration { get; init; } + /// + /// Optional bundle-level introductory description. Only set when there's a single bundle with a description (MVP approach). + /// + public string? BundleDescription { get; init; } } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 099ea5587..c78d4ba27 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -133,8 +133,27 @@ Cancel ctx // Emit warnings for hidden entries EmitHiddenEntryWarnings(collector, resolvedResult.Entries, combinedHideFeatures); + // Extract descriptions from bundles for MVP support + var bundleDescriptions = validationResult.Bundles + .Select(b => b.Data.Description) + .Where(d => !string.IsNullOrEmpty(d)) + .ToList(); + + // MVP: Check for multiple descriptions and warn + string? renderDescription = null; + if (bundleDescriptions.Count > 1) + { + collector.EmitWarning(string.Empty, + $"Multiple bundles contain descriptions ({bundleDescriptions.Count} found). " + + "Multi-bundle description support is not yet implemented. Descriptions will be skipped."); + } + else if (bundleDescriptions.Count == 1) + { + renderDescription = bundleDescriptions[0]; + } + // Build render context - var context = BuildRenderContext(input, outputSetup, resolvedResult, combinedHideFeatures, config); + var context = BuildRenderContext(input, outputSetup, resolvedResult, combinedHideFeatures, config, renderDescription); // Validate entry types if (!ValidateEntryTypes(collector, resolvedResult.Entries, config.Types)) @@ -246,7 +265,8 @@ private static ChangelogRenderContext BuildRenderContext( OutputSetup outputSetup, ResolvedEntriesResult resolved, HashSet featureIdsToHide, - ChangelogConfiguration? config) + ChangelogConfiguration? config, + string? description = null) { // Group entries by type var entriesByType = resolved.Entries @@ -287,7 +307,8 @@ private static ChangelogRenderContext BuildRenderContext( EntryToRepo = entryToRepo, EntryToOwner = entryToOwner, EntryToHideLinks = entryToHideLinks, - Configuration = config + Configuration = config, + BundleDescription = description }; } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index e00af54ca..3d9b19234 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -52,6 +52,13 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct var sb = new StringBuilder(); _ = sb.AppendLine(InvariantCulture, $"## {context.Title} [{context.Repo}-release-notes-{context.TitleSlug}]"); + // Add description if present + if (!string.IsNullOrEmpty(context.BundleDescription)) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(context.BundleDescription); + } + if (otherLinks.Count > 0) { var linksText = string.Join(" and ", otherLinks); diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs index f74818c6a..372be436f 100644 --- a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs +++ b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs @@ -280,6 +280,11 @@ internal record BundleConfigurationYaml /// public bool? Resolve { get; set; } + /// + /// Default bundle description used when no profile-specific description is provided. + /// + public string? Description { get; set; } + /// /// Default GitHub repository name applied to all profiles that do not specify their own. /// @@ -324,6 +329,11 @@ internal record BundleProfileYaml /// public string? OutputProducts { get; set; } + /// + /// Profile-specific bundle description. Overrides bundle.description when provided. + /// + public string? Description { get; set; } + /// /// GitHub repository name for generating PR/issue links in bundle output. /// diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index aae2b44fc..f071af8c6 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -497,6 +497,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// Include all changelogs in the directory. /// 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 + /// Optional: Bundle description text with placeholder support. Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. Overrides bundle.description from config. In option-based mode, placeholders require --output-products to be explicitly specified. /// 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 CLI render or {changelog} directive). /// Filter by products in format "product target lifecycle, ..." (for example, "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). /// Filter by issue URLs (comma-separated), or a path to a newline-delimited file containing fully-qualified GitHub issue URLs. Can be specified multiple times. @@ -519,6 +520,7 @@ public async Task Bundle( bool all = false, string? config = null, string? directory = null, + string? description = null, string[]? hideFeatures = null, [ProductInfoParser] List? inputProducts = null, string? output = null, @@ -624,6 +626,8 @@ public async Task Bundle( forbidden.Add("--config"); if (!string.IsNullOrWhiteSpace(directory)) forbidden.Add("--directory"); + if (!string.IsNullOrWhiteSpace(description)) + forbidden.Add("--description"); if (forbidden.Count > 0) { @@ -774,7 +778,8 @@ public async Task Bundle( Output = processedOutput, Profile = profile, ProfileArgument = profileArg, - Config = config + Config = config, + Description = description }; var planResult = await service.PlanBundleAsync(collector, planInput, releaseVersion != null, ctx); if (planResult == null) @@ -809,7 +814,8 @@ public async Task Bundle( ProfileReport = isProfileMode ? profileReport : null, Report = !isProfileMode ? report : null, Config = config, - HideFeatures = allFeatureIdsForBundle.Count > 0 ? allFeatureIdsForBundle.ToArray() : null + HideFeatures = allFeatureIdsForBundle.Count > 0 ? allFeatureIdsForBundle.ToArray() : null, + Description = description }; serviceInvoker.AddCommand(service, input, @@ -1120,6 +1126,7 @@ async static (s, collector, state, ctx) => await s.RenderChangelogs(collector, s /// Required: GitHub repository in owner/repo format (e.g., "elastic/elasticsearch" or just "elasticsearch" which defaults to elastic/elasticsearch) /// Optional: Version tag to fetch (e.g., "v9.0.0", "9.0.0"). Defaults to "latest" /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' + /// Optional: Bundle description text with placeholder support. Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. Overrides bundle.description from config. /// Optional: Output directory for changelog files. Falls back to bundle.directory in changelog.yml when not specified. Defaults to './changelogs' /// Optional: Remove square brackets and text within them from the beginning of PR titles (e.g., "[Inference API] Title" becomes "Title") /// Optional: Warn when the type inferred from release notes section headers doesn't match the type derived from PR labels. Defaults to true @@ -1129,6 +1136,7 @@ public async Task GitHubRelease( [Argument] string repo, [Argument] string version = "latest", string? config = null, + string? description = null, string? output = null, bool stripTitlePrefix = false, bool warnOnTypeMismatch = true, @@ -1156,7 +1164,8 @@ public async Task GitHubRelease( Config = config, Output = resolvedOutput, StripTitlePrefix = stripTitlePrefixResolved, - WarnOnTypeMismatch = warnOnTypeMismatch + WarnOnTypeMismatch = warnOnTypeMismatch, + Description = description }; serviceInvoker.AddCommand(service, input, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index d508f5271..d7a671b0f 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -7,6 +7,7 @@ using Elastic.Changelog.Bundling; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; namespace Elastic.Changelog.Tests.Changelogs; @@ -3970,7 +3971,7 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, { Directory = _changelogDir, Prs = [prsFile], - Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -4011,7 +4012,7 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, { Directory = _changelogDir, Issues = [issuesFile], - Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -5959,4 +5960,105 @@ public async Task BundleChangelogs_PartialPerProductRules_AllOrNothingReplacemen var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); bundleContent.Should().Contain("1755268204-partial-rule.yaml", "entry should be included - per-product rule ignores global type exclusions"); } + + [Fact] + public async Task BundleChangelogs_OptionModeWithPlaceholdersButNoOutputProducts_ReturnsError() + { + // Arrange + CreateSampleChangelogs(); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + Output = outputPath, + Description = "Release includes {version} with {lifecycle} features from {owner}/{repo}" + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse("bundling should fail when placeholders are used without --output-products"); + Collector.Errors.Should().Be(1, "should have exactly one validation error"); + Collector.Diagnostics.Should().Contain(d => d.Message.Contains( + "When using placeholders in bundle description in option-based mode, --output-products must be explicitly specified to ensure predictable substitution values.")); + } + + [Fact] + public async Task BundleChangelogs_OptionModeWithPlaceholdersAndOutputProducts_Succeeds() + { + // Arrange + CreateSampleChangelogs(); + var outputProducts = new List + { + new() { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" } + }; + + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + Output = outputPath, + OutputProducts = outputProducts, + Description = "Release includes {version} with {lifecycle} features from {owner}/{repo}", + Owner = "elastic", + Repo = "elasticsearch" + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue("bundling should succeed when placeholders have --output-products"); + Collector.Errors.Should().Be(0, "no errors expected when validation passes"); + + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); + bundleContent.Should().Contain("Release includes 9.2.0 with ga features from elastic/elasticsearch", + "placeholders should be substituted correctly"); + } + + [Fact] + public async Task BundleChangelogs_OptionModeWithConfigDescriptionAndPlaceholders_ReturnsError() + { + // Arrange - config-provided description with placeholders but no --output-products + CreateSampleChangelogs(); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + Output = outputPath, + Description = "Version {version} includes {lifecycle} updates from {owner}/{repo}" + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse("bundling should fail when description has placeholders without --output-products"); + Collector.Errors.Should().Be(1, "should have exactly one validation error"); + Collector.Diagnostics.Should().Contain(d => d.Message.Contains( + "When using placeholders in bundle description in option-based mode, --output-products must be explicitly specified to ensure predictable substitution values.")); + } + + + private void CreateSampleChangelogs() + { + // language=yaml + var changelog1 = + """ + title: First changelog + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + + FileSystem.File.WriteAllText(FileSystem.Path.Join(_changelogDir, "changelog1.yaml"), changelog1); + } } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleDescriptionSubstitutionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleDescriptionSubstitutionTests.cs new file mode 100644 index 000000000..99353bc81 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleDescriptionSubstitutionTests.cs @@ -0,0 +1,108 @@ +// 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 AwesomeAssertions; +using Elastic.Changelog.Bundling; + +namespace Elastic.Changelog.Tests.Changelogs; + +public class BundleDescriptionSubstitutionTests +{ + [Fact] + public void SubstitutePlaceholders_AllPlaceholdersResolved_ReturnsSubstitutedString() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + "Release {version} ({lifecycle}) from {owner}/{repo}", + "9.2.0", "ga", "elastic", "elasticsearch"); + + result.Should().Be("Release 9.2.0 (ga) from elastic/elasticsearch"); + } + + [Fact] + public void SubstitutePlaceholders_NullValues_ReplacedWithEmptyString() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + "Version {version} by {owner}", null, null, null, null); + + result.Should().Be("Version by "); + } + + [Fact] + public void SubstitutePlaceholders_EmptyDescription_ReturnsEmpty() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + "", "9.2.0", "ga", "elastic", "elasticsearch"); + + result.Should().BeEmpty(); + } + + [Fact] + public void SubstitutePlaceholders_NoPlaceholders_ReturnsOriginal() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + "Just a plain description.", "9.2.0", "ga", "elastic", "elasticsearch"); + + result.Should().Be("Just a plain description."); + } + + [Fact] + public void SubstitutePlaceholders_PartialPlaceholders_OnlySubstitutesPresent() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + "Download: https://github.com/{owner}/{repo}/releases", + null, null, "elastic", "elasticsearch"); + + result.Should().Be("Download: https://github.com/elastic/elasticsearch/releases"); + } + + [Fact] + public void SubstitutePlaceholders_ValidateResolvable_ThrowsWhenVersionMissing() + { + var act = () => BundleDescriptionSubstitution.SubstitutePlaceholders( + "Release {version}", null, null, null, null, validateResolvable: true); + + act.Should().Throw() + .WithMessage("*version*"); + } + + [Fact] + public void SubstitutePlaceholders_ValidateResolvable_ThrowsWhenMultipleMissing() + { + var act = () => BundleDescriptionSubstitution.SubstitutePlaceholders( + "v{version} ({lifecycle}) from {owner}/{repo}", + null, null, null, null, validateResolvable: true); + + act.Should().Throw() + .WithMessage("*version*lifecycle*owner*repo*"); + } + + [Fact] + public void SubstitutePlaceholders_ValidateResolvable_SucceedsWhenAllProvided() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + "v{version} from {owner}/{repo}", + "9.2.0", "ga", "elastic", "elasticsearch", validateResolvable: true); + + result.Should().Be("v9.2.0 from elastic/elasticsearch"); + } + + [Fact] + public void SubstitutePlaceholders_ValidateResolvable_IgnoresUnusedNullValues() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + "Download from {owner}/{repo}", + null, null, "elastic", "elasticsearch", validateResolvable: true); + + result.Should().Be("Download from elastic/elasticsearch"); + } + + [Fact] + public void SubstitutePlaceholders_NullDescription_ReturnsNull() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + null!, "9.2.0", "ga", "elastic", "elasticsearch"); + + result.Should().BeNull(); + } +} diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs index c24670546..3887682e1 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs @@ -1090,6 +1090,100 @@ public void LoadBundles_HideFeaturesSerializesAndDeserializesCorrectly() _warnings.Should().BeEmpty(); } + [Fact] + public void LoadBundles_DescriptionSerializesAndDeserializesCorrectly() + { + // Arrange - Test round-trip serialization of description field + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + var multilineDescription = """ + This is a test description with multiple paragraphs. + + It includes: + - A bullet list + - Multiple lines + - And a [link](https://example.com) for testing + + This ensures proper YAML serialization and deserialization. + """; + + var originalBundle = new Bundle + { + Products = + [ + new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" } + ], + Description = multilineDescription, + 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); + + // Normalize line endings for cross-platform compatibility + var actualDescription = bundles[0].Data.Description?.Replace("\r\n", "\n").Replace("\r", "\n"); + var expectedDescription = multilineDescription.Replace("\r\n", "\n").Replace("\r", "\n"); + actualDescription.Should().Be(expectedDescription); + + _warnings.Should().BeEmpty(); + } + + [Fact] + public void LoadBundles_DescriptionCanBeNull() + { + // Arrange - Test that null description is handled correctly + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + var originalBundle = new Bundle + { + Products = + [ + new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" } + ], + Description = null, + 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].Data.Description.Should().BeNull(); + _warnings.Should().BeEmpty(); + } + [Fact] public void LoadedBundle_HideFeatures_ExposedFromBundleData() {