diff --git a/docs/cli/release/changelog-bundle.md b/docs/cli/release/changelog-bundle.md index db300c391..dfd67577d 100644 --- a/docs/cli/release/changelog-bundle.md +++ b/docs/cli/release/changelog-bundle.md @@ -35,7 +35,8 @@ docs-builder changelog bundle [options...] [-h|--help] - `"* * *"` - match all changelogs (equivalent to `--all`) `--output ` -: Optional: The output file path for the bundle. +: Optional: The output path for the bundle. +: Can be either (1) a directory path, in which case `changelog-bundle.yaml` is created in that directory, or (2) a file path ending in `.yml` or `.yaml`. : Defaults to `changelog-bundle.yaml` in the input directory. `--output-products ?>` diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index a44da735f..93c05ade5 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -147,7 +147,7 @@ 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] + --output Optional: Output path for the bundled changelog. Can be either (1) a directory path, in which case 'changelog-bundle.yaml' is created in that directory, or (2) a file path ending in .yml or .yaml. Defaults to 'changelog-bundle.yaml' in the input directory [Default: null] --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] @@ -312,6 +312,26 @@ entries: When a changelog matches multiple `--input-products` filters, it appears only once in the bundle. This deduplication applies even when using `--all` or `--prs`. ::: +### Output file location + +The `--output` option supports two formats: + +1. **Directory path**: If you specify a directory path (without a filename), the command creates `changelog-bundle.yaml` in that directory: + + ```sh + docs-builder changelog bundle --all --output /path/to/output/dir + # Creates /path/to/output/dir/changelog-bundle.yaml + ``` + +2. **File path**: If you specify a file path ending in `.yml` or `.yaml`, the command uses that exact path: + + ```sh + docs-builder changelog bundle --all --output /path/to/custom-bundle.yaml + # Creates /path/to/custom-bundle.yaml + ``` + +If you specify a file path with a different extension (not `.yml` or `.yaml`), the command returns an error. + ## Create documentation [render-changelogs] The `docs-builder changelog render` command creates markdown files from changelog bundles for documentation purposes. diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index a3beec77d..c57baf3de 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -106,7 +106,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// Bundle changelog files /// /// 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 + /// Optional: Output path for the bundled changelog. Can be either (1) a directory path, in which case 'changelog-bundle.yaml' is created in that directory, or (2) a file path ending in .yml or .yaml. Defaults to 'changelog-bundle.yaml' in the input directory /// 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. @@ -231,10 +231,42 @@ public async Task Bundle( } } + // Process and validate output parameter + string? processedOutput = null; + if (!string.IsNullOrWhiteSpace(output)) + { + var outputLower = output.ToLowerInvariant(); + var endsWithYml = outputLower.EndsWith(".yml", StringComparison.OrdinalIgnoreCase); + var endsWithYaml = outputLower.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase); + + if (endsWithYml || endsWithYaml) + { + // It's a file path - use as-is + processedOutput = output; + } + else + { + // Check if it has a file extension (other than .yml/.yaml) + var extension = Path.GetExtension(output); + if (!string.IsNullOrEmpty(extension)) + { + // Has an extension that's not .yml/.yaml - this is invalid + collector.EmitError(string.Empty, $"--output: If a filename is provided, it must end in .yml or .yaml. Found: {extension}"); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + + // It's a directory path - append default filename + processedOutput = Path.Combine(output, "changelog-bundle.yaml"); + } + } + var input = new ChangelogBundleInput { Directory = directory ?? Directory.GetCurrentDirectory(), - Output = output, + Output = processedOutput, All = all, InputProducts = inputProducts, OutputProducts = outputProducts, diff --git a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs index 59323a086..c5e146d34 100644 --- a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs +++ b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs @@ -1089,7 +1089,7 @@ await fileSystem.File.WriteAllTextAsync(prsFile, """ var input = new ChangelogBundleInput { Directory = changelogDir, - Prs = new[] { prsFile }, + Prs = [prsFile], Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") }; @@ -1553,7 +1553,7 @@ public async Task BundleChangelogs_WithNonExistentFileAsPrs_ReturnsError() var input = new ChangelogBundleInput { Directory = changelogDir, - Prs = new[] { nonexistentFile }, + Prs = [nonexistentFile], Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") }; @@ -1592,7 +1592,7 @@ public async Task BundleChangelogs_WithUrlAsPrs_TreatsAsPrIdentifier() var input = new ChangelogBundleInput { Directory = changelogDir, - Prs = new[] { "https://github.com/elastic/elasticsearch/pull/123" }, + Prs = ["https://github.com/elastic/elasticsearch/pull/123"], Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") }; @@ -1635,7 +1635,7 @@ public async Task BundleChangelogs_WithNonExistentFileAndOtherPrs_EmitsWarning() var input = new ChangelogBundleInput { Directory = changelogDir, - Prs = new[] { nonexistentFile, "https://github.com/elastic/elasticsearch/pull/123" }, + Prs = [nonexistentFile, "https://github.com/elastic/elasticsearch/pull/123"], Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml") }; @@ -2065,7 +2065,7 @@ public async Task BundleChangelogs_WithMultipleTargets_WarningIncludesLifecycle( _collector.Errors.Should().Be(0); _collector.Warnings.Should().BeGreaterThan(0); // Verify warning message includes lifecycle values - _collector.Diagnostics.Should().Contain(d => + _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") && @@ -2126,6 +2126,54 @@ public async Task BundleChangelogs_WithResolve_CopiesChangelogContents() bundleContent.Should().Contain("description: This is a test feature"); } + [Fact] + public async Task BundleChangelogs_WithDirectoryOutputPath_CreatesDefaultFilename() + { + // 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 file + var changelog1 = """ + title: Test 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-test-feature.yaml"); + await fileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + // Use a directory path with default filename (simulating command layer processing) + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputPath = fileSystem.Path.Combine(outputDir, "changelog-bundle.yaml"); + + var input = new ChangelogBundleInput + { + Directory = changelogDir, + All = true, + Output = outputPath + }; + + // Act + var result = await service.BundleChangelogs(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + fileSystem.File.Exists(outputPath).Should().BeTrue("Output file should be created"); + + var bundleContent = await fileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); + bundleContent.Should().Contain("products:"); + bundleContent.Should().Contain("product: elasticsearch"); + bundleContent.Should().Contain("entries:"); + bundleContent.Should().Contain("name: 1755268130-test-feature.yaml"); + } + [Fact] public async Task BundleChangelogs_WithResolveAndMissingTitle_ReturnsError() { @@ -4435,7 +4483,7 @@ private static string ComputeSha1(string content) { var bytes = System.Text.Encoding.UTF8.GetBytes(content); var hash = System.Security.Cryptography.SHA1.HashData(bytes); - return System.Convert.ToHexString(hash).ToLowerInvariant(); + return Convert.ToHexString(hash).ToLowerInvariant(); } }