diff --git a/docs/cli/release/changelog-bundle.md b/docs/cli/release/changelog-bundle.md index 223800c4c..db300c391 100644 --- a/docs/cli/release/changelog-bundle.md +++ b/docs/cli/release/changelog-bundle.md @@ -17,6 +17,7 @@ docs-builder changelog bundle [options...] [-h|--help] `--all` : Include all changelogs from the directory. +: Only one filter option can be specified: `--all`, `--input-products`, or `--prs`. `--directory ` : Optional: The directory that contains the changelog YAML files. @@ -24,27 +25,36 @@ docs-builder changelog bundle [options...] [-h|--help] `--input-products ?>` : Filter by products in format "product target lifecycle, ..." -: For example, `cloud-serverless 2025-12-02, cloud-serverless 2025-12-06`. +: Only one filter option can be specified: `--all`, `--input-products`, or `--prs`. +: When specified, all three parts (product, target, lifecycle) are required but can be wildcards (`*`). For example: + +- `"cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta"` - exact matches +- `"cloud-serverless 2025-12-02 *"` - match cloud-serverless 2025-12-02 with any lifecycle +- `"elasticsearch * *"` - match all elasticsearch changelogs +- `"* 9.3.* *"` - match any product with target starting with "9.3." +- `"* * *"` - match all changelogs (equivalent to `--all`) `--output ` : Optional: The output file path for the bundle. : Defaults to `changelog-bundle.yaml` in the input directory. `--output-products ?>` -: Explicitly set the products array in the output file in format "product target lifecycle, ...". +: Optional: Explicitly set the products array in the output file in format "product target lifecycle, ...". : This value replaces information that would otherwise by derived from changelogs. `--owner ` -: Optional: The GitHub repository owner, which is required when pull requests are specified as numbers. +: The GitHub repository owner, which is required when pull requests are specified as numbers. `--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`. : Each occurrence can be either comma-separated PRs (e.g., `--prs "https://github.com/owner/repo/pull/123,6789"`) or a file path (e.g., `--prs /path/to/file.txt`). : When specifying PRs directly, provide comma-separated values. : When specifying a file path, provide a single value that points to a newline-delimited file. `--repo ` -: Optional: The GitHub repository name, which is required when PRs are specified as numbers. +: The GitHub repository name, which is required when PRs are specified as numbers. `--resolve` -: Copy the contents of each changelog file into the entries array. +: Optional: Copy the contents of each changelog file into the entries array. +: By default, the bundle contains only the file names and checksums. diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 790ef927e..a44da735f 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -148,45 +148,55 @@ Bundle changelogs Options: --directory Optional: Directory containing changelog YAML files. Defaults to current directory [Default: null] --output Optional: Output file path for the bundled changelog. Defaults to 'changelog-bundle.yaml' in the input directory [Default: null] - --all Include all changelogs in the directory - --input-products ?> Filter by products in format "product target lifecycle, ..." (e.g., "cloud-serverless 2025-12-02, cloud-serverless 2025-12-06") [Default: null] - --output-products ?> Explicitly set the products array in the output file in format "product target lifecycle, ...". Overrides any values from changelogs. [Default: null] - --resolve Copy the contents of each changelog file into the entries array - --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. [Default: null] - --owner Optional: GitHub repository owner (used when PRs are specified as numbers) [Default: null] - --repo Optional: GitHub repository name (used when PRs are specified as numbers) [Default: null] + --all Include all changelogs in the directory. Only one filter option can be specified: `--all`, `--input-products`, or `--prs`. + --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-products ?> Optional: Explicitly set the products array in the output file in format "product target lifecycle, ...". Overrides any values from changelogs. [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. + --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] + --owner GitHub repository owner (required only when PRs are specified as numbers) [Default: null] + --repo GitHub repository name (required only when PRs are specified as numbers) [Default: null] ``` You can specify only one of the following filter options: -`--all` -: Include all changelogs from the directory. - -`--input-products` -: Include changelogs for the specified products. -: The format aligns with [](#product-format). -: For example, `"cloud-serverless 2025-12-02, cloud-serverless 2025-12-06"`. - -`--prs` -: Include changelogs for the specified pull request URLs or numbers, or a path to a newline-delimited file containing PR URLs or numbers. Can be specified multiple times. -: Each occurrence can be either comma-separated PRs (e.g., `--prs "https://github.com/owner/repo/pull/123,12345"`) or a file path (e.g., `--prs /path/to/file.txt`). -: When specifying PRs 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 PR URL or number per line. -: Pull requests can be identified by a full URL (such as `https://github.com/owner/repo/pull/123`), a short format (such as `owner/repo#123`), or just a number (in which case you must also provide `--owner` and `--repo` options). +- `--all`: Include all changelogs from the directory. +- `--input-products`: Include changelogs for the specified products. Refer to [Filter by product](#changelog-bundle-pr). +- `--prs`: Include changelogs for the specified pull request URLs or numbers, or a path to a newline-delimited file containing PR URLs or numbers. Go to [Filter by pull requests](#changelog-bundle-pr). By default, the output file contains only the changelog file names and checksums. You can optionally use the `--resolve` command option to pull all of the content from each changelog into the bundle. ### Filter by product [changelog-bundle-product] -You can use the `--input-products` option to create a bundle of changelogs that match the product details: +You can use the `--input-products` option to create a bundle of changelogs that match the product details. +When using `--input-products`, you must provide all three parts: product, target, and lifecycle. +Each part can be a wildcard (`*`) to match any value. ```sh docs-builder changelog bundle \ - --input-products "cloud-serverless 2025-12-02, cloud-serverless 2025-12-06" <1> + --input-products "cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta" <1> ``` -1. Include all changelogs that have the `cloud-serverless` product identifier and target dates of either December 2 2025 or December 12 2025. For more information about product values, refer to [](#product-format). +1. Include all changelogs that have the `cloud-serverless` product identifier with target dates of either December 2 2025 (lifecycle `ga`) or December 6 2025 (lifecycle `beta`). For more information about product values, refer to [](#product-format). + +You can use wildcards in any of the three parts: + +```sh +# Bundle any changelogs that have exact matches for either of these clauses +docs-builder changelog bundle --input-products "cloud-serverless 2025-12-02 ga, elasticsearch 9.3.0 beta" + +# Bundle all elasticsearch changelogs regardless of target or lifecycle +docs-builder changelog bundle --input-products "elasticsearch * *" + +# Bundle all cloud-serverless 2025-12-02 changelogs with any lifecycle +docs-builder changelog bundle --input-products "cloud-serverless 2025-12-02 *" + +# Bundle any cloud-serverless changelogs with target starting with "2025-11-" and "ga" lifecycle +docs-builder changelog bundle --input-products "cloud-serverless 2025-11-* ga" + +# Bundle all changelogs (equivalent to --all) +docs-builder changelog bundle --input-products "* * *" +``` If you have changelog files that reference those product details, the command creates a file like this: @@ -205,22 +215,27 @@ entries: checksum: 70d197d96752c05b6595edffe6fe3ba3d055c845 ``` -1. By default these values match your `--input-products` (even if the changelogs have more products). To specify different product metadata, use the `--output-products` option. +1. By default these values match your `--input-products` (even if the changelogs have more products). +To specify different product metadata, use the `--output-products` option. If you add the `--resolve` option, the contents of each changelog will be included in the output file. ### Filter by pull requests [changelog-bundle-pr] -You can use the `--prs` option (with the `--repo` and `--owner` options if you provide only the PR numbers) to create a bundle of the changelogs that relate to those pull requests: +You can use the `--prs` option to create a bundle of the changelogs that relate to those pull requests. +You can provide either a comma-separated list of PRs (`--prs "https://github.com/owner/repo/pull/123,12345"`) or a path to a newline-delimited file (`--prs /path/to/file.txt`). +In the latter case, the file should contain one PR URL or number per line. + +Pull requests can be identified by a full URL (such as `https://github.com/owner/repo/pull/123`), a short format (such as `owner/repo#123`), or just a number (in which case you must also provide `--owner` and `--repo` options). ```sh docs-builder changelog bundle --prs "108875,135873,136886" \ <1> --repo elasticsearch \ <2> --owner elastic \ <3> - --output-products "elasticsearch 9.2.2" <4> + --output-products "elasticsearch 9.2.2 ga" <4> ``` -1. The comma-separated list of pull request numbers to seek. You can also specify multiple `--prs` options, each with comma-separated PRs or a file path. +1. The comma-separated list of pull request numbers to seek. 2. The repository in the pull request URLs. This option is not required if you specify the short or full PR URLs in the `--prs` option. 3. The owner in the pull request URLs. This option is not required if you specify the short or full PR URLs in the `--prs` option. 4. The product metadata for the bundle. If it is not provided, it will be derived from all the changelog product values. @@ -231,6 +246,7 @@ If you have changelog files that reference those pull requests, the command crea products: - product: elasticsearch target: 9.2.2 + lifecycle: ga entries: - file: name: 1765507819-fix-ml-calendar-event-update-scalability-issues.yaml @@ -262,7 +278,7 @@ You can use the `--prs` option with a file path to create a bundle of the change ./docs-builder changelog bundle \ --prs "https://github.com/elastic/elasticsearch/pull/108875,135873" \ <1> --prs test/9.2.2.txt \ <2> - --output-products "elasticsearch 9.2.2" <3> + --output-products "elasticsearch 9.2.2 ga" <3> --resolve <4> ``` @@ -277,6 +293,7 @@ If you have changelog files that reference those pull requests, the command crea products: - product: elasticsearch target: 9.2.2 + lifecycle: ga entries: - file: name: 1765507778-break-on-fielddata-when-building-global-ordinals.yaml @@ -291,6 +308,10 @@ entries: ... ``` +:::{note} +When a changelog matches multiple `--input-products` filters, it appears only once in the bundle. This deduplication applies even when using `--all` or `--prs`. +::: + ## Create documentation [render-changelogs] The `docs-builder changelog render` command creates markdown files from changelog bundles for documentation purposes. diff --git a/src/services/Elastic.Documentation.Services/Changelog/BundledChangelogData.cs b/src/services/Elastic.Documentation.Services/Changelog/BundledChangelogData.cs index c69cd2b42..d07e3c521 100644 --- a/src/services/Elastic.Documentation.Services/Changelog/BundledChangelogData.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/BundledChangelogData.cs @@ -17,6 +17,7 @@ public class BundledProduct { public string Product { get; set; } = string.Empty; public string? Target { get; set; } + public string? Lifecycle { get; set; } } public class BundledEntry diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index aa042ff14..520128b79 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -583,26 +583,60 @@ Cancel ctx } // Validate filter options - var filterCount = 0; + var specifiedFilters = new List(); if (input.All) - filterCount++; + specifiedFilters.Add("--all"); if (input.InputProducts is { Count: > 0 }) - filterCount++; + specifiedFilters.Add("--input-products"); if (input.Prs is { Length: > 0 }) - filterCount++; + specifiedFilters.Add("--prs"); - if (filterCount == 0) + if (specifiedFilters.Count == 0) { collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --input-products, or --prs"); return false; } - if (filterCount > 1) + if (specifiedFilters.Count > 1) { - collector.EmitError(string.Empty, "Only one filter option can be specified at a time: --all, --input-products, or --prs"); + collector.EmitError(string.Empty, $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specifiedFilters)}. Please use only one filter option: --all, --input-products, or --prs"); return false; } + // Build product filter patterns (with wildcard support) + var productFilters = new List<(string? productPattern, string? targetPattern, string? lifecyclePattern)>(); + if (input.InputProducts is { Count: > 0 }) + { + foreach (var product in input.InputProducts) + { + productFilters.Add(( + product.Product == "*" ? null : product.Product, + product.Target == "*" ? null : product.Target, + product.Lifecycle == "*" ? null : product.Lifecycle + )); + } + } + + // Helper function to check if a string matches a pattern (supports wildcards) + static bool MatchesPattern(string? value, string? pattern) + { + if (pattern == null) + return true; // Wildcard matches anything (including null/empty) + + if (value == null) + return false; // Non-wildcard pattern doesn't match null + + // If pattern ends with *, do prefix match + if (pattern.EndsWith('*')) + { + var prefix = pattern[..^1]; + return value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + } + + // Exact match (case-insensitive) + return string.Equals(value, pattern, StringComparison.OrdinalIgnoreCase); + } + // Load PRs - check if --prs contains a file path or a list of PRs var prsToMatch = new HashSet(StringComparer.OrdinalIgnoreCase); if (input.Prs is { Length: > 0 }) @@ -811,16 +845,7 @@ Cancel ctx } } - // Build set of product/version combinations to filter by - var productsToMatch = new HashSet<(string product, string version)>(); - if (input.InputProducts is { Count: > 0 }) - { - foreach (var product in input.InputProducts) - { - var version = product.Target ?? string.Empty; - _ = productsToMatch.Add((product.Product.ToLowerInvariant(), version)); - } - } + // Product filters are already built above with wildcard support // Determine output path to exclude it from input files var outputPath = input.Output ?? _fileSystem.Path.Combine(input.Directory, "changelog-bundle.yaml"); @@ -877,6 +902,7 @@ Cancel ctx var changelogEntries = new List<(ChangelogData data, string filePath, string fileName, string checksum)>(); var matchedPrs = new HashSet(StringComparer.OrdinalIgnoreCase); + var seenChangelogs = new HashSet(); // For deduplication (using checksum) foreach (var filePath in yamlFiles) { @@ -905,19 +931,41 @@ Cancel ctx continue; } + // Check for duplicates (using checksum) + if (seenChangelogs.Contains(checksum)) + { + _logger.LogDebug("Skipping duplicate changelog: {FileName} (checksum: {Checksum})", fileName, checksum); + continue; + } + // Apply filters if (input.All) { - // Include all + // Include all - no filtering needed } - else if (productsToMatch.Count > 0) + else if (productFilters.Count > 0) { - // Filter by products - var matches = data.Products.Any(p => + // Filter by products with wildcard support + var matches = false; + foreach (var (productPattern, targetPattern, lifecyclePattern) in productFilters) { - var version = p.Target ?? string.Empty; - return productsToMatch.Contains((p.Product.ToLowerInvariant(), version)); - }); + // Check if any product in the changelog matches this filter + foreach (var changelogProduct in data.Products) + { + var productMatches = MatchesPattern(changelogProduct.Product, productPattern); + var targetMatches = MatchesPattern(changelogProduct.Target, targetPattern); + var lifecycleMatches = MatchesPattern(changelogProduct.Lifecycle, lifecyclePattern); + + if (productMatches && targetMatches && lifecycleMatches) + { + matches = true; + break; + } + } + + if (matches) + break; + } if (!matches) { @@ -950,6 +998,8 @@ Cancel ctx } } + // Add to seen set and entries list + _ = seenChangelogs.Add(checksum); changelogEntries.Add((data, filePath, fileName, checksum)); } catch (YamlException ex) @@ -991,46 +1041,63 @@ Cancel ctx bundledData.Products = input.OutputProducts .OrderBy(p => p.Product) .ThenBy(p => p.Target ?? string.Empty) + .ThenBy(p => p.Lifecycle ?? string.Empty) .Select(p => new BundledProduct { Product = p.Product, - Target = p.Target + Target = p.Target == "*" ? null : p.Target, + Lifecycle = p.Lifecycle == "*" ? null : p.Lifecycle }) .ToList(); } - // If --input-products filter was used, only include those specific product-versions - else if (productsToMatch.Count > 0) + // If --input-products was specified (and --output-products was not), extract from matched changelog entries + // This ensures the products array reflects the actual values from the changelogs, not the filter + else if (input.InputProducts is { Count: > 0 } && changelogEntries.Count > 0) { - bundledData.Products = productsToMatch + var productVersions = new HashSet<(string product, string version, string? lifecycle)>(); + foreach (var (data, _, _, _) in changelogEntries) + { + foreach (var product in data.Products) + { + var version = product.Target ?? string.Empty; + _ = productVersions.Add((product.Product, version, product.Lifecycle)); + } + } + + bundledData.Products = productVersions .OrderBy(pv => pv.product) .ThenBy(pv => pv.version) + .ThenBy(pv => pv.lifecycle ?? string.Empty) .Select(pv => new BundledProduct { Product = pv.product, - Target = string.IsNullOrWhiteSpace(pv.version) ? null : pv.version + Target = string.IsNullOrWhiteSpace(pv.version) ? null : pv.version, + Lifecycle = pv.lifecycle }) .ToList(); } - // Otherwise, extract unique products/versions from changelog entries + // Otherwise, extract unique products/versions/lifecycles from changelog entries else if (changelogEntries.Count > 0) { - var productVersions = new HashSet<(string product, string version)>(); + var productVersions = new HashSet<(string product, string version, string? lifecycle)>(); foreach (var (data, _, _, _) in changelogEntries) { foreach (var product in data.Products) { var version = product.Target ?? string.Empty; - _ = productVersions.Add((product.Product, version)); + _ = productVersions.Add((product.Product, version, product.Lifecycle)); } } bundledData.Products = productVersions .OrderBy(pv => pv.product) .ThenBy(pv => pv.version) + .ThenBy(pv => pv.lifecycle ?? string.Empty) .Select(pv => new BundledProduct { Product = pv.product, - Target = string.IsNullOrWhiteSpace(pv.version) ? null : pv.version + Target = string.IsNullOrWhiteSpace(pv.version) ? null : pv.version, + Lifecycle = pv.lifecycle }) .ToList(); } @@ -1054,7 +1121,15 @@ Cancel ctx foreach (var productGroup in productsByProductId) { - var targets = productGroup.Select(p => string.IsNullOrWhiteSpace(p.Target) ? "(no target)" : p.Target).ToList(); + var targets = productGroup.Select(p => + { + var target = string.IsNullOrWhiteSpace(p.Target) ? "(no target)" : p.Target; + if (!string.IsNullOrWhiteSpace(p.Lifecycle)) + { + target = $"{target} {p.Lifecycle}"; + } + return target; + }).ToList(); collector.EmitWarning(string.Empty, $"Product '{productGroup.Key}' has multiple targets in bundle: {string.Join(", ", targets)}"); } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index ccf11f990..a3beec77d 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -107,13 +107,13 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// /// Optional: Directory containing changelog YAML files. Defaults to current directory /// Optional: Output file path for the bundled changelog. Defaults to 'changelog-bundle.yaml' in the input directory - /// Include all changelogs in the directory - /// Filter by products in format "product target lifecycle, ..." (e.g., "cloud-serverless 2025-12-02, cloud-serverless 2025-12-06") - /// Explicitly set the products array in the output file in format "product target lifecycle, ...". Overrides any values from changelogs. - /// Copy the contents of each changelog file into the entries array - /// 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. - /// Optional: GitHub repository owner (used when PRs are specified as numbers) - /// Optional: GitHub repository name (used when PRs are specified as numbers) + /// Include all changelogs in the directory. Only one filter option can be specified: `--all`, `--input-products`, or `--prs`. + /// 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: Explicitly set the products array in the output file in format "product target lifecycle, ...". Overrides any values from changelogs. + /// Optional: Copy the contents of each changelog file into the entries array. By default, the bundle contains only the file names and checksums. + /// 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 owner (required only when PRs are specified as numbers) + /// GitHub repository name (required only when PRs are specified as numbers) /// [Command("bundle")] public async Task Bundle( @@ -155,6 +155,82 @@ public async Task Bundle( } } + // Validate filter options - at least one must be specified + var specifiedFilters = new List(); + if (all) + specifiedFilters.Add("--all"); + if (inputProducts != null && inputProducts.Count > 0) + specifiedFilters.Add("--input-products"); + if (allPrs.Count > 0) + specifiedFilters.Add("--prs"); + + if (specifiedFilters.Count == 0) + { + collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --input-products, or --prs"); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + + if (specifiedFilters.Count > 1) + { + collector.EmitError(string.Empty, $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specifiedFilters)}. Please use only one filter option: --all, --input-products, or --prs"); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + + // Validate that if inputProducts is provided, all three parts (product, target, lifecycle) are present for each entry + // They can be wildcards (*) but must be present + if (inputProducts != null && inputProducts.Count > 0) + { + foreach (var product in inputProducts) + { + if (string.IsNullOrWhiteSpace(product.Product)) + { + collector.EmitError(string.Empty, "--input-products: product is required (use '*' for wildcard)"); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + + // When --input-products is used, target and lifecycle are required (but can be "*") + // If they're null, it means they weren't provided in the input + if (product.Target == null) + { + collector.EmitError(string.Empty, $"--input-products: target is required for product '{product.Product}' (use '*' for wildcard)"); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + + if (product.Lifecycle == null) + { + collector.EmitError(string.Empty, $"--input-products: lifecycle is required for product '{product.Product}' (use '*' for wildcard)"); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + } + + // Check if --input-products * * * is specified (equivalent to --all) + var isAllWildcard = inputProducts.Count == 1 && + inputProducts[0].Product == "*" && + inputProducts[0].Target == "*" && + inputProducts[0].Lifecycle == "*"; + + if (isAllWildcard) + { + all = true; + inputProducts = null; // Clear inputProducts so service treats it as --all + } + } + var input = new ChangelogBundleInput { Directory = directory ?? Directory.GetCurrentDirectory(), diff --git a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs index 21ad65a2e..59323a086 100644 --- a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs +++ b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs @@ -897,6 +897,7 @@ public async Task BundleChangelogs_WithProductsFilter_FiltersCorrectly() products: - product: elasticsearch target: 9.2.0 + lifecycle: ga pr: https://github.com/elastic/elasticsearch/pull/100 """; var changelog2 = """ @@ -905,6 +906,7 @@ public async Task BundleChangelogs_WithProductsFilter_FiltersCorrectly() products: - product: kibana target: 9.2.0 + lifecycle: ga pr: https://github.com/elastic/kibana/pull/200 """; @@ -916,7 +918,7 @@ public async Task BundleChangelogs_WithProductsFilter_FiltersCorrectly() var input = new ChangelogBundleInput { Directory = changelogDir, - InputProducts = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + InputProducts = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") }; @@ -1197,7 +1199,7 @@ public async Task BundleChangelogs_WithNoMatchingFiles_ReturnsError() var input = new ChangelogBundleInput { Directory = changelogDir, - InputProducts = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + InputProducts = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") }; @@ -1243,6 +1245,31 @@ public async Task BundleChangelogs_WithNoFilterOption_ReturnsError() var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); fileSystem.Directory.CreateDirectory(changelogDir); + // Create test changelog files + var changelog1 = """ + title: First changelog + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + var changelog2 = """ + title: Second changelog + type: enhancement + products: + - product: kibana + target: 9.2.0 + lifecycle: ga + pr: https://github.com/elastic/kibana/pull/200 + """; + + var file1 = fileSystem.Path.Combine(changelogDir, "1755268130-first-changelog.yaml"); + var file2 = fileSystem.Path.Combine(changelogDir, "1755268140-second-changelog.yaml"); + await fileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + await fileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); + var input = new ChangelogBundleInput { Directory = changelogDir, @@ -1271,7 +1298,7 @@ public async Task BundleChangelogs_WithMultipleFilterOptions_ReturnsError() { Directory = changelogDir, All = true, - InputProducts = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + InputProducts = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") }; @@ -1281,7 +1308,7 @@ public async Task BundleChangelogs_WithMultipleFilterOptions_ReturnsError() // Assert result.Should().BeFalse(); _collector.Errors.Should().BeGreaterThan(0); - _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Only one filter option can be specified")); + _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Multiple filter options cannot be specified together")); } [Fact] @@ -1320,8 +1347,8 @@ public async Task BundleChangelogs_WithMultipleProducts_CreatesValidBundle() { Directory = changelogDir, InputProducts = [ - new ProductInfo { Product = "cloud-serverless", Target = "2025-12-02" }, - new ProductInfo { Product = "cloud-serverless", Target = "2025-12-06" } + new ProductInfo { Product = "cloud-serverless", Target = "2025-12-02", Lifecycle = "*" }, + new ProductInfo { Product = "cloud-serverless", Target = "2025-12-06", Lifecycle = "*" } ], Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") }; @@ -1341,6 +1368,177 @@ public async Task BundleChangelogs_WithMultipleProducts_CreatesValidBundle() bundleContent.Should().Contain("name: 1755268140-cloud-feature2.yaml"); } + [Fact] + public async Task BundleChangelogs_WithWildcardProductFilter_MatchesAllProducts() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog files + 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 changelog2 = """ + title: Kibana feature + type: feature + products: + - product: kibana + target: 9.2.0 + lifecycle: ga + pr: https://github.com/elastic/kibana/pull/200 + """; + + var file1 = fileSystem.Path.Combine(changelogDir, "1755268130-elasticsearch-feature.yaml"); + var file2 = fileSystem.Path.Combine(changelogDir, "1755268140-kibana-feature.yaml"); + await fileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + await fileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); + + var input = new ChangelogBundleInput + { + Directory = changelogDir, + InputProducts = [new ProductInfo { Product = "*", Target = "9.2.0", 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); + bundleContent.Should().Contain("name: 1755268130-elasticsearch-feature.yaml"); + bundleContent.Should().Contain("name: 1755268140-kibana-feature.yaml"); + } + + [Fact] + public async Task BundleChangelogs_WithWildcardAllParts_EquivalentToAll() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog files + 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 changelog2 = """ + title: Kibana feature + type: feature + products: + - product: kibana + target: 9.3.0 + lifecycle: beta + pr: https://github.com/elastic/kibana/pull/200 + """; + + var file1 = fileSystem.Path.Combine(changelogDir, "1755268130-elasticsearch-feature.yaml"); + var file2 = fileSystem.Path.Combine(changelogDir, "1755268140-kibana-feature.yaml"); + await fileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + await fileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); + + var input = new ChangelogBundleInput + { + Directory = changelogDir, + InputProducts = [new ProductInfo { Product = "*", Target = "*", Lifecycle = "*" }], + 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); + bundleContent.Should().Contain("name: 1755268130-elasticsearch-feature.yaml"); + bundleContent.Should().Contain("name: 1755268140-kibana-feature.yaml"); + } + + [Fact] + public async Task BundleChangelogs_WithPrefixWildcardTarget_MatchesCorrectly() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog files + var changelog1 = """ + title: Elasticsearch 9.3.0 feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + lifecycle: ga + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + var changelog2 = """ + title: Elasticsearch 9.3.1 feature + type: feature + products: + - product: elasticsearch + target: 9.3.1 + lifecycle: ga + pr: https://github.com/elastic/elasticsearch/pull/200 + """; + var changelog3 = """ + title: Elasticsearch 9.2.0 feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + pr: https://github.com/elastic/elasticsearch/pull/300 + """; + + var file1 = fileSystem.Path.Combine(changelogDir, "1755268130-es-9.3.0.yaml"); + var file2 = fileSystem.Path.Combine(changelogDir, "1755268140-es-9.3.1.yaml"); + var file3 = fileSystem.Path.Combine(changelogDir, "1755268150-es-9.2.0.yaml"); + await fileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + await fileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); + await fileSystem.File.WriteAllTextAsync(file3, changelog3, TestContext.Current.CancellationToken); + + var input = new ChangelogBundleInput + { + Directory = changelogDir, + InputProducts = [new ProductInfo { Product = "elasticsearch", Target = "9.3.*", Lifecycle = "*" }], + 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); + bundleContent.Should().Contain("name: 1755268130-es-9.3.0.yaml"); + bundleContent.Should().Contain("name: 1755268140-es-9.3.1.yaml"); + bundleContent.Should().NotContain("name: 1755268150-es-9.2.0.yaml"); + } + [Fact] public async Task BundleChangelogs_WithNonExistentFileAsPrs_ReturnsError() { @@ -1494,8 +1692,8 @@ public async Task BundleChangelogs_WithOutputProducts_OverridesChangelogProducts Directory = changelogDir, All = true, OutputProducts = [ - new ProductInfo { Product = "cloud-serverless", Target = "2025-12-02" }, - new ProductInfo { Product = "cloud-serverless", Target = "2025-12-06" } + new ProductInfo { Product = "cloud-serverless", Target = "2025-12-02", Lifecycle = "ga" }, + new ProductInfo { Product = "cloud-serverless", Target = "2025-12-06", Lifecycle = "beta" } ], Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") }; @@ -1512,6 +1710,9 @@ public async Task BundleChangelogs_WithOutputProducts_OverridesChangelogProducts bundleContent.Should().Contain("product: cloud-serverless"); bundleContent.Should().Contain("target: 2025-12-02"); bundleContent.Should().Contain("target: 2025-12-06"); + // Lifecycle values should be included in products array + bundleContent.Should().Contain("lifecycle: ga"); + bundleContent.Should().Contain("lifecycle: beta"); // Should not contain products from changelogs bundleContent.Should().NotContain("product: elasticsearch"); bundleContent.Should().NotContain("product: kibana"); @@ -1587,6 +1788,290 @@ public async Task BundleChangelogs_WithMultipleProducts_IncludesAllProducts() entryCount.Should().Be(3); } + [Fact] + public async Task BundleChangelogs_WithInputProducts_IncludesLifecycleInProductsArray() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog files + var changelog1 = """ + title: Elasticsearch GA feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + var changelog2 = """ + title: Elasticsearch Beta feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + lifecycle: beta + pr: https://github.com/elastic/elasticsearch/pull/200 + """; + + var file1 = fileSystem.Path.Combine(changelogDir, "1755268130-elasticsearch-ga.yaml"); + var file2 = fileSystem.Path.Combine(changelogDir, "1755268140-elasticsearch-beta.yaml"); + await fileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + await fileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); + + var input = new ChangelogBundleInput + { + Directory = changelogDir, + InputProducts = [ + new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }, + new ProductInfo { Product = "elasticsearch", Target = "9.3.0", Lifecycle = "beta" } + ], + 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 lifecycle is included in products array (extracted from changelog entries, not filter) + bundleContent.Should().Contain("product: elasticsearch"); + bundleContent.Should().Contain("target: 9.2.0"); + bundleContent.Should().Contain("target: 9.3.0"); + bundleContent.Should().Contain("lifecycle: ga"); + bundleContent.Should().Contain("lifecycle: beta"); + } + + [Fact] + public async Task BundleChangelogs_WithOutputProducts_IncludesLifecycleInProductsArray() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog files + var changelog1 = """ + title: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = fileSystem.Path.Combine(changelogDir, "1755268130-elasticsearch.yaml"); + await fileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var input = new ChangelogBundleInput + { + Directory = changelogDir, + All = true, + OutputProducts = [ + new ProductInfo { Product = "cloud-serverless", Target = "2025-12-02", Lifecycle = "ga" }, + new ProductInfo { Product = "cloud-serverless", Target = "2025-12-06", Lifecycle = "beta" } + ], + 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 lifecycle is included in products array from --output-products + bundleContent.Should().Contain("product: cloud-serverless"); + bundleContent.Should().Contain("target: 2025-12-02"); + bundleContent.Should().Contain("target: 2025-12-06"); + bundleContent.Should().Contain("lifecycle: ga"); + bundleContent.Should().Contain("lifecycle: beta"); + } + + [Fact] + public async Task BundleChangelogs_ExtractsLifecycleFromChangelogEntries() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog files with lifecycle + var changelog1 = """ + title: Elasticsearch GA feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + var changelog2 = """ + title: Elasticsearch Beta feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + lifecycle: beta + pr: https://github.com/elastic/elasticsearch/pull/200 + """; + + var file1 = fileSystem.Path.Combine(changelogDir, "1755268130-elasticsearch-ga.yaml"); + var file2 = fileSystem.Path.Combine(changelogDir, "1755268140-elasticsearch-beta.yaml"); + await fileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + await fileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); + + var input = new ChangelogBundleInput + { + Directory = changelogDir, + All = true, + 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 lifecycle is included in products array extracted from changelog entries + bundleContent.Should().Contain("product: elasticsearch"); + bundleContent.Should().Contain("target: 9.2.0"); + bundleContent.Should().Contain("target: 9.3.0"); + bundleContent.Should().Contain("lifecycle: ga"); + bundleContent.Should().Contain("lifecycle: beta"); + } + + [Fact] + public async Task BundleChangelogs_WithInputProductsWildcardLifecycle_ExtractsActualLifecycleFromChangelogs() + { + // Arrange - Test the scenario where --input-products uses "*" for lifecycle, + // but the actual lifecycle value should be extracted from the changelog entries + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file with lifecycle + var changelog1 = """ + title: A new feature was added + 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, "1-feature.yaml"); + await fileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var input = new ChangelogBundleInput + { + Directory = changelogDir, + InputProducts = [ + new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "*" } + ], + 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 the actual lifecycle value "ga" from the changelog is included in products array, + // not the wildcard "*" from the filter + bundleContent.Should().Contain("product: elasticsearch"); + bundleContent.Should().Contain("target: 9.2.0"); + bundleContent.Should().Contain("lifecycle: ga"); + // Verify wildcard "*" is not included in the products array + bundleContent.Should().NotContain("lifecycle: *"); + bundleContent.Should().NotContain("lifecycle: '*\""); + } + + [Fact] + public async Task BundleChangelogs_WithMultipleTargets_WarningIncludesLifecycle() + { + // Arrange - Test that warning message includes lifecycle when multiple products + // have the same target but different lifecycles + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog files with same target but different lifecycles + var changelog1 = """ + title: Elasticsearch GA feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + pr: https://github.com/elastic/elasticsearch/pull/100 + """; + var changelog2 = """ + title: Elasticsearch Beta feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: beta + pr: https://github.com/elastic/elasticsearch/pull/200 + """; + var changelog3 = """ + title: Elasticsearch feature without lifecycle + type: feature + products: + - product: elasticsearch + target: 9.2.0 + pr: https://github.com/elastic/elasticsearch/pull/300 + """; + + var file1 = fileSystem.Path.Combine(changelogDir, "1755268130-elasticsearch-ga.yaml"); + var file2 = fileSystem.Path.Combine(changelogDir, "1755268140-elasticsearch-beta.yaml"); + var file3 = fileSystem.Path.Combine(changelogDir, "1755268150-elasticsearch-no-lifecycle.yaml"); + await fileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + await fileSystem.File.WriteAllTextAsync(file2, changelog2, TestContext.Current.CancellationToken); + await fileSystem.File.WriteAllTextAsync(file3, changelog3, TestContext.Current.CancellationToken); + + var input = new ChangelogBundleInput + { + Directory = changelogDir, + All = true, + 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); + _collector.Warnings.Should().BeGreaterThan(0); + // Verify warning message includes lifecycle values + _collector.Diagnostics.Should().Contain(d => + d.Message.Contains("Product 'elasticsearch' has multiple targets in bundle") && + d.Message.Contains("9.2.0") && + d.Message.Contains("9.2.0 beta") && + d.Message.Contains("9.2.0 ga")); + } + [Fact] public async Task BundleChangelogs_WithResolve_CopiesChangelogContents() {