From cbaf2a68d09ec9fea15510137ee1888c7a351f82 Mon Sep 17 00:00:00 2001 From: lcawl Date: Mon, 4 May 2026 15:21:16 -0700 Subject: [PATCH 01/12] Add changelog render --dropdowns --- docs/cli/changelog/cmd-render.md | 12 + docs/cli/changelog/render.md | 168 +++++++ .../Rendering/ChangelogRenderContext.cs | 1 + .../Rendering/ChangelogRenderingService.cs | 2 + .../BreakingChangesMarkdownRenderer.cs | 64 ++- .../Markdown/DeprecationsMarkdownRenderer.cs | 64 ++- .../Markdown/HighlightsMarkdownRenderer.cs | 33 +- .../Markdown/KnownIssuesMarkdownRenderer.cs | 64 ++- .../docs-builder/Commands/ChangelogCommand.cs | 3 + .../Changelogs/Render/DropdownRenderTests.cs | 429 ++++++++++++++++++ 10 files changed, 793 insertions(+), 47 deletions(-) create mode 100644 docs/cli/changelog/render.md create mode 100644 tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs diff --git a/docs/cli/changelog/cmd-render.md b/docs/cli/changelog/cmd-render.md index e02c565e3d..f5baf1d3b2 100644 --- a/docs/cli/changelog/cmd-render.md +++ b/docs/cli/changelog/cmd-render.md @@ -55,4 +55,16 @@ docs-builder changelog render \ docs-builder changelog render \ --input "./public-bundle.yaml|./changelog|elasticsearch|keep-links,./private-bundle.yaml|./private-changelog|internal-repo|hide-links" \ --output ./release-notes + +# Render with subsections and flattened format (default) +docs-builder changelog render \ + --input "./docs/changelog/bundles/9.3.0.yaml" \ + --output ./release-notes \ + --subsections + +### Render with dropdown format +docs-builder changelog render \ + --input "./docs/changelog/bundles/9.3.0.yaml" \ + --output ./release-notes \ + --dropdowns ``` diff --git a/docs/cli/changelog/render.md b/docs/cli/changelog/render.md new file mode 100644 index 0000000000..a2c2d3ce9c --- /dev/null +++ b/docs/cli/changelog/render.md @@ -0,0 +1,168 @@ +# changelog render + +Generate markdown or asciidoc files from changelog bundle files. + +To create the bundle files, use [](/cli/changelog/bundle.md). +For details and examples, go to [](/contribute/publish-changelogs.md). + +## Usage + +```sh +docs-builder changelog render [options...] [-h|--help] +``` + +## Options + +`--config ` +: Optional: Path to the changelog.yml configuration file. +: Defaults to `docs/changelog.yml`. +: Note: The `changelog render` command does not use `rules.publish` for filtering. Filtering must be done at bundle time using `rules.bundle`. + +`--hide-features ` +: Optional: Filter by feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. +: Each occurrence can be either comma-separated feature IDs (e.g., `--hide-features "feature:new-search-api,feature:enhanced-analytics"`) or a file path (e.g., `--hide-features /path/to/file.txt`). +: When specifying feature IDs directly, provide comma-separated values. +: When specifying a file path, provide a single value that points to a newline-delimited file. The file should contain one feature ID per line. +: Entries with matching `feature-id` values will be commented out in the output and a warning will be emitted. +: If the bundle contains `hide-features` values (that is to say, it was created with the `--hide-features` option), those values are merged with this list and are also hidden. + +`--input ` +: One or more bundle input files. +: Each bundle is specified as "bundle-file-path|changelog-file-path|repo|link-visibility" using pipe (`|`) as delimiter. +: To merge multiple bundles, separate them with commas: `--input "bundle1|dir1|repo1|keep-links,bundle2|dir2|repo2|hide-links"`. +: For example, `--input "/path/to/changelog-bundle.yaml|/path/to/changelogs|elasticsearch|keep-links"`. +: Only `bundle-file-path` is required for each bundle. +: Use `repo` if your changelogs do not contain full URLs for the pull requests or issues; otherwise they will be incorrectly derived with "elastic/elastic" in the URL by default. +: Use `link-visibility` to control whether PR/issue links are shown or hidden for entries from this bundle. Valid values are `keep-links` (default) or `hide-links`. Use `hide-links` for bundles from private repositories. When `hide-links` is set, all links are hidden for each affected entry — changelog entries can contain multiple PR links (`prs`) and issue links (`issues`), and all of them are hidden or shown together. +: Paths support tilde (`~`) expansion and relative paths. + +:::{note} +The `render` command automatically discovers and merges `.amend-*.yaml` files with their parent bundle. For more information about amended bundles, go to [](bundle-amend.md). +::: + +`--file-type ` +: Optional: Output file type. Valid values: `"markdown"` or `"asciidoc"`. +: Defaults to `"markdown"`. +: When `"markdown"` is specified, the command generates multiple markdown files (index.md, breaking-changes.md, deprecations.md, known-issues.md). +: When `"asciidoc"` is specified, the command generates a single asciidoc file with all sections. + +`--output ` +: Optional: The output directory for rendered files. +: Defaults to current directory. + +`--subsections` +: Optional: Group entries by area in subsections. +: Defaults to false. +: When enabled, entries are grouped by their area within each section. The first area from each entry's areas list is used for grouping. + +`--dropdowns` +: Optional: Render separated types (breaking changes, deprecations, known issues, highlights) as MyST dropdowns. +: Defaults to false (flattened bulleted lists). +: When enabled, each entry in separated files is rendered as a collapsible dropdown section using MyST syntax (`::::{dropdown}`). +: When disabled (default), entries are rendered as flattened bulleted lists with PR/issue links inline and Impact/Action sections indented. +: This flag only affects markdown output; AsciiDoc output always uses its standard format. + +`--title ` +: Optional: The title to use for section headers, directories, and anchors in output files. +: Defaults to the version in the first bundle. +: If the string contains spaces, they are replaced with dashes when used in directory names and anchors. + +The `changelog render` command does **not** use `rules.publish` for filtering. Filtering must be done at bundle time using `rules.bundle`. For more information, refer to [](/contribute/publish-changelogs.md). For how the directive differs, see the [{changelog} directive syntax reference](/syntax/changelog.md). + +## Output formats + +### Markdown format + +When `--file-type markdown` is specified (the default), the command generates multiple markdown files: + +- `index.md` - Contains features, enhancements, bug fixes, security updates, documentation changes, regressions, and other changes +- `breaking-changes.md` - Contains breaking changes +- `deprecations.md` - Contains deprecations +- `known-issues.md` - Contains known issues +- `highlights.md` - Contains highlighted entries (only created when at least one entry has `highlight: true`) + +### Asciidoc format + +When `--file-type asciidoc` is specified, the command generates a single asciidoc file with all sections: + +- Security updates +- Bug fixes +- Highlights (only included when at least one entry has `highlight: true`) +- New features and enhancements +- Breaking changes +- Deprecations +- Known issues +- Documentation +- Regressions +- Other changes + +The asciidoc output uses attribute references for links (for example, `{repo-pull}NUMBER[#NUMBER]`). + +### Multiple PR and issue links + +Changelog entries can reference multiple pull requests and issues using the `prs` and `issues` array fields. When an entry has multiple links, all of them are rendered inline for that entry: + +```md +* Fix ML calendar event update scalability issues. [#136886](https://github.com/elastic/elastic/pull/136886) [#136900](https://github.com/elastic/elastic/pull/136900) +``` + +## Examples + +### Render a single bundle + +```sh +docs-builder changelog render \ + --input "./docs/changelog/bundles/9.3.0.yaml" \ + --output ./release-notes +``` + +### Render with tilde expansion + +```sh +docs-builder changelog render \ + --input "~/docs/changelog/bundles/9.3.0.yaml|~/docs/changelog|elasticsearch" \ + --output ~/release-notes +``` + +### Render with relative paths + +```sh +docs-builder changelog render \ + --input "./bundles/9.3.0.yaml|./changelog|elasticsearch|keep-links" \ + --file-type markdown \ + --output ./output +``` + +### Merge multiple bundles + +```sh +docs-builder changelog render \ + --input "./bundles/elasticsearch-9.3.0.yaml|./changelog|elasticsearch,./bundles/kibana-9.3.0.yaml|./changelog|kibana" \ + --output ./merged-release-notes +``` + +### Hide links from private repository bundles + +```sh +docs-builder changelog render \ + --input "./public-bundle.yaml|./changelog|elasticsearch|keep-links,./private-bundle.yaml|./private-changelog|internal-repo|hide-links" \ + --output ./release-notes +``` + +### Render with dropdown format + +```sh +docs-builder changelog render \ + --input "./bundles/9.3.0.yaml|./changelog|elasticsearch" \ + --dropdowns \ + --output ./release-notes +``` + +### Render with subsections and flattened format (default) + +```sh +docs-builder changelog render \ + --input "./bundles/9.3.0.yaml|./changelog|elasticsearch" \ + --subsections \ + --output ./release-notes +``` diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs index 4fcdfbe203..19a949aa76 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs @@ -20,6 +20,7 @@ public record ChangelogRenderContext public required string Owner { get; init; } public required IReadOnlyDictionary> EntriesByType { get; init; } public required bool Subsections { get; init; } + public required bool Dropdowns { get; init; } public required HashSet FeatureIdsToHide { get; init; } public required Dictionary> EntryToBundleProducts { get; init; } public required Dictionary EntryToRepo { get; init; } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 6d394a904a..4be056a5d0 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -28,6 +28,7 @@ public record RenderChangelogsArguments public string? Output { get; init; } public string? Title { get; init; } public bool Subsections { get; init; } + public bool Dropdowns { get; init; } public string[]? HideFeatures { get; init; } public string? Config { get; init; } public ChangelogFileType FileType { get; init; } = ChangelogFileType.Markdown; @@ -325,6 +326,7 @@ private static ChangelogRenderContext BuildRenderContext( Owner = ownerForAnchors, EntriesByType = entriesByType, Subsections = input.Subsections, + Dropdowns = input.Dropdowns, FeatureIdsToHide = featureIdsToHide, EntryToBundleProducts = entryToBundleProducts, EntryToRepo = entryToRepo, diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs index 7cace43f70..ff83942084 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs @@ -63,22 +63,58 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); if (shouldHide) _ = sb.AppendLine(""); } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs index 78dcf5fbd5..aa917486f7 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs @@ -60,22 +60,58 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); if (shouldHide) _ = sb.AppendLine(""); } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs index f55a3c19a3..bc48a92717 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs @@ -63,11 +63,34 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); if (shouldHide) _ = sb.AppendLine(""); } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs index a8640f14b3..c1bbe5c88d 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs @@ -60,22 +60,58 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); if (shouldHide) _ = sb.AppendLine(""); } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 2d87ef40b7..3b023cf912 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -1149,6 +1149,7 @@ async static (s, collector, state, ctx) => await s.RemoveChangelogs(collector, s /// Filter by feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. Entries with matching feature-id values will be commented out in the output. /// Optional: Output directory for rendered files. Defaults to current directory /// Optional: Group entries by area/component in subsections. For breaking changes with a subtype, groups by subtype instead of area. Defaults to false + /// Optional: Render separated types (breaking changes, deprecations, known issues, highlights) as MyST dropdowns. When false (default), renders as flattened bulleted lists. Defaults to false /// Optional: Title to use for section headers in output files. Defaults to version from first bundle /// [NoOptionsInjection] @@ -1159,6 +1160,7 @@ public async Task Render( string[]? hideFeatures = null, string? output = null, bool subsections = false, + bool dropdowns = false, string? title = null, CancellationToken ct = default ) @@ -1191,6 +1193,7 @@ public async Task Render( Output = output, Title = title, Subsections = subsections, + Dropdowns = dropdowns, HideFeatures = allFeatureIds.Count > 0 ? allFeatureIds.ToArray() : null, FileType = ft.Value, Config = config?.FullName diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs new file mode 100644 index 0000000000..d4ec84e2a2 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs @@ -0,0 +1,429 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO; +using AwesomeAssertions; +using Elastic.Changelog.Bundling; +using Elastic.Changelog.Rendering; +using Elastic.Documentation.Configuration; + +namespace Elastic.Changelog.Tests.Changelogs.Render; + +public class DropdownRenderTests(ITestOutputHelper output) : RenderChangelogTestBase(output) +{ + [Fact] + public async Task RenderChangelogs_WithDropdownsTrue_RendersDropdownFormat() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create breaking change changelog + // language=yaml + var breakingChange = + """ + title: Breaking API change + type: breaking-change + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "123" + description: API has been changed to improve performance + impact: Existing API calls will fail + action: Update your code to use the new API endpoints + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "breaking-change.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, breakingChange, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: breaking-change.yaml + checksum: {ComputeSha1(breakingChange)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0", + Dropdowns = true + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var breakingChangesFile = FileSystem.Path.Join(outputDir, "9.2.0", "breaking-changes.md"); + FileSystem.File.Exists(breakingChangesFile).Should().BeTrue(); + + var content = await FileSystem.File.ReadAllTextAsync(breakingChangesFile, TestContext.Current.CancellationToken); + + // Verify dropdown format + content.Should().Contain("::::{dropdown} Breaking API change"); + content.Should().Contain("API has been changed to improve performance"); + content.Should().Contain("**Impact**
Existing API calls will fail"); + content.Should().Contain("**Action**
Update your code to use the new API endpoints"); + content.Should().Contain("::::"); + } + + [Fact] + public async Task RenderChangelogs_WithDropdownsFalse_RendersFlattendFormat() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create deprecation changelog + // language=yaml + var deprecation = + """ + title: Deprecated old API + type: deprecation + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "456" + issues: + - "789" + description: The old API is deprecated + impact: API will be removed in future version + action: Migrate to the new API + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "deprecation.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, deprecation, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: deprecation.yaml + checksum: {ComputeSha1(deprecation)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0", + Dropdowns = false // Explicitly set to false for clarity + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var deprecationsFile = FileSystem.Path.Join(outputDir, "9.2.0", "deprecations.md"); + FileSystem.File.Exists(deprecationsFile).Should().BeTrue(); + + var content = await FileSystem.File.ReadAllTextAsync(deprecationsFile, TestContext.Current.CancellationToken); + + // Verify flattened format + content.Should().Contain("* Deprecated old API"); + content.Should().Contain("The old API is deprecated"); + content.Should().Contain("For more information, check"); + content.Should().Contain("#456"); + content.Should().Contain("#789"); + content.Should().Contain("**Impact:** API will be removed in future version"); + content.Should().Contain("**Action:** Migrate to the new API"); + + // Should NOT contain dropdown syntax + content.Should().NotContain("::::{dropdown}"); + content.Should().NotContain("::::"); + } + + [Fact] + public async Task RenderChangelogs_DefaultDropdownsFalse_RendersFlattedFormat() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create known issue changelog + // language=yaml + var knownIssue = + """ + title: Known issue with search + type: known-issue + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "999" + description: Search results are incomplete under certain conditions + impact: Some search results may be missing + action: Use the workaround provided in the documentation + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "known-issue.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, knownIssue, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: known-issue.yaml + checksum: {ComputeSha1(knownIssue)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0" + // Note: Dropdowns not set, should default to false + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var knownIssuesFile = FileSystem.Path.Join(outputDir, "9.2.0", "known-issues.md"); + FileSystem.File.Exists(knownIssuesFile).Should().BeTrue(); + + var content = await FileSystem.File.ReadAllTextAsync(knownIssuesFile, TestContext.Current.CancellationToken); + + // Verify flattened format (default behavior) + content.Should().Contain("* Known issue with search"); + content.Should().Contain("Search results are incomplete under certain conditions"); + content.Should().Contain("For more information, check"); + content.Should().Contain("#999"); + content.Should().Contain("**Impact:** Some search results may be missing"); + content.Should().Contain("**Action:** Use the workaround provided in the documentation"); + + // Should NOT contain dropdown syntax + content.Should().NotContain("::::{dropdown}"); + content.Should().NotContain("::::"); + } + + [Fact] + public async Task RenderChangelogs_HighlightsWithDropdowns_RendersCorrectFormat() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create highlight feature + // language=yaml + var highlight = + """ + title: Amazing new feature + type: feature + highlight: true + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "555" + description: This feature revolutionizes how you work with data + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "highlight.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, highlight, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: highlight.yaml + checksum: {ComputeSha1(highlight)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + // Test both dropdown and flattened modes + var testCases = new[] + { + new { Dropdowns = true, ExpectDropdown = true }, + new { Dropdowns = false, ExpectDropdown = false } + }; + + foreach (var testCase in testCases) + { + var subOutputDir = FileSystem.Path.Join(outputDir, testCase.Dropdowns.ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = subOutputDir, + Title = "9.2.0", + Dropdowns = testCase.Dropdowns + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var highlightsFile = FileSystem.Path.Join(subOutputDir, "9.2.0", "highlights.md"); + FileSystem.File.Exists(highlightsFile).Should().BeTrue(); + + var content = await FileSystem.File.ReadAllTextAsync(highlightsFile, TestContext.Current.CancellationToken); + + if (testCase.ExpectDropdown) + { + // Verify dropdown format + content.Should().Contain("::::{dropdown} Amazing new feature"); + content.Should().Contain("This feature revolutionizes how you work with data"); + content.Should().Contain("::::"); + } + else + { + // Verify flattened format + content.Should().Contain("* Amazing new feature"); + content.Should().Contain("This feature revolutionizes how you work with data"); + content.Should().Contain("For more information, check"); + content.Should().Contain("#555"); + + // Should NOT contain dropdown syntax + content.Should().NotContain("::::{dropdown}"); + content.Should().NotContain("::::"); + } + } + } + + [Fact] + public async Task RenderChangelogs_AsciidocFormat_IgnoresDropdownsFlag() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create breaking change + // language=yaml + var breakingChange = + """ + title: Breaking API change + type: breaking-change + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "123" + description: API has been changed + impact: Existing API calls will fail + action: Update your code + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "breaking-change.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, breakingChange, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: breaking-change.yaml + checksum: {ComputeSha1(breakingChange)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + // Test both dropdown values with AsciiDoc format + var testCases = new[] { true, false }; + + foreach (var dropdowns in testCases) + { + var subOutputDir = FileSystem.Path.Join(outputDir, dropdowns.ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = subOutputDir, + Title = "9.2.0", + Dropdowns = dropdowns, + FileType = ChangelogFileType.Asciidoc + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + // Find the AsciiDoc file (path structure differs from Markdown) + var asciidocFiles = FileSystem.Directory.GetFiles(subOutputDir, "*.asciidoc", SearchOption.AllDirectories); + asciidocFiles.Should().HaveCount(1, "should create exactly one AsciiDoc file"); + + var asciidocFile = asciidocFiles[0]; + var content = await FileSystem.File.ReadAllTextAsync(asciidocFile, TestContext.Current.CancellationToken); + + // AsciiDoc should always use bullet format regardless of dropdowns flag + content.Should().Contain("* Breaking API change"); + content.Should().Contain("**Impact:** Existing API calls will fail"); + content.Should().Contain("**Action:** Update your code"); + + // Should never contain MyST dropdown syntax + content.Should().NotContain("::::{dropdown}"); + content.Should().NotContain("::::"); + } + } +} From bffc918a65b7393994a629b43c97881a8257f9d9 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Wed, 6 May 2026 14:00:36 -0700 Subject: [PATCH 02/12] Update changelog render date handling (#3260) --- docs/cli/changelog/render.md | 2 +- .../Rendering/ChangelogRenderingService.cs | 20 +- .../Changelogs/Render/TitleTargetTests.cs | 198 ++++++++++++++++++ 3 files changed, 216 insertions(+), 4 deletions(-) diff --git a/docs/cli/changelog/render.md b/docs/cli/changelog/render.md index a2c2d3ce9c..ef28debd3b 100644 --- a/docs/cli/changelog/render.md +++ b/docs/cli/changelog/render.md @@ -64,7 +64,7 @@ The `render` command automatically discovers and merges `.amend-*.yaml` files wi `--title ` : Optional: The title to use for section headers, directories, and anchors in output files. -: Defaults to the version in the first bundle. +: Defaults to the version in the first bundle. When omitted, ISO date targets are formatted for display the same way as the `{changelog}` directive (e.g., `2026-05-04` becomes "May 4, 2026", `2026-05` becomes "May 2026"), while directory names and heading anchors continue to use the raw target slug. : If the string contains spaces, they are replaced with dashes when used in directory names and anchors. The `changelog render` command does **not** use `rules.publish` for filtering. Filtering must be done at bundle time using `rules.bundle`. For more information, refer to [](/contribute/publish-changelogs.md). For how the directive differs, see the [{changelog} directive syntax reference](/syntax/changelog.md). diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 4be056a5d0..068212a72a 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -227,9 +227,23 @@ private OutputSetup SetupOutput( if (string.IsNullOrWhiteSpace(input.Title) && version == "unknown") collector.EmitWarning(string.Empty, "No --title option provided and bundle files do not contain 'target' values. Output folder and markdown titles will default to 'unknown'. Consider using --title to specify a custom title."); - // Use title from input or default to version - var title = input.Title ?? version; - var titleSlug = ChangelogTextUtilities.TitleToSlug(title); + // Determine title and slug + string title; + string titleSlug; + + if (string.IsNullOrWhiteSpace(input.Title)) + { + // Default title: format dates like the changelog directive + title = VersionOrDate.FormatDisplayVersion(version); + // Slug always uses raw version to maintain consistent paths/anchors + titleSlug = ChangelogTextUtilities.TitleToSlug(version); + } + else + { + // Explicit title provided: use as-is for both title and slug + title = input.Title; + titleSlug = ChangelogTextUtilities.TitleToSlug(input.Title); + } return new OutputSetup(outputDir, title, titleSlug); } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs index b6bf4c5767..71b3fa53c0 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/TitleTargetTests.cs @@ -132,4 +132,202 @@ public async Task RenderChangelogs_WithTitleAndNoTargets_NoWarning() d.Severity == Severity.Warning && d.Message.Contains("No --title option provided")); } + + [Fact] + public async Task RenderChangelogs_WithIsoDateTarget_FormatsDateInHeading() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file with ISO date target + // language=yaml + var changelog1 = + """ + title: Test feature + type: feature + products: + - product: elasticsearch + target: 2026-05-04 + prs: + - "100" + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "1755268130-test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); + + // Create bundle file with ISO date target + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 2026-05-04 + entries: + - file: + name: 1755268130-test-feature.yaml + checksum: {ComputeSha1(changelog1)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir + // Note: Title is not set, should default to formatted date + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + // Check that output directory uses raw date slug + var indexFile = FileSystem.Path.Join(outputDir, "2026-05-04", "index.md"); + FileSystem.File.Exists(indexFile).Should().BeTrue(); + + // Check that heading uses formatted date but anchor uses raw date + var indexContent = await FileSystem.File.ReadAllTextAsync(indexFile, TestContext.Current.CancellationToken); + indexContent.Should().Contain("## May 4, 2026 [elastic-release-notes-2026-05-04]"); + } + + [Fact] + public async Task RenderChangelogs_WithYearMonthTarget_FormatsDateInHeading() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file with year-month target + // language=yaml + var changelog1 = + """ + title: Test feature + type: feature + products: + - product: elasticsearch + target: 2026-05 + prs: + - "100" + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "1755268130-test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); + + // Create bundle file with year-month target + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 2026-05 + entries: + - file: + name: 1755268130-test-feature.yaml + checksum: {ComputeSha1(changelog1)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir + // Note: Title is not set, should default to formatted date + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + // Check that output directory uses raw date slug + var indexFile = FileSystem.Path.Join(outputDir, "2026-05", "index.md"); + FileSystem.File.Exists(indexFile).Should().BeTrue(); + + // Check that heading uses formatted date but anchor uses raw date + var indexContent = await FileSystem.File.ReadAllTextAsync(indexFile, TestContext.Current.CancellationToken); + indexContent.Should().Contain("## May 2026 [elastic-release-notes-2026-05]"); + } + + [Fact] + public async Task RenderChangelogs_WithExplicitDateTitle_DoesNotFormatTitle() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file with ISO date target + // language=yaml + var changelog1 = + """ + title: Test feature + type: feature + products: + - product: elasticsearch + target: 2026-05-04 + prs: + - "100" + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "1755268130-test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); + + // Create bundle file with ISO date target + var bundleDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(bundleDir); + + var bundleFile = FileSystem.Path.Join(bundleDir, "bundle.yaml"); + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 2026-05-04 + entries: + - file: + name: 1755268130-test-feature.yaml + checksum: {ComputeSha1(changelog1)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "2026-05-04" // Explicit title provided - should stay literal + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + // Check that output directory uses title slug + var indexFile = FileSystem.Path.Join(outputDir, "2026-05-04", "index.md"); + FileSystem.File.Exists(indexFile).Should().BeTrue(); + + // Check that heading uses literal title (no formatting applied) + var indexContent = await FileSystem.File.ReadAllTextAsync(indexFile, TestContext.Current.CancellationToken); + indexContent.Should().Contain("## 2026-05-04 [elastic-release-notes-2026-05-04]"); + } } From d88a8ce64ee0dda88f2ac8ee1b18b0777ea0939e Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 6 May 2026 14:46:43 -0700 Subject: [PATCH 03/12] Fix list indentation --- .../BreakingChangesMarkdownRenderer.cs | 10 ++--- .../Markdown/DeprecationsMarkdownRenderer.cs | 10 ++--- .../Markdown/HighlightsMarkdownRenderer.cs | 4 +- .../Markdown/KnownIssuesMarkdownRenderer.cs | 10 ++--- .../Markdown/MarkdownRendererBase.cs | 42 +++++++++---------- .../Changelogs/Render/DropdownRenderTests.cs | 14 +++---- 6 files changed, 45 insertions(+), 45 deletions(-) diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs index ff83942084..783df0253d 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs @@ -98,19 +98,19 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); } - // PR/Issue links with "For more information" pattern - RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks); + // PR/Issue links with "For more information" pattern - indented for list continuation + RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks, indentForListItem: true); - // Impact and Action sections + // Impact and Action sections - indented for list continuation if (!string.IsNullOrWhiteSpace(entry.Impact)) { - _ = sb.AppendLine("**Impact:** " + entry.Impact); + _ = sb.AppendLine(ChangelogTextUtilities.Indent("**Impact:** " + entry.Impact)); _ = sb.AppendLine(); } if (!string.IsNullOrWhiteSpace(entry.Action)) { - _ = sb.AppendLine("**Action:** " + entry.Action); + _ = sb.AppendLine(ChangelogTextUtilities.Indent("**Action:** " + entry.Action)); _ = sb.AppendLine(); } } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs index aa917486f7..0d47e0a22c 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs @@ -95,19 +95,19 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); } - // PR/Issue links with "For more information" pattern - RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks); + // PR/Issue links with "For more information" pattern - indented for list continuation + RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks, indentForListItem: true); - // Impact and Action sections + // Impact and Action sections - indented for list continuation if (!string.IsNullOrWhiteSpace(entry.Impact)) { - _ = sb.AppendLine("**Impact:** " + entry.Impact); + _ = sb.AppendLine(ChangelogTextUtilities.Indent("**Impact:** " + entry.Impact)); _ = sb.AppendLine(); } if (!string.IsNullOrWhiteSpace(entry.Action)) { - _ = sb.AppendLine("**Action:** " + entry.Action); + _ = sb.AppendLine(ChangelogTextUtilities.Indent("**Action:** " + entry.Action)); _ = sb.AppendLine(); } } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs index bc48a92717..d20a439027 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs @@ -87,8 +87,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); } - // PR/Issue links with "For more information" pattern - RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks); + // PR/Issue links with "For more information" pattern - indented for list continuation + RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks, indentForListItem: true); } if (shouldHide) diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs index c1bbe5c88d..46e94fb349 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs @@ -95,19 +95,19 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); } - // PR/Issue links with "For more information" pattern - RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks); + // PR/Issue links with "For more information" pattern - indented for list continuation + RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks, indentForListItem: true); - // Impact and Action sections + // Impact and Action sections - indented for list continuation if (!string.IsNullOrWhiteSpace(entry.Impact)) { - _ = sb.AppendLine("**Impact:** " + entry.Impact); + _ = sb.AppendLine(ChangelogTextUtilities.Indent("**Impact:** " + entry.Impact)); _ = sb.AppendLine(); } if (!string.IsNullOrWhiteSpace(entry.Action)) { - _ = sb.AppendLine("**Action:** " + entry.Action); + _ = sb.AppendLine(ChangelogTextUtilities.Indent("**Action:** " + entry.Action)); _ = sb.AppendLine(); } } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs index 6e7cc94473..704394972e 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs @@ -40,6 +40,12 @@ protected async Task WriteOutputFileAsync(string outputDir, string titleSlug, st /// Renders PR and issue links for dropdown entries /// protected static void RenderPrIssueLinks(StringBuilder sb, ChangelogEntry entry, string entryRepo, string entryOwner, bool entryHideLinks) + => RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks, indentForListItem: false); + + /// + /// Renders PR and issue links with optional indentation for flattened list items + /// + protected static void RenderPrIssueLinks(StringBuilder sb, ChangelogEntry entry, string entryRepo, string entryOwner, bool entryHideLinks, bool indentForListItem) { var prParts = new List(); foreach (var pr in entry.Prs ?? []) @@ -62,34 +68,28 @@ protected static void RenderPrIssueLinks(StringBuilder sb, ChangelogEntry entry, if (entryHideLinks) { - foreach (var s in prParts) - _ = sb.AppendLine(s); - foreach (var s in issueParts) - _ = sb.AppendLine(s); - - _ = sb.AppendLine("For more information, check the pull request or issue above."); - } - else - { - _ = sb.Append("For more information, check "); - var first = true; foreach (var s in prParts) { - if (!first) - _ = sb.Append(' '); - _ = sb.Append(s); - first = false; + var line = indentForListItem ? ChangelogTextUtilities.Indent(s) : s; + _ = sb.AppendLine(line); } - foreach (var s in issueParts) { - if (!first) - _ = sb.Append(' '); - _ = sb.Append(s); - first = false; + var line = indentForListItem ? ChangelogTextUtilities.Indent(s) : s; + _ = sb.AppendLine(line); } - _ = sb.AppendLine("."); + var infoLine = "For more information, check the pull request or issue above."; + _ = sb.AppendLine(indentForListItem ? ChangelogTextUtilities.Indent(infoLine) : infoLine); + } + else + { + var lineParts = new List { "For more information, check" }; + lineParts.AddRange(prParts); + lineParts.AddRange(issueParts); + + var fullLine = string.Join(" ", lineParts) + "."; + _ = sb.AppendLine(indentForListItem ? ChangelogTextUtilities.Indent(fullLine) : fullLine); } _ = sb.AppendLine(); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs index d4ec84e2a2..af37eb8f77 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs @@ -155,11 +155,11 @@ public async Task RenderChangelogs_WithDropdownsFalse_RendersFlattendFormat() // Verify flattened format content.Should().Contain("* Deprecated old API"); content.Should().Contain("The old API is deprecated"); - content.Should().Contain("For more information, check"); + content.Should().Contain(" For more information, check"); // Indented for list continuation content.Should().Contain("#456"); content.Should().Contain("#789"); - content.Should().Contain("**Impact:** API will be removed in future version"); - content.Should().Contain("**Action:** Migrate to the new API"); + content.Should().Contain(" **Impact:** API will be removed in future version"); // Indented for list continuation + content.Should().Contain(" **Action:** Migrate to the new API"); // Indented for list continuation // Should NOT contain dropdown syntax content.Should().NotContain("::::{dropdown}"); @@ -234,10 +234,10 @@ public async Task RenderChangelogs_DefaultDropdownsFalse_RendersFlattedFormat() // Verify flattened format (default behavior) content.Should().Contain("* Known issue with search"); content.Should().Contain("Search results are incomplete under certain conditions"); - content.Should().Contain("For more information, check"); + content.Should().Contain(" For more information, check"); // Indented for list continuation content.Should().Contain("#999"); - content.Should().Contain("**Impact:** Some search results may be missing"); - content.Should().Contain("**Action:** Use the workaround provided in the documentation"); + content.Should().Contain(" **Impact:** Some search results may be missing"); // Indented for list continuation + content.Should().Contain(" **Action:** Use the workaround provided in the documentation"); // Indented for list continuation // Should NOT contain dropdown syntax content.Should().NotContain("::::{dropdown}"); @@ -331,7 +331,7 @@ public async Task RenderChangelogs_HighlightsWithDropdowns_RendersCorrectFormat( // Verify flattened format content.Should().Contain("* Amazing new feature"); content.Should().Contain("This feature revolutionizes how you work with data"); - content.Should().Contain("For more information, check"); + content.Should().Contain(" For more information, check"); // Indented for list continuation content.Should().Contain("#555"); // Should NOT contain dropdown syntax From 45554d396da245c4bb961dfc3b2d471d2749546c Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Thu, 7 May 2026 08:27:46 -0700 Subject: [PATCH 04/12] Improve changelog render for asciidoc (#3262) * Improve changelog render asciidoc output * Remove definition lists in favour of subsections --- docs/cli/changelog/render.md | 6 +++ .../Asciidoc/AsciidocRendererBase.cs | 34 ++++++++++------ .../BreakingChangesAsciidocRenderer.cs | 14 ++++++- .../Asciidoc/DeprecationsAsciidocRenderer.cs | 36 ++++++++++++----- .../Asciidoc/EntriesByAreaAsciidocRenderer.cs | 36 +++++++++++------ .../Asciidoc/HighlightsAsciidocRenderer.cs | 36 +++++++++++------ .../Asciidoc/KnownIssuesAsciidocRenderer.cs | 36 ++++++++++++----- .../BreakingChangesMarkdownRenderer.cs | 4 +- .../Markdown/DeprecationsMarkdownRenderer.cs | 4 +- .../Markdown/HighlightsMarkdownRenderer.cs | 4 +- .../Markdown/KnownIssuesMarkdownRenderer.cs | 4 +- .../Markdown/MarkdownRendererBase.cs | 39 +++++++++++-------- .../Changelogs/Render/DropdownRenderTests.cs | 4 +- 13 files changed, 175 insertions(+), 82 deletions(-) diff --git a/docs/cli/changelog/render.md b/docs/cli/changelog/render.md index ef28debd3b..9ce352db24 100644 --- a/docs/cli/changelog/render.md +++ b/docs/cli/changelog/render.md @@ -98,6 +98,12 @@ When `--file-type asciidoc` is specified, the command generates a single asciido The asciidoc output uses attribute references for links (for example, `{repo-pull}NUMBER[#NUMBER]`). +AsciiDoc output ignores the `--dropdowns` flag and always uses a standardized format with the following characteristics: + +- Multi-block entries (containing description, Impact, and Action sections) use proper list continuation markers (`+`) to maintain list structure +- Strong text formatting uses idiomatic single asterisk syntax (`*Impact:*`, `*Action:*`) following AsciiDoc best practices +- All content blocks are properly attached to their parent list items for correct rendering + ### Multiple PR and issue links Changelog entries can reference multiple pull requests and issues using the `prs` and `issues` array fields. When an entry has multiple links, all of them are rendered inline for that entry: diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs index 92a6e23f0e..d9aa5fcb6c 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs @@ -61,40 +61,48 @@ private static void RenderEntryTitleAndLinks(StringBuilder sb, ChangelogEntry en } /// - /// Renders an entry's description with optional comment handling + /// Renders an entry's description with optional comment handling and list continuation /// - private static void RenderEntryDescription(StringBuilder sb, ChangelogEntry entry, bool shouldHide) + private static void RenderEntryDescription(StringBuilder sb, ChangelogEntry entry, bool shouldHide, bool needsContinuation = true) { if (string.IsNullOrWhiteSpace(entry.Description)) return; _ = sb.AppendLine(); - var indented = ChangelogTextUtilities.Indent(entry.Description); + + // Add list continuation marker for multi-block list items + if (needsContinuation) + { + _ = sb.AppendLine("+"); + } + if (shouldHide) { - var indentedLines = indented.Split('\n'); - foreach (var line in indentedLines) + var descriptionLines = entry.Description.Split('\n'); + foreach (var line in descriptionLines) _ = sb.AppendLine(CultureInfo.InvariantCulture, $"// {line}"); } else - _ = sb.AppendLine(indented); + _ = sb.AppendLine(entry.Description); } /// - /// Renders Impact and Action fields for breaking changes, deprecations, and known issues + /// Renders Impact and Action fields for breaking changes, deprecations, and known issues with list continuation /// private static void RenderImpactAndAction(StringBuilder sb, ChangelogEntry entry) { if (!string.IsNullOrWhiteSpace(entry.Impact)) { _ = sb.AppendLine(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"**Impact:** {entry.Impact}"); + _ = sb.AppendLine("+"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"*Impact:* {entry.Impact}"); } if (!string.IsNullOrWhiteSpace(entry.Action)) { _ = sb.AppendLine(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"**Action:** {entry.Action}"); + _ = sb.AppendLine("+"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"*Action:* {entry.Action}"); } } @@ -105,7 +113,7 @@ protected void RenderBasicEntry(StringBuilder sb, ChangelogEntry entry, Changelo { var (entryRepo, _, hideLinks, shouldHide) = ChangelogRenderUtilities.GetEntryContext(entry, context); RenderEntryTitleAndLinks(sb, entry, entryRepo, hideLinks, shouldHide); - RenderEntryDescription(sb, entry, shouldHide); + RenderEntryDescription(sb, entry, shouldHide, needsContinuation: !string.IsNullOrWhiteSpace(entry.Description)); _ = sb.AppendLine(); } @@ -116,7 +124,11 @@ protected void RenderEntryWithImpactAction(StringBuilder sb, ChangelogEntry entr { var (entryRepo, _, hideLinks, shouldHide) = ChangelogRenderUtilities.GetEntryContext(entry, context); RenderEntryTitleAndLinks(sb, entry, entryRepo, hideLinks, shouldHide); - RenderEntryDescription(sb, entry, shouldHide); + + // Description needs continuation when it exists + var hasDescription = !string.IsNullOrWhiteSpace(entry.Description); + RenderEntryDescription(sb, entry, shouldHide, needsContinuation: hasDescription); + RenderImpactAndAction(sb, entry); _ = sb.AppendLine(); } diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs index cdf2a794de..99532acf38 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/BreakingChangesAsciidocRenderer.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Globalization; using System.Text; using Elastic.Documentation; using Elastic.Documentation.ReleaseNotes; @@ -30,8 +31,17 @@ public override void Render(IReadOnlyCollection entries, Changel if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) { var header = ChangelogTextUtilities.FormatSubtypeHeader(group.Key); - var headerLine = allEntriesHidden ? $"// **{header}**" : $"**{header}**"; - _ = sb.AppendLine(headerLine); + + if (allEntriesHidden) + { + _ = sb.AppendLine("// [float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"// ==== {header}"); + } + else + { + _ = sb.AppendLine("[float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"==== {header}"); + } _ = sb.AppendLine(); } diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs index 11d1cf7c06..264730c058 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Globalization; using System.Text; using Elastic.Documentation.ReleaseNotes; @@ -15,22 +16,37 @@ public class DeprecationsAsciidocRenderer(StringBuilder sb) : AsciidocRendererBa /// public override void Render(IReadOnlyCollection entries, ChangelogRenderContext context) { - var groupedByArea = entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList(); + // Group by area if subsections is enabled, otherwise use single group + var groupedEntries = context.Subsections + ? entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList() + : [entries.GroupBy(_ => string.Empty).First()]; - foreach (var areaGroup in groupedByArea) + foreach (var group in groupedEntries) { - // Check if all entries in this area group are hidden - var allEntriesHidden = areaGroup.All(entry => + // Check if all entries in this group are hidden + var allEntriesHidden = group.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); - var componentName = !string.IsNullOrWhiteSpace(areaGroup.Key) ? areaGroup.Key : "General"; - var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); + // Add nested section header when subsections are enabled and group has a name + if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) + { + var componentName = group.Key != string.Empty ? group.Key : "General"; + var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); - var headerLine = allEntriesHidden ? $"// {formattedComponent}::" : $"{formattedComponent}::"; - _ = sb.AppendLine(headerLine); - _ = sb.AppendLine(); + if (allEntriesHidden) + { + _ = sb.AppendLine("// [float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"// ==== {formattedComponent}"); + } + else + { + _ = sb.AppendLine("[float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"==== {formattedComponent}"); + } + _ = sb.AppendLine(); + } - foreach (var entry in areaGroup) + foreach (var entry in group) RenderEntryWithImpactAction(sb, entry, context); } } diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs index 0bd171ad27..513f8901ba 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/EntriesByAreaAsciidocRenderer.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Globalization; using System.Text; using Elastic.Documentation.ReleaseNotes; @@ -15,24 +16,37 @@ public class EntriesByAreaAsciidocRenderer(StringBuilder sb) : AsciidocRendererB /// public override void Render(IReadOnlyCollection entries, ChangelogRenderContext context) { - var groupedByArea = context.Subsections + // Group by area if subsections is enabled, otherwise use single group + var groupedEntries = context.Subsections ? entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList() - : entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).ToList(); + : [entries.GroupBy(_ => string.Empty).First()]; - foreach (var areaGroup in groupedByArea) + foreach (var group in groupedEntries) { - // Check if all entries in this area group are hidden - var allEntriesHidden = areaGroup.All(entry => + // Check if all entries in this group are hidden + var allEntriesHidden = group.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); - var componentName = !string.IsNullOrWhiteSpace(areaGroup.Key) ? areaGroup.Key : "General"; - var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); + // Add nested section header when subsections are enabled and group has a name + if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) + { + var componentName = group.Key != string.Empty ? group.Key : "General"; + var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); - var headerLine = allEntriesHidden ? $"// {formattedComponent}::" : $"{formattedComponent}::"; - _ = sb.AppendLine(headerLine); - _ = sb.AppendLine(); + if (allEntriesHidden) + { + _ = sb.AppendLine("// [float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"// ==== {formattedComponent}"); + } + else + { + _ = sb.AppendLine("[float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"==== {formattedComponent}"); + } + _ = sb.AppendLine(); + } - foreach (var entry in areaGroup) + foreach (var entry in group) RenderBasicEntry(sb, entry, context); } } diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/HighlightsAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/HighlightsAsciidocRenderer.cs index eff5542d09..07312f14f4 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/HighlightsAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/HighlightsAsciidocRenderer.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Globalization; using System.Text; using Elastic.Documentation.ReleaseNotes; @@ -15,24 +16,37 @@ public class HighlightsAsciidocRenderer(StringBuilder sb) : AsciidocRendererBase /// public override void Render(IReadOnlyCollection entries, ChangelogRenderContext context) { - var groupedByArea = context.Subsections + // Group by area if subsections is enabled, otherwise use single group + var groupedEntries = context.Subsections ? entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList() - : entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).ToList(); + : [entries.GroupBy(_ => string.Empty).First()]; - foreach (var areaGroup in groupedByArea) + foreach (var group in groupedEntries) { - // Check if all entries in this area group are hidden - var allEntriesHidden = areaGroup.All(entry => + // Check if all entries in this group are hidden + var allEntriesHidden = group.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); - var componentName = !string.IsNullOrWhiteSpace(areaGroup.Key) ? areaGroup.Key : "General"; - var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); + // Add nested section header when subsections are enabled and group has a name + if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) + { + var componentName = group.Key != string.Empty ? group.Key : "General"; + var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); - var headerLine = allEntriesHidden ? $"// {formattedComponent}::" : $"{formattedComponent}::"; - _ = sb.AppendLine(headerLine); - _ = sb.AppendLine(); + if (allEntriesHidden) + { + _ = sb.AppendLine("// [float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"// ==== {formattedComponent}"); + } + else + { + _ = sb.AppendLine("[float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"==== {formattedComponent}"); + } + _ = sb.AppendLine(); + } - foreach (var entry in areaGroup) + foreach (var entry in group) RenderBasicEntry(sb, entry, context); } } diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs index f7ff3bc346..74fac4e4f9 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/KnownIssuesAsciidocRenderer.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Globalization; using System.Text; using Elastic.Documentation.ReleaseNotes; @@ -15,22 +16,37 @@ public class KnownIssuesAsciidocRenderer(StringBuilder sb) : AsciidocRendererBas /// public override void Render(IReadOnlyCollection entries, ChangelogRenderContext context) { - var groupedByArea = entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList(); + // Group by area if subsections is enabled, otherwise use single group + var groupedEntries = context.Subsections + ? entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList() + : [entries.GroupBy(_ => string.Empty).First()]; - foreach (var areaGroup in groupedByArea) + foreach (var group in groupedEntries) { - // Check if all entries in this area group are hidden - var allEntriesHidden = areaGroup.All(entry => + // Check if all entries in this group are hidden + var allEntriesHidden = group.All(entry => ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); - var componentName = !string.IsNullOrWhiteSpace(areaGroup.Key) ? areaGroup.Key : "General"; - var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); + // Add nested section header when subsections are enabled and group has a name + if (context.Subsections && !string.IsNullOrWhiteSpace(group.Key)) + { + var componentName = group.Key != string.Empty ? group.Key : "General"; + var formattedComponent = ChangelogTextUtilities.FormatAreaHeader(componentName); - var headerLine = allEntriesHidden ? $"// {formattedComponent}::" : $"{formattedComponent}::"; - _ = sb.AppendLine(headerLine); - _ = sb.AppendLine(); + if (allEntriesHidden) + { + _ = sb.AppendLine("// [float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"// ==== {formattedComponent}"); + } + else + { + _ = sb.AppendLine("[float]"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"==== {formattedComponent}"); + } + _ = sb.AppendLine(); + } - foreach (var entry in areaGroup) + foreach (var entry in group) RenderEntryWithImpactAction(sb, entry, context); } } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs index 783df0253d..4cee9acece 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs @@ -70,7 +70,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); _ = sb.AppendLine(entry.Description ?? "% Describe the functionality that changed"); _ = sb.AppendLine(); - RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks); + RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks)); _ = sb.AppendLine(!string.IsNullOrWhiteSpace(entry.Impact) ? "**Impact**
" + entry.Impact @@ -99,7 +99,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct } // PR/Issue links with "For more information" pattern - indented for list continuation - RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks, indentForListItem: true); + RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks, IndentForListItem: true)); // Impact and Action sections - indented for list continuation if (!string.IsNullOrWhiteSpace(entry.Impact)) diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs index 0d47e0a22c..c5b80228b4 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs @@ -67,7 +67,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); _ = sb.AppendLine(entry.Description ?? "% Describe the functionality that was deprecated"); _ = sb.AppendLine(); - RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks); + RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks)); _ = sb.AppendLine(!string.IsNullOrWhiteSpace(entry.Impact) ? "**Impact**
" + entry.Impact @@ -96,7 +96,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct } // PR/Issue links with "For more information" pattern - indented for list continuation - RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks, indentForListItem: true); + RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks, IndentForListItem: true)); // Impact and Action sections - indented for list continuation if (!string.IsNullOrWhiteSpace(entry.Impact)) diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs index d20a439027..5d5cf5a2e7 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs @@ -70,7 +70,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); _ = sb.AppendLine(entry.Description ?? "% Describe the highlight"); _ = sb.AppendLine(); - RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks); + RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks)); _ = sb.AppendLine("::::"); } else @@ -88,7 +88,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct } // PR/Issue links with "For more information" pattern - indented for list continuation - RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks, indentForListItem: true); + RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks, IndentForListItem: true)); } if (shouldHide) diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs index 46e94fb349..3f6e3b22da 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs @@ -67,7 +67,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); _ = sb.AppendLine(entry.Description ?? "% Describe the known issue"); _ = sb.AppendLine(); - RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks); + RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks)); _ = sb.AppendLine(!string.IsNullOrWhiteSpace(entry.Impact) ? "**Impact**
" + entry.Impact @@ -96,7 +96,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct } // PR/Issue links with "For more information" pattern - indented for list continuation - RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks, indentForListItem: true); + RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks, IndentForListItem: true)); // Impact and Action sections - indented for list continuation if (!string.IsNullOrWhiteSpace(entry.Impact)) diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs index 704394972e..d545d6e502 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs @@ -10,6 +10,17 @@ namespace Elastic.Changelog.Rendering.Markdown; +/// +/// Options for rendering PR and issue links +/// +public record PrIssueLinkOptions( + ChangelogEntry Entry, + string Repo, + string Owner, + bool HideLinks, + bool IndentForListItem = false +); + /// /// Abstract base class for changelog markdown renderers /// @@ -37,28 +48,22 @@ protected async Task WriteOutputFileAsync(string outputDir, string titleSlug, st } /// - /// Renders PR and issue links for dropdown entries - /// - protected static void RenderPrIssueLinks(StringBuilder sb, ChangelogEntry entry, string entryRepo, string entryOwner, bool entryHideLinks) - => RenderPrIssueLinks(sb, entry, entryRepo, entryOwner, entryHideLinks, indentForListItem: false); - - /// - /// Renders PR and issue links with optional indentation for flattened list items + /// Renders PR and issue links with configurable formatting options /// - protected static void RenderPrIssueLinks(StringBuilder sb, ChangelogEntry entry, string entryRepo, string entryOwner, bool entryHideLinks, bool indentForListItem) + protected static void RenderPrIssueLinks(StringBuilder sb, PrIssueLinkOptions options) { var prParts = new List(); - foreach (var pr in entry.Prs ?? []) + foreach (var pr in options.Entry.Prs ?? []) { - var s = ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner); + var s = ChangelogTextUtilities.FormatPrLink(pr, options.Repo, options.HideLinks, options.Owner); if (!string.IsNullOrEmpty(s)) prParts.Add(s); } var issueParts = new List(); - foreach (var issue in entry.Issues ?? []) + foreach (var issue in options.Entry.Issues ?? []) { - var s = ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner); + var s = ChangelogTextUtilities.FormatIssueLink(issue, options.Repo, options.HideLinks, options.Owner); if (!string.IsNullOrEmpty(s)) issueParts.Add(s); } @@ -66,21 +71,21 @@ protected static void RenderPrIssueLinks(StringBuilder sb, ChangelogEntry entry, if (prParts.Count == 0 && issueParts.Count == 0) return; - if (entryHideLinks) + if (options.HideLinks) { foreach (var s in prParts) { - var line = indentForListItem ? ChangelogTextUtilities.Indent(s) : s; + var line = options.IndentForListItem ? ChangelogTextUtilities.Indent(s) : s; _ = sb.AppendLine(line); } foreach (var s in issueParts) { - var line = indentForListItem ? ChangelogTextUtilities.Indent(s) : s; + var line = options.IndentForListItem ? ChangelogTextUtilities.Indent(s) : s; _ = sb.AppendLine(line); } var infoLine = "For more information, check the pull request or issue above."; - _ = sb.AppendLine(indentForListItem ? ChangelogTextUtilities.Indent(infoLine) : infoLine); + _ = sb.AppendLine(options.IndentForListItem ? ChangelogTextUtilities.Indent(infoLine) : infoLine); } else { @@ -89,7 +94,7 @@ protected static void RenderPrIssueLinks(StringBuilder sb, ChangelogEntry entry, lineParts.AddRange(issueParts); var fullLine = string.Join(" ", lineParts) + "."; - _ = sb.AppendLine(indentForListItem ? ChangelogTextUtilities.Indent(fullLine) : fullLine); + _ = sb.AppendLine(options.IndentForListItem ? ChangelogTextUtilities.Indent(fullLine) : fullLine); } _ = sb.AppendLine(); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs index af37eb8f77..204fe7c0d1 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/DropdownRenderTests.cs @@ -418,8 +418,8 @@ public async Task RenderChangelogs_AsciidocFormat_IgnoresDropdownsFlag() // AsciiDoc should always use bullet format regardless of dropdowns flag content.Should().Contain("* Breaking API change"); - content.Should().Contain("**Impact:** Existing API calls will fail"); - content.Should().Contain("**Action:** Update your code"); + content.Should().Contain("*Impact:* Existing API calls will fail"); + content.Should().Contain("*Action:* Update your code"); // Should never contain MyST dropdown syntax content.Should().NotContain("::::{dropdown}"); From 758bab90c49190641c33af56ca76c860d5e711b9 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Thu, 7 May 2026 09:19:15 -0700 Subject: [PATCH 05/12] Update src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs index 264730c058..2800e9507f 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/DeprecationsAsciidocRenderer.cs @@ -17,6 +17,8 @@ public class DeprecationsAsciidocRenderer(StringBuilder sb) : AsciidocRendererBa public override void Render(IReadOnlyCollection entries, ChangelogRenderContext context) { // Group by area if subsections is enabled, otherwise use single group + if (entries.Count == 0) + return; var groupedEntries = context.Subsections ? entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList() : [entries.GroupBy(_ => string.Empty).First()]; From 0691b1148b510c903edcc06cc527ba10bfba7f07 Mon Sep 17 00:00:00 2001 From: lcawl Date: Tue, 12 May 2026 10:16:33 -0700 Subject: [PATCH 06/12] Move updates from render.md to cmd-render.md --- docs/cli/changelog/cmd-render.md | 29 +++++- docs/cli/changelog/render.md | 174 ------------------------------- 2 files changed, 28 insertions(+), 175 deletions(-) delete mode 100644 docs/cli/changelog/render.md diff --git a/docs/cli/changelog/cmd-render.md b/docs/cli/changelog/cmd-render.md index f5baf1d3b2..ffd314a2b0 100644 --- a/docs/cli/changelog/cmd-render.md +++ b/docs/cli/changelog/cmd-render.md @@ -9,6 +9,14 @@ The `render` command automatically discovers and merges `.amend-*.yaml` files wi The `changelog render` command does **not** use `rules.publish` for filtering. Filtering must be done at bundle time using `rules.bundle`. For how the directive differs, see the [{changelog} directive syntax reference](/syntax/changelog.md). +## Options + +: `--dropdowns` + Render separated types (breaking changes, deprecations, known issues, highlights) as MyST dropdowns. Defaults to false (flattened bulleted lists). When used, each entry in separated files is rendered as a collapsible dropdown section using MyST syntax (`::::{dropdown}`). When it's not used, entries are rendered as flattened bulleted lists with PR/issue links inline and `Impact` and `Action` sections indented. This flag affects only markdown output; AsciiDoc output always uses its standard format. + +: `--title` + The title to use for section headers, directories, and anchors in output files. Defaults to the version in the first bundle. When omitted, ISO date targets are formatted for display the same way as the `{changelog}` directive (for example, `2026-05-04` becomes "May 4, 2026", `2026-05` becomes "May 2026"), while directory names and heading anchors continue to use the raw target slug. If the string contains spaces, they are replaced with dashes when used in directory names and anchors. + ## Output formats ### Markdown @@ -23,7 +31,26 @@ The default output (`--file-type markdown`) generates multiple files: ### Asciidoc -`--file-type asciidoc` generates a single file with all sections in order: security updates, bug fixes, highlights, new features and enhancements, breaking changes, deprecations, known issues, documentation, regressions, and other changes. The asciidoc output uses attribute references for links (for example, `{repo-pull}NUMBER[#NUMBER]`). +When `--file-type asciidoc` is specified, the command generates a single asciidoc file with all sections: + +- Security updates +- Bug fixes +- Highlights (only included when at least one entry has `highlight: true`) +- New features and enhancements +- Breaking changes +- Deprecations +- Known issues +- Documentation +- Regressions +- Other changes + +The asciidoc output uses attribute references for links (for example, `{repo-pull}NUMBER[#NUMBER]`). + +AsciiDoc output ignores the `--dropdowns` flag and always uses a standardized format with the following characteristics: + +- Multi-block entries (containing description, Impact, and Action sections) use proper list continuation markers (`+`) to maintain list structure +- Strong text formatting uses idiomatic single asterisk syntax (`*Impact:*`, `*Action:*`) following AsciiDoc best practices +- All content blocks are properly attached to their parent list items for correct rendering. ### Multiple PR and issue links diff --git a/docs/cli/changelog/render.md b/docs/cli/changelog/render.md deleted file mode 100644 index 9ce352db24..0000000000 --- a/docs/cli/changelog/render.md +++ /dev/null @@ -1,174 +0,0 @@ -# changelog render - -Generate markdown or asciidoc files from changelog bundle files. - -To create the bundle files, use [](/cli/changelog/bundle.md). -For details and examples, go to [](/contribute/publish-changelogs.md). - -## Usage - -```sh -docs-builder changelog render [options...] [-h|--help] -``` - -## Options - -`--config ` -: Optional: Path to the changelog.yml configuration file. -: Defaults to `docs/changelog.yml`. -: Note: The `changelog render` command does not use `rules.publish` for filtering. Filtering must be done at bundle time using `rules.bundle`. - -`--hide-features ` -: Optional: Filter by feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. -: Each occurrence can be either comma-separated feature IDs (e.g., `--hide-features "feature:new-search-api,feature:enhanced-analytics"`) or a file path (e.g., `--hide-features /path/to/file.txt`). -: When specifying feature IDs directly, provide comma-separated values. -: When specifying a file path, provide a single value that points to a newline-delimited file. The file should contain one feature ID per line. -: Entries with matching `feature-id` values will be commented out in the output and a warning will be emitted. -: If the bundle contains `hide-features` values (that is to say, it was created with the `--hide-features` option), those values are merged with this list and are also hidden. - -`--input ` -: One or more bundle input files. -: Each bundle is specified as "bundle-file-path|changelog-file-path|repo|link-visibility" using pipe (`|`) as delimiter. -: To merge multiple bundles, separate them with commas: `--input "bundle1|dir1|repo1|keep-links,bundle2|dir2|repo2|hide-links"`. -: For example, `--input "/path/to/changelog-bundle.yaml|/path/to/changelogs|elasticsearch|keep-links"`. -: Only `bundle-file-path` is required for each bundle. -: Use `repo` if your changelogs do not contain full URLs for the pull requests or issues; otherwise they will be incorrectly derived with "elastic/elastic" in the URL by default. -: Use `link-visibility` to control whether PR/issue links are shown or hidden for entries from this bundle. Valid values are `keep-links` (default) or `hide-links`. Use `hide-links` for bundles from private repositories. When `hide-links` is set, all links are hidden for each affected entry — changelog entries can contain multiple PR links (`prs`) and issue links (`issues`), and all of them are hidden or shown together. -: Paths support tilde (`~`) expansion and relative paths. - -:::{note} -The `render` command automatically discovers and merges `.amend-*.yaml` files with their parent bundle. For more information about amended bundles, go to [](bundle-amend.md). -::: - -`--file-type ` -: Optional: Output file type. Valid values: `"markdown"` or `"asciidoc"`. -: Defaults to `"markdown"`. -: When `"markdown"` is specified, the command generates multiple markdown files (index.md, breaking-changes.md, deprecations.md, known-issues.md). -: When `"asciidoc"` is specified, the command generates a single asciidoc file with all sections. - -`--output ` -: Optional: The output directory for rendered files. -: Defaults to current directory. - -`--subsections` -: Optional: Group entries by area in subsections. -: Defaults to false. -: When enabled, entries are grouped by their area within each section. The first area from each entry's areas list is used for grouping. - -`--dropdowns` -: Optional: Render separated types (breaking changes, deprecations, known issues, highlights) as MyST dropdowns. -: Defaults to false (flattened bulleted lists). -: When enabled, each entry in separated files is rendered as a collapsible dropdown section using MyST syntax (`::::{dropdown}`). -: When disabled (default), entries are rendered as flattened bulleted lists with PR/issue links inline and Impact/Action sections indented. -: This flag only affects markdown output; AsciiDoc output always uses its standard format. - -`--title ` -: Optional: The title to use for section headers, directories, and anchors in output files. -: Defaults to the version in the first bundle. When omitted, ISO date targets are formatted for display the same way as the `{changelog}` directive (e.g., `2026-05-04` becomes "May 4, 2026", `2026-05` becomes "May 2026"), while directory names and heading anchors continue to use the raw target slug. -: If the string contains spaces, they are replaced with dashes when used in directory names and anchors. - -The `changelog render` command does **not** use `rules.publish` for filtering. Filtering must be done at bundle time using `rules.bundle`. For more information, refer to [](/contribute/publish-changelogs.md). For how the directive differs, see the [{changelog} directive syntax reference](/syntax/changelog.md). - -## Output formats - -### Markdown format - -When `--file-type markdown` is specified (the default), the command generates multiple markdown files: - -- `index.md` - Contains features, enhancements, bug fixes, security updates, documentation changes, regressions, and other changes -- `breaking-changes.md` - Contains breaking changes -- `deprecations.md` - Contains deprecations -- `known-issues.md` - Contains known issues -- `highlights.md` - Contains highlighted entries (only created when at least one entry has `highlight: true`) - -### Asciidoc format - -When `--file-type asciidoc` is specified, the command generates a single asciidoc file with all sections: - -- Security updates -- Bug fixes -- Highlights (only included when at least one entry has `highlight: true`) -- New features and enhancements -- Breaking changes -- Deprecations -- Known issues -- Documentation -- Regressions -- Other changes - -The asciidoc output uses attribute references for links (for example, `{repo-pull}NUMBER[#NUMBER]`). - -AsciiDoc output ignores the `--dropdowns` flag and always uses a standardized format with the following characteristics: - -- Multi-block entries (containing description, Impact, and Action sections) use proper list continuation markers (`+`) to maintain list structure -- Strong text formatting uses idiomatic single asterisk syntax (`*Impact:*`, `*Action:*`) following AsciiDoc best practices -- All content blocks are properly attached to their parent list items for correct rendering - -### Multiple PR and issue links - -Changelog entries can reference multiple pull requests and issues using the `prs` and `issues` array fields. When an entry has multiple links, all of them are rendered inline for that entry: - -```md -* Fix ML calendar event update scalability issues. [#136886](https://github.com/elastic/elastic/pull/136886) [#136900](https://github.com/elastic/elastic/pull/136900) -``` - -## Examples - -### Render a single bundle - -```sh -docs-builder changelog render \ - --input "./docs/changelog/bundles/9.3.0.yaml" \ - --output ./release-notes -``` - -### Render with tilde expansion - -```sh -docs-builder changelog render \ - --input "~/docs/changelog/bundles/9.3.0.yaml|~/docs/changelog|elasticsearch" \ - --output ~/release-notes -``` - -### Render with relative paths - -```sh -docs-builder changelog render \ - --input "./bundles/9.3.0.yaml|./changelog|elasticsearch|keep-links" \ - --file-type markdown \ - --output ./output -``` - -### Merge multiple bundles - -```sh -docs-builder changelog render \ - --input "./bundles/elasticsearch-9.3.0.yaml|./changelog|elasticsearch,./bundles/kibana-9.3.0.yaml|./changelog|kibana" \ - --output ./merged-release-notes -``` - -### Hide links from private repository bundles - -```sh -docs-builder changelog render \ - --input "./public-bundle.yaml|./changelog|elasticsearch|keep-links,./private-bundle.yaml|./private-changelog|internal-repo|hide-links" \ - --output ./release-notes -``` - -### Render with dropdown format - -```sh -docs-builder changelog render \ - --input "./bundles/9.3.0.yaml|./changelog|elasticsearch" \ - --dropdowns \ - --output ./release-notes -``` - -### Render with subsections and flattened format (default) - -```sh -docs-builder changelog render \ - --input "./bundles/9.3.0.yaml|./changelog|elasticsearch" \ - --subsections \ - --output ./release-notes -``` From a0ab4da3c328153881e734ab86d15c7a63c7439a Mon Sep 17 00:00:00 2001 From: lcawl Date: Tue, 12 May 2026 10:34:42 -0700 Subject: [PATCH 07/12] Refresh cli schema --- docs/cli-schema.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 99fae06718..1e089f1443 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -4530,6 +4530,15 @@ "summary": "Optional: Group entries by area/component in subsections. For breaking changes with a subtype, groups by subtype instead of area. Defaults to false", "defaultValue": "false" }, + { + "role": "flag", + "name": "dropdowns", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Render separated types (breaking changes, deprecations, known issues, highlights) as MyST dropdowns. When false (default), renders as flattened bulleted lists. Defaults to false", + "defaultValue": "false" + }, { "role": "flag", "name": "title", From 757832e257ff608f945a89ecc021d2c82ad74f16 Mon Sep 17 00:00:00 2001 From: lcawl Date: Thu, 7 May 2026 10:27:08 -0700 Subject: [PATCH 08/12] Add changelog render --no-descriptions --- docs/cli/changelog/render.md | 181 +++++++ .../Asciidoc/AsciidocRendererBase.cs | 8 +- .../Rendering/ChangelogRenderContext.cs | 1 + .../Rendering/ChangelogRenderingService.cs | 2 + .../BreakingChangesMarkdownRenderer.cs | 5 +- .../Markdown/DeprecationsMarkdownRenderer.cs | 5 +- .../Markdown/HighlightsMarkdownRenderer.cs | 5 +- .../Markdown/IndexMarkdownRenderer.cs | 2 +- .../Markdown/KnownIssuesMarkdownRenderer.cs | 5 +- .../docs-builder/Commands/ChangelogCommand.cs | 3 + .../Render/DescriptionVisibilityTests.cs | 458 ++++++++++++++++++ 11 files changed, 662 insertions(+), 13 deletions(-) create mode 100644 docs/cli/changelog/render.md create mode 100644 tests/Elastic.Changelog.Tests/Changelogs/Render/DescriptionVisibilityTests.cs diff --git a/docs/cli/changelog/render.md b/docs/cli/changelog/render.md new file mode 100644 index 0000000000..11202b07f5 --- /dev/null +++ b/docs/cli/changelog/render.md @@ -0,0 +1,181 @@ +# changelog render + +Generate markdown or asciidoc files from changelog bundle files. + +To create the bundle files, use [](/cli/changelog/bundle.md). +For details and examples, go to [](/contribute/publish-changelogs.md). + +## Usage + +```sh +docs-builder changelog render [options...] [-h|--help] +``` + +## Options + +`--config ` +: Optional: Path to the changelog.yml configuration file. +: Defaults to `docs/changelog.yml`. +: Note: The `changelog render` command does not use `rules.publish` for filtering. Filtering must be done at bundle time using `rules.bundle`. + +`--hide-features ` +: Optional: Filter by feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. +: Each occurrence can be either comma-separated feature IDs (e.g., `--hide-features "feature:new-search-api,feature:enhanced-analytics"`) or a file path (e.g., `--hide-features /path/to/file.txt`). +: When specifying feature IDs directly, provide comma-separated values. +: When specifying a file path, provide a single value that points to a newline-delimited file. The file should contain one feature ID per line. +: Entries with matching `feature-id` values will be commented out in the output and a warning will be emitted. +: If the bundle contains `hide-features` values (that is to say, it was created with the `--hide-features` option), those values are merged with this list and are also hidden. + +`--input ` +: One or more bundle input files. +: Each bundle is specified as "bundle-file-path|changelog-file-path|repo|link-visibility" using pipe (`|`) as delimiter. +: To merge multiple bundles, separate them with commas: `--input "bundle1|dir1|repo1|keep-links,bundle2|dir2|repo2|hide-links"`. +: For example, `--input "/path/to/changelog-bundle.yaml|/path/to/changelogs|elasticsearch|keep-links"`. +: Only `bundle-file-path` is required for each bundle. +: Use `repo` if your changelogs do not contain full URLs for the pull requests or issues; otherwise they will be incorrectly derived with "elastic/elastic" in the URL by default. +: Use `link-visibility` to control whether PR/issue links are shown or hidden for entries from this bundle. Valid values are `keep-links` (default) or `hide-links`. Use `hide-links` for bundles from private repositories. When `hide-links` is set, all links are hidden for each affected entry — changelog entries can contain multiple PR links (`prs`) and issue links (`issues`), and all of them are hidden or shown together. +: Paths support tilde (`~`) expansion and relative paths. + +:::{note} +The `render` command automatically discovers and merges `.amend-*.yaml` files with their parent bundle. For more information about amended bundles, go to [](bundle-amend.md). +::: + +`--file-type ` +: Optional: Output file type. Valid values: `"markdown"` or `"asciidoc"`. +: Defaults to `"markdown"`. +: When `"markdown"` is specified, the command generates multiple markdown files (index.md, breaking-changes.md, deprecations.md, known-issues.md). +: When `"asciidoc"` is specified, the command generates a single asciidoc file with all sections. + +`--output ` +: Optional: The output directory for rendered files. +: Defaults to current directory. + +`--subsections` +: Optional: Group entries by area in subsections. +: Defaults to false. +: When enabled, entries are grouped by their area within each section. The first area from each entry's areas list is used for grouping. + +`--dropdowns` +: Optional: Render separated types (breaking changes, deprecations, known issues, highlights) as MyST dropdowns. +: Defaults to false (flattened bulleted lists). +: When enabled, each entry in separated files is rendered as a collapsible dropdown section using MyST syntax (`::::{dropdown}`). +: When disabled (default), entries are rendered as flattened bulleted lists with PR/issue links inline and Impact/Action sections indented. +: This flag only affects markdown output; AsciiDoc output always uses its standard format. + +`--no-descriptions` +: Optional: Hide changelog record descriptions from output. +: Defaults to false (descriptions are shown). +: When enabled, entry titles, PR/issue links, Impact and Action sections remain visible. +: Bundle-level descriptions (release intro text) are unaffected. +: Works with both markdown and asciidoc output formats. + +`--title ` +: Optional: The title to use for section headers, directories, and anchors in output files. +: Defaults to the version in the first bundle. When omitted, ISO date targets are formatted for display the same way as the `{changelog}` directive (e.g., `2026-05-04` becomes "May 4, 2026", `2026-05` becomes "May 2026"), while directory names and heading anchors continue to use the raw target slug. +: If the string contains spaces, they are replaced with dashes when used in directory names and anchors. + +The `changelog render` command does **not** use `rules.publish` for filtering. Filtering must be done at bundle time using `rules.bundle`. For more information, refer to [](/contribute/publish-changelogs.md). For how the directive differs, see the [{changelog} directive syntax reference](/syntax/changelog.md). + +## Output formats + +### Markdown format + +When `--file-type markdown` is specified (the default), the command generates multiple markdown files: + +- `index.md` - Contains features, enhancements, bug fixes, security updates, documentation changes, regressions, and other changes +- `breaking-changes.md` - Contains breaking changes +- `deprecations.md` - Contains deprecations +- `known-issues.md` - Contains known issues +- `highlights.md` - Contains highlighted entries (only created when at least one entry has `highlight: true`) + +### Asciidoc format + +When `--file-type asciidoc` is specified, the command generates a single asciidoc file with all sections: + +- Security updates +- Bug fixes +- Highlights (only included when at least one entry has `highlight: true`) +- New features and enhancements +- Breaking changes +- Deprecations +- Known issues +- Documentation +- Regressions +- Other changes + +The asciidoc output uses attribute references for links (for example, `{repo-pull}NUMBER[#NUMBER]`). + +AsciiDoc output ignores the `--dropdowns` flag and always uses a standardized format with the following characteristics: + +- Multi-block entries (containing description, Impact, and Action sections) use proper list continuation markers (`+`) to maintain list structure +- Strong text formatting uses idiomatic single asterisk syntax (`*Impact:*`, `*Action:*`) following AsciiDoc best practices +- All content blocks are properly attached to their parent list items for correct rendering + +### Multiple PR and issue links + +Changelog entries can reference multiple pull requests and issues using the `prs` and `issues` array fields. When an entry has multiple links, all of them are rendered inline for that entry: + +```md +* Fix ML calendar event update scalability issues. [#136886](https://github.com/elastic/elastic/pull/136886) [#136900](https://github.com/elastic/elastic/pull/136900) +``` + +## Examples + +### Render a single bundle + +```sh +docs-builder changelog render \ + --input "./docs/changelog/bundles/9.3.0.yaml" \ + --output ./release-notes +``` + +### Render with tilde expansion + +```sh +docs-builder changelog render \ + --input "~/docs/changelog/bundles/9.3.0.yaml|~/docs/changelog|elasticsearch" \ + --output ~/release-notes +``` + +### Render with relative paths + +```sh +docs-builder changelog render \ + --input "./bundles/9.3.0.yaml|./changelog|elasticsearch|keep-links" \ + --file-type markdown \ + --output ./output +``` + +### Merge multiple bundles + +```sh +docs-builder changelog render \ + --input "./bundles/elasticsearch-9.3.0.yaml|./changelog|elasticsearch,./bundles/kibana-9.3.0.yaml|./changelog|kibana" \ + --output ./merged-release-notes +``` + +### Hide links from private repository bundles + +```sh +docs-builder changelog render \ + --input "./public-bundle.yaml|./changelog|elasticsearch|keep-links,./private-bundle.yaml|./private-changelog|internal-repo|hide-links" \ + --output ./release-notes +``` + +### Render with dropdown format + +```sh +docs-builder changelog render \ + --input "./bundles/9.3.0.yaml|./changelog|elasticsearch" \ + --dropdowns \ + --output ./release-notes +``` + +### Render with subsections and flattened format (default) + +```sh +docs-builder changelog render \ + --input "./bundles/9.3.0.yaml|./changelog|elasticsearch" \ + --subsections \ + --output ./release-notes +``` diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs index d9aa5fcb6c..85d8b8809b 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs @@ -63,9 +63,9 @@ private static void RenderEntryTitleAndLinks(StringBuilder sb, ChangelogEntry en /// /// Renders an entry's description with optional comment handling and list continuation /// - private static void RenderEntryDescription(StringBuilder sb, ChangelogEntry entry, bool shouldHide, bool needsContinuation = true) + private static void RenderEntryDescription(StringBuilder sb, ChangelogEntry entry, ChangelogRenderContext context, bool shouldHide, bool needsContinuation = true) { - if (string.IsNullOrWhiteSpace(entry.Description)) + if (context.HideDescriptions || string.IsNullOrWhiteSpace(entry.Description)) return; _ = sb.AppendLine(); @@ -113,7 +113,7 @@ protected void RenderBasicEntry(StringBuilder sb, ChangelogEntry entry, Changelo { var (entryRepo, _, hideLinks, shouldHide) = ChangelogRenderUtilities.GetEntryContext(entry, context); RenderEntryTitleAndLinks(sb, entry, entryRepo, hideLinks, shouldHide); - RenderEntryDescription(sb, entry, shouldHide, needsContinuation: !string.IsNullOrWhiteSpace(entry.Description)); + RenderEntryDescription(sb, entry, context, shouldHide, needsContinuation: !string.IsNullOrWhiteSpace(entry.Description)); _ = sb.AppendLine(); } @@ -127,7 +127,7 @@ protected void RenderEntryWithImpactAction(StringBuilder sb, ChangelogEntry entr // Description needs continuation when it exists var hasDescription = !string.IsNullOrWhiteSpace(entry.Description); - RenderEntryDescription(sb, entry, shouldHide, needsContinuation: hasDescription); + RenderEntryDescription(sb, entry, context, shouldHide, needsContinuation: hasDescription); RenderImpactAndAction(sb, entry); _ = sb.AppendLine(); diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs index 19a949aa76..dac9524d55 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs @@ -21,6 +21,7 @@ public record ChangelogRenderContext public required IReadOnlyDictionary> EntriesByType { get; init; } public required bool Subsections { get; init; } public required bool Dropdowns { get; init; } + public required bool HideDescriptions { get; init; } public required HashSet FeatureIdsToHide { get; init; } public required Dictionary> EntryToBundleProducts { get; init; } public required Dictionary EntryToRepo { get; init; } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 068212a72a..b6a64e8c4c 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -32,6 +32,7 @@ public record RenderChangelogsArguments public string[]? HideFeatures { get; init; } public string? Config { get; init; } public ChangelogFileType FileType { get; init; } = ChangelogFileType.Markdown; + public bool HideDescriptions { get; init; } } @@ -341,6 +342,7 @@ private static ChangelogRenderContext BuildRenderContext( EntriesByType = entriesByType, Subsections = input.Subsections, Dropdowns = input.Dropdowns, + HideDescriptions = input.HideDescriptions, FeatureIdsToHide = featureIdsToHide, EntryToBundleProducts = entryToBundleProducts, EntryToRepo = entryToRepo, diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs index 4cee9acece..0434f2c902 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs @@ -68,7 +68,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct { // Dropdown rendering (current logic) _ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); - _ = sb.AppendLine(entry.Description ?? "% Describe the functionality that changed"); + if (!context.HideDescriptions) + _ = sb.AppendLine(entry.Description ?? "% Describe the functionality that changed"); _ = sb.AppendLine(); RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks)); @@ -92,7 +93,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); // Description with proper indentation - if (!string.IsNullOrWhiteSpace(entry.Description)) + if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description)) { _ = sb.AppendLine(ChangelogTextUtilities.Indent(entry.Description)); _ = sb.AppendLine(); diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs index c5b80228b4..048612f32d 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs @@ -65,7 +65,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct { // Dropdown rendering (current logic) _ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); - _ = sb.AppendLine(entry.Description ?? "% Describe the functionality that was deprecated"); + if (!context.HideDescriptions) + _ = sb.AppendLine(entry.Description ?? "% Describe the functionality that was deprecated"); _ = sb.AppendLine(); RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks)); @@ -89,7 +90,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); // Description with proper indentation - if (!string.IsNullOrWhiteSpace(entry.Description)) + if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description)) { _ = sb.AppendLine(ChangelogTextUtilities.Indent(entry.Description)); _ = sb.AppendLine(); diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs index 5d5cf5a2e7..22d39044b9 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs @@ -68,7 +68,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct { // Dropdown rendering (current logic) _ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); - _ = sb.AppendLine(entry.Description ?? "% Describe the highlight"); + if (!context.HideDescriptions) + _ = sb.AppendLine(entry.Description ?? "% Describe the highlight"); _ = sb.AppendLine(); RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks)); _ = sb.AppendLine("::::"); @@ -81,7 +82,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); // Description with proper indentation - if (!string.IsNullOrWhiteSpace(entry.Description)) + if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description)) { _ = sb.AppendLine(ChangelogTextUtilities.Indent(entry.Description)); _ = sb.AppendLine(); diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index c310834d2b..73f92dee39 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -240,7 +240,7 @@ private static void RenderEntriesByArea( } } - if (!string.IsNullOrWhiteSpace(entry.Description)) + if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description)) { _ = sb.AppendLine(entryHideLinks && hasCommentedLinks ? " " : ""); _ = sb.AppendLine(); diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs index 3f6e3b22da..5b9dbe521a 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs @@ -65,7 +65,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct { // Dropdown rendering (current logic) _ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); - _ = sb.AppendLine(entry.Description ?? "% Describe the known issue"); + if (!context.HideDescriptions) + _ = sb.AppendLine(entry.Description ?? "% Describe the known issue"); _ = sb.AppendLine(); RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks)); @@ -89,7 +90,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(); // Description with proper indentation - if (!string.IsNullOrWhiteSpace(entry.Description)) + if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description)) { _ = sb.AppendLine(ChangelogTextUtilities.Indent(entry.Description)); _ = sb.AppendLine(); diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 3b023cf912..18e17a15b9 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -1150,6 +1150,7 @@ async static (s, collector, state, ctx) => await s.RemoveChangelogs(collector, s /// Optional: Output directory for rendered files. Defaults to current directory /// Optional: Group entries by area/component in subsections. For breaking changes with a subtype, groups by subtype instead of area. Defaults to false /// Optional: Render separated types (breaking changes, deprecations, known issues, highlights) as MyST dropdowns. When false (default), renders as flattened bulleted lists. Defaults to false + /// Optional: Hide changelog record descriptions from output. When enabled, entry titles, PR/issue links, Impact and Action sections remain visible. Bundle-level descriptions are unaffected. Defaults to false /// Optional: Title to use for section headers in output files. Defaults to version from first bundle /// [NoOptionsInjection] @@ -1161,6 +1162,7 @@ public async Task Render( string? output = null, bool subsections = false, bool dropdowns = false, + bool noDescriptions = false, string? title = null, CancellationToken ct = default ) @@ -1194,6 +1196,7 @@ public async Task Render( Title = title, Subsections = subsections, Dropdowns = dropdowns, + HideDescriptions = noDescriptions, HideFeatures = allFeatureIds.Count > 0 ? allFeatureIds.ToArray() : null, FileType = ft.Value, Config = config?.FullName diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/DescriptionVisibilityTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/DescriptionVisibilityTests.cs new file mode 100644 index 0000000000..2c3f6441fb --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/DescriptionVisibilityTests.cs @@ -0,0 +1,458 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO; +using AwesomeAssertions; +using Elastic.Changelog.Bundling; +using Elastic.Changelog.Rendering; +using Elastic.Documentation.Configuration; + +namespace Elastic.Changelog.Tests.Changelogs.Render; + +public class DescriptionVisibilityTests(ITestOutputHelper output) : RenderChangelogTestBase(output) +{ + [Fact] + public async Task RenderChangelogs_DefaultBehavior_IncludesDescriptionsInMarkdown() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file with description + // language=yaml + var changelog1 = + """ + title: Test feature with description + type: feature + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "100" + description: This is a detailed description of the test feature that should be visible by default. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: test-feature.yaml + checksum: {ComputeSha1(changelog1)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir, + FileType = ChangelogFileType.Markdown, + HideDescriptions = false // Default behavior + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var indexMarkdown = FileSystem.Path.Join(outputDir, "9.2.0", "index.md"); + FileSystem.File.Exists(indexMarkdown).Should().BeTrue(); + + var indexContent = await FileSystem.File.ReadAllTextAsync(indexMarkdown, TestContext.Current.CancellationToken); + indexContent.Should().Contain("Test feature with description"); + indexContent.Should().Contain("This is a detailed description of the test feature that should be visible by default."); + indexContent.Should().Contain("[#100](https://github.com/elastic/elasticsearch/pull/100)"); + } + + [Fact] + public async Task RenderChangelogs_NoDescriptionsFlag_HidesDescriptionsInMarkdown() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file with description + // language=yaml + var changelog1 = + """ + title: Test feature with hidden description + type: feature + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "200" + description: This description should be hidden when --no-descriptions flag is used. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: test-feature.yaml + checksum: {ComputeSha1(changelog1)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir, + FileType = ChangelogFileType.Markdown, + HideDescriptions = true // Hide descriptions + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var indexMarkdown = FileSystem.Path.Join(outputDir, "9.2.0", "index.md"); + FileSystem.File.Exists(indexMarkdown).Should().BeTrue(); + + var indexContent = await FileSystem.File.ReadAllTextAsync(indexMarkdown, TestContext.Current.CancellationToken); + + // Title and links should still be present + indexContent.Should().Contain("Test feature with hidden description"); + indexContent.Should().Contain("[#200](https://github.com/elastic/elasticsearch/pull/200)"); + + // Description should be hidden + indexContent.Should().NotContain("This description should be hidden when --no-descriptions flag is used."); + } + + [Fact] + public async Task RenderChangelogs_NoDescriptionsFlag_HidesDescriptionsInAsciidoc() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file with description + // language=yaml + var changelog1 = + """ + title: Test feature for asciidoc + type: feature + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "300" + description: This description should be hidden in asciidoc format when --no-descriptions is used. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: test-feature.yaml + checksum: {ComputeSha1(changelog1)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir, + FileType = ChangelogFileType.Asciidoc, + HideDescriptions = true // Hide descriptions + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var asciidocFiles = FileSystem.Directory.GetFiles(outputDir, "*.asciidoc", SearchOption.AllDirectories); + asciidocFiles.Should().HaveCount(1); + + var asciidocContent = await FileSystem.File.ReadAllTextAsync(asciidocFiles[0], TestContext.Current.CancellationToken); + + // Title and links should still be present + asciidocContent.Should().Contain("Test feature for asciidoc"); + asciidocContent.Should().Contain("{es-pull}300[#300]"); + + // Description should be hidden + asciidocContent.Should().NotContain("This description should be hidden in asciidoc format when --no-descriptions is used."); + } + + [Fact] + public async Task RenderChangelogs_NoDescriptionsFlag_PreservesImpactAndActionForBreakingChanges() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create breaking change with description, impact, and action + // language=yaml + var changelog1 = + """ + title: Breaking change test + type: breaking-change + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "400" + description: This description should be hidden but Impact and Action should remain. + impact: This is the impact section that should always be visible. + action: This is the action section that should always be visible. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "breaking-change.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: breaking-change.yaml + checksum: {ComputeSha1(changelog1)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir, + FileType = ChangelogFileType.Markdown, + HideDescriptions = true, + Dropdowns = false // Test flattened mode + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var breakingChangesMarkdown = FileSystem.Path.Join(outputDir, "9.2.0", "breaking-changes.md"); + FileSystem.File.Exists(breakingChangesMarkdown).Should().BeTrue(); + + var breakingChangesContent = await FileSystem.File.ReadAllTextAsync(breakingChangesMarkdown, TestContext.Current.CancellationToken); + + // Title and links should be present + breakingChangesContent.Should().Contain("Breaking change test"); + breakingChangesContent.Should().Contain("[#400](https://github.com/elastic/elasticsearch/pull/400)"); + + // Description should be hidden + breakingChangesContent.Should().NotContain("This description should be hidden but Impact and Action should remain."); + + // Impact and Action should still be visible + breakingChangesContent.Should().Contain("**Impact:** This is the impact section that should always be visible."); + breakingChangesContent.Should().Contain("**Action:** This is the action section that should always be visible."); + } + + [Fact] + public async Task RenderChangelogs_NoDescriptionsFlag_WorksWithDropdownsMode() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create breaking change with description + // language=yaml + var changelog1 = + """ + title: Breaking change for dropdown test + type: breaking-change + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "500" + description: This description should be hidden in dropdown mode. + impact: Impact visible in dropdown mode. + action: Action visible in dropdown mode. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "breaking-change.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: breaking-change.yaml + checksum: {ComputeSha1(changelog1)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir, + FileType = ChangelogFileType.Markdown, + HideDescriptions = true, + Dropdowns = true // Test dropdown mode + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var breakingChangesMarkdown = FileSystem.Path.Join(outputDir, "9.2.0", "breaking-changes.md"); + FileSystem.File.Exists(breakingChangesMarkdown).Should().BeTrue(); + + var breakingChangesContent = await FileSystem.File.ReadAllTextAsync(breakingChangesMarkdown, TestContext.Current.CancellationToken); + + // Should have dropdown structure + breakingChangesContent.Should().Contain("::::{dropdown} Breaking change for dropdown test"); + breakingChangesContent.Should().Contain("::::"); + + // Links should be present + breakingChangesContent.Should().Contain("[#500](https://github.com/elastic/elasticsearch/pull/500)"); + + // Description should be hidden (no placeholder text either) + breakingChangesContent.Should().NotContain("This description should be hidden in dropdown mode."); + breakingChangesContent.Should().NotContain("% Describe the functionality that changed"); + + // Impact and Action should still be visible + breakingChangesContent.Should().Contain("**Impact**
Impact visible in dropdown mode."); + breakingChangesContent.Should().Contain("**Action**
Action visible in dropdown mode."); + } + + [Fact] + public async Task RenderChangelogs_NoDescriptionsFlag_PreservesBundleDescription() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file with description + // language=yaml + var changelog1 = + """ + title: Test feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + description: This entry description should be hidden. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); + + // Create bundle file with bundle-level description + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + description: | + This is the bundle-level description that should always be visible + regardless of the --no-descriptions flag. + entries: + - file: + name: test-feature.yaml + checksum: {ComputeSha1(changelog1)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir, + FileType = ChangelogFileType.Markdown, + HideDescriptions = true + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var indexMarkdown = FileSystem.Path.Join(outputDir, "9.2.0", "index.md"); + FileSystem.File.Exists(indexMarkdown).Should().BeTrue(); + + var indexContent = await FileSystem.File.ReadAllTextAsync(indexMarkdown, TestContext.Current.CancellationToken); + + // Bundle description should be visible + indexContent.Should().Contain("This is the bundle-level description that should always be visible"); + indexContent.Should().Contain("regardless of the --no-descriptions flag."); + + // Entry title should be present + indexContent.Should().Contain("Test feature"); + + // Entry description should be hidden + indexContent.Should().NotContain("This entry description should be hidden."); + } +} From 482e2ee576192506b88c830918b28a858f2b935c Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Mon, 11 May 2026 07:17:38 -0700 Subject: [PATCH 09/12] Add gfm output format for changelog render (#3272) --- docs/cli/changelog/render.md | 33 +- .../Rendering/ChangelogRenderer.cs | 11 + .../Rendering/ChangelogRenderingService.cs | 7 +- .../Markdown/ChangelogGfmRenderer.cs | 293 +++++++++++ .../docs-builder/Commands/ChangelogCommand.cs | 5 +- .../Changelogs/Render/GfmRenderTests.cs | 480 ++++++++++++++++++ 6 files changed, 824 insertions(+), 5 deletions(-) create mode 100644 src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs create mode 100644 tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs diff --git a/docs/cli/changelog/render.md b/docs/cli/changelog/render.md index 11202b07f5..2eabe5c726 100644 --- a/docs/cli/changelog/render.md +++ b/docs/cli/changelog/render.md @@ -41,10 +41,11 @@ The `render` command automatically discovers and merges `.amend-*.yaml` files wi ::: `--file-type ` -: Optional: Output file type. Valid values: `"markdown"` or `"asciidoc"`. +: Optional: Output file type. Valid values: `"markdown"`, `"asciidoc"`, or `"gfm"`. : Defaults to `"markdown"`. : When `"markdown"` is specified, the command generates multiple markdown files (index.md, breaking-changes.md, deprecations.md, known-issues.md). : When `"asciidoc"` is specified, the command generates a single asciidoc file with all sections. +: When `"gfm"` is specified, the command generates a single changelog.md file optimized for GitHub releases with clean headings and no anchor links. `--output ` : Optional: The output directory for rendered files. @@ -105,6 +106,27 @@ When `--file-type asciidoc` is specified, the command generates a single asciido The asciidoc output uses attribute references for links (for example, `{repo-pull}NUMBER[#NUMBER]`). +### GFM format + +When `--file-type gfm` is specified, the command generates a single GitHub Flavored Markdown file optimized for GitHub releases: + +- `changelog.md` - Contains all sections in a single file with clean headings +- Clean section headings without anchor links (e.g., `### Features and enhancements`) +- Simplified structure focused on readability +- Suitable for copy/pasting into GitHub releases + +The GFM output includes the following sections in order when entries are present: + +- Highlights (only included when at least one entry has `highlight: true`) +- Features and enhancements +- Breaking changes +- Deprecations +- Bug fixes (includes security updates) +- Known issues +- Documentation +- Regressions +- Other changes + AsciiDoc output ignores the `--dropdowns` flag and always uses a standardized format with the following characteristics: - Multi-block entries (containing description, Impact, and Action sections) use proper list continuation markers (`+`) to maintain list structure @@ -179,3 +201,12 @@ docs-builder changelog render \ --subsections \ --output ./release-notes ``` + +### Render as GitHub Flavored Markdown for releases + +```sh +docs-builder changelog render \ + --input "./bundles/9.3.0.yaml|./changelog|elasticsearch" \ + --file-type gfm \ + --output ./github-release +``` diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs index 2f901c29d4..5e609cd243 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs @@ -33,6 +33,10 @@ public async Task RenderAsync( await RenderMarkdownAsync(context, ctx); break; + case ChangelogFileType.Gfm: + await RenderGfmAsync(context, ctx); + break; + default: throw new ArgumentException($"Unknown changelog file type: {fileType}", nameof(fileType)); } @@ -51,4 +55,11 @@ private async Task RenderMarkdownAsync(ChangelogRenderContext context, Cancel ct await markdownRenderer.RenderAsync(context, ctx); logger.LogInformation("Rendered changelog markdown files to {OutputDir}", context.OutputDir); } + + private async Task RenderGfmAsync(ChangelogRenderContext context, Cancel ctx) + { + var gfmRenderer = new ChangelogGfmRenderer(fileSystem); + await gfmRenderer.RenderAsync(context, ctx); + logger.LogInformation("Rendered changelog GFM file to {OutputDir}", context.OutputDir); + } } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index b6a64e8c4c..0c8188b41b 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -60,11 +60,14 @@ public enum ChangelogFileType Markdown, [Display(Name = "asciidoc")] [JsonStringEnumMemberName("asciidoc")] - Asciidoc + Asciidoc, + [Display(Name = "gfm")] + [JsonStringEnumMemberName("gfm")] + Gfm } /// -/// Service for rendering changelog output (markdown or asciidoc) +/// Service for rendering changelog output (markdown, asciidoc, or gfm) /// public class ChangelogRenderingService( ILoggerFactory logFactory, diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs new file mode 100644 index 0000000000..38a4afe3e5 --- /dev/null +++ b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs @@ -0,0 +1,293 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Collections.Generic; +using System.IO.Abstractions; +using System.Text; +using Elastic.Documentation.ReleaseNotes; +using Nullean.ScopedFileSystem; +using static System.Globalization.CultureInfo; +using static Elastic.Documentation.ChangelogEntryType; + +namespace Elastic.Changelog.Rendering.Markdown; + +/// +/// Renderer for generating clean GitHub Flavored Markdown in a single changelog.md file +/// +public class ChangelogGfmRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +{ + /// + public override string OutputFileName => "changelog.md"; + + /// + public override async Task RenderAsync(ChangelogRenderContext context, Cancel ctx) + { + var entriesByType = context.EntriesByType; + var features = entriesByType.GetValueOrDefault(Feature, []); + var enhancements = entriesByType.GetValueOrDefault(Enhancement, []); + var security = entriesByType.GetValueOrDefault(Security, []); + var bugFixes = entriesByType.GetValueOrDefault(BugFix, []); + var docs = entriesByType.GetValueOrDefault(Docs, []); + var regressions = entriesByType.GetValueOrDefault(Regression, []); + var other = entriesByType.GetValueOrDefault(Other, []); + var breakingChanges = entriesByType.GetValueOrDefault(BreakingChange, []); + var deprecations = entriesByType.GetValueOrDefault(Deprecation, []); + var knownIssues = entriesByType.GetValueOrDefault(KnownIssue, []); + + // Check for highlights + var highlights = entriesByType.Values + .SelectMany(e => e) + .Where(e => e.Highlight == true) + .ToList(); + + var sb = new StringBuilder(); + + // Main heading - clean without anchors + _ = sb.AppendLine(InvariantCulture, $"## {context.Title}"); + + // Release date if present + if (context.BundleReleaseDate is { } releaseDate) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(InvariantCulture, $"_Released: {releaseDate.ToString("MMMM d, yyyy", InvariantCulture)}_"); + } + + // Add description if present + if (!string.IsNullOrEmpty(context.BundleDescription)) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(context.BundleDescription); + } + + _ = sb.AppendLine(); + + // Helper to check if all entries in a collection are hidden + bool AllEntriesHidden(IReadOnlyCollection entries) => + entries.Count > 0 && entries.All(entry => + ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + + // Render highlights first if any exist + if (highlights.Count > 0) + { + _ = sb.AppendLine("### Highlights"); + RenderEntriesByArea(sb, highlights, context); + _ = sb.AppendLine(); + } + + // Features and enhancements + if (features.Count > 0 || enhancements.Count > 0) + { + var combined = features.Concat(enhancements).ToList(); + if (!AllEntriesHidden(combined)) + { + _ = sb.AppendLine("### Features and enhancements"); + RenderEntriesByArea(sb, combined, context); + _ = sb.AppendLine(); + } + } + + // Breaking changes + if (breakingChanges.Count > 0 && !AllEntriesHidden(breakingChanges)) + { + _ = sb.AppendLine("### Breaking changes"); + RenderEntriesByArea(sb, breakingChanges, context); + _ = sb.AppendLine(); + } + + // Deprecations + if (deprecations.Count > 0 && !AllEntriesHidden(deprecations)) + { + _ = sb.AppendLine("### Deprecations"); + RenderEntriesByArea(sb, deprecations, context); + _ = sb.AppendLine(); + } + + // Bug fixes and security updates + if (security.Count > 0 || bugFixes.Count > 0) + { + var combined = security.Concat(bugFixes).ToList(); + if (!AllEntriesHidden(combined)) + { + _ = sb.AppendLine("### Bug fixes"); + RenderEntriesByArea(sb, combined, context); + _ = sb.AppendLine(); + } + } + + // Known issues + if (knownIssues.Count > 0 && !AllEntriesHidden(knownIssues)) + { + _ = sb.AppendLine("### Known issues"); + RenderEntriesByArea(sb, knownIssues, context); + _ = sb.AppendLine(); + } + + // Documentation + if (docs.Count > 0 && !AllEntriesHidden(docs)) + { + _ = sb.AppendLine("### Documentation"); + RenderEntriesByArea(sb, docs, context); + _ = sb.AppendLine(); + } + + // Regressions + if (regressions.Count > 0 && !AllEntriesHidden(regressions)) + { + _ = sb.AppendLine("### Regressions"); + RenderEntriesByArea(sb, regressions, context); + _ = sb.AppendLine(); + } + + // Other changes + if (other.Count > 0 && !AllEntriesHidden(other)) + { + _ = sb.AppendLine("### Other changes"); + RenderEntriesByArea(sb, other, context); + _ = sb.AppendLine(); + } + + // Check if we have any visible content + var hasAnyVisibleContent = highlights.Count > 0 || + (!AllEntriesHidden(features) && features.Count > 0) || + (!AllEntriesHidden(enhancements) && enhancements.Count > 0) || + (!AllEntriesHidden(breakingChanges) && breakingChanges.Count > 0) || + (!AllEntriesHidden(deprecations) && deprecations.Count > 0) || + (!AllEntriesHidden(security) && security.Count > 0) || + (!AllEntriesHidden(bugFixes) && bugFixes.Count > 0) || + (!AllEntriesHidden(knownIssues) && knownIssues.Count > 0) || + (!AllEntriesHidden(docs) && docs.Count > 0) || + (!AllEntriesHidden(regressions) && regressions.Count > 0) || + (!AllEntriesHidden(other) && other.Count > 0); + + if (!hasAnyVisibleContent) + { + _ = sb.AppendLine("_There are no new features, enhancements, or fixes associated with this release._"); + _ = sb.AppendLine(); + } + + await WriteOutputFileAsync(context.OutputDir, context.TitleSlug, sb.ToString(), ctx); + } + + private static void RenderEntriesByArea( + StringBuilder sb, + IReadOnlyCollection entries, + ChangelogRenderContext context) + { + var groupedByArea = context.Subsections + ? entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList() + : entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).ToList(); + + foreach (var areaGroup in groupedByArea) + { + // Check if all entries in this area group are hidden + var allEntriesHidden = areaGroup.All(entry => + ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context)); + + if (context.Subsections && !string.IsNullOrWhiteSpace(areaGroup.Key)) + { + var header = ChangelogTextUtilities.FormatAreaHeader(areaGroup.Key); + if (allEntriesHidden) + _ = sb.Append("% "); + _ = sb.AppendLine(InvariantCulture, $"**{header}**"); + _ = sb.AppendLine(); + } + + foreach (var entry in areaGroup) + { + var (entryRepo, entryOwner, entryHideLinks, shouldHide) = ChangelogRenderUtilities.GetEntryContext(entry, context); + + if (shouldHide) + _ = sb.Append("% "); + _ = sb.Append("* "); + _ = sb.Append(ChangelogTextUtilities.Beautify(entry.Title)); + + var hasCommentedLinks = false; + if (entryHideLinks) + { + foreach (var pr in entry.Prs ?? []) + { + var formatted = ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner); + if (string.IsNullOrEmpty(formatted)) + continue; + + _ = sb.AppendLine(); + if (shouldHide) + _ = sb.Append("% "); + _ = sb.Append(" "); + _ = sb.Append(formatted); + hasCommentedLinks = true; + } + + foreach (var issue in entry.Issues ?? []) + { + var formatted = ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner); + if (string.IsNullOrEmpty(formatted)) + continue; + + _ = sb.AppendLine(); + if (shouldHide) + _ = sb.Append("% "); + _ = sb.Append(" "); + _ = sb.Append(formatted); + hasCommentedLinks = true; + } + + if (hasCommentedLinks) + _ = sb.AppendLine(); + } + else + { + var linkParts = new List(); + foreach (var pr in entry.Prs ?? []) + { + var s = ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner); + if (!string.IsNullOrEmpty(s)) + linkParts.Add(s); + } + + foreach (var issue in entry.Issues ?? []) + { + var s = ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner); + if (!string.IsNullOrEmpty(s)) + linkParts.Add(s); + } + + if (linkParts.Count > 0) + { + _ = sb.Append(' '); + var first = true; + foreach (var s in linkParts) + { + if (!first) + _ = sb.Append(' '); + _ = sb.Append(s); + first = false; + } + } + } + + if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description)) + { + _ = sb.AppendLine(entryHideLinks && hasCommentedLinks ? " " : ""); + _ = sb.AppendLine(); + var indented = ChangelogTextUtilities.Indent(entry.Description); + if (shouldHide) + { + // Comment out each line of the description + var indentedLines = indented.Split('\n'); + foreach (var line in indentedLines) + { + _ = sb.Append("% "); + _ = sb.AppendLine(line); + } + } + else + _ = sb.AppendLine(indented); + } + else + _ = sb.AppendLine(); + } + } + } +} diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 18e17a15b9..909c56823e 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -1145,7 +1145,7 @@ async static (s, collector, state, ctx) => await s.RemoveChangelogs(collector, s /// Render one or more changelog bundles to Markdown or AsciiDoc. /// Required: Bundle input(s) in format "bundle-file-path|changelog-file-path|repo|link-visibility" (use pipe as delimiter). To merge multiple bundles, separate them with commas. Only bundle-file-path is required. link-visibility can be "hide-links" or "keep-links" (default). Use "hide-links" for private repositories; when set, all PR and issue links for each affected entry are hidden (entries may have multiple links via the prs and issues arrays). Paths support tilde (~) expansion and relative paths. /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' - /// Optional: Output file type. Valid values: "markdown" or "asciidoc". Defaults to "markdown" + /// Optional: Output file type. Valid values: "markdown", "asciidoc", or "gfm". Defaults to "markdown" /// Filter by feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. Entries with matching feature-id values will be commented out in the output. /// Optional: Output directory for rendered files. Defaults to current directory /// Optional: Group entries by area/component in subsections. For breaking changes with a subtype, groups by subtype instead of area. Defaults to false @@ -1178,11 +1178,12 @@ public async Task Render( { "markdown" => ChangelogFileType.Markdown, "asciidoc" => ChangelogFileType.Asciidoc, + "gfm" => ChangelogFileType.Gfm, _ => null }; if (ft is null) { - collector.EmitError(string.Empty, $"Invalid file-type '{fileType}'. Valid values are 'markdown' or 'asciidoc'."); + collector.EmitError(string.Empty, $"Invalid file-type '{fileType}'. Valid values are 'markdown', 'asciidoc', or 'gfm'."); return 1; } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs new file mode 100644 index 0000000000..e6e71524e6 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs @@ -0,0 +1,480 @@ +// 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; +using Elastic.Changelog.Rendering; +using Elastic.Documentation.Configuration; + +namespace Elastic.Changelog.Tests.Changelogs.Render; + +public class GfmRenderTests(ITestOutputHelper output) : RenderChangelogTestBase(output) +{ + [Fact] + public async Task RenderChangelogs_WithGfmFileType_CreatesSingleGfmFile() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file + // language=yaml + var changelog = + """ + title: Test feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + prs: + - "100" + description: This is a test feature + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "test-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: test-feature.yaml + checksum: {ComputeSha1(changelog)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir, + Title = "9.2.0", + FileType = ChangelogFileType.Gfm + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + // Should create only changelog.md file, not multiple files like regular markdown + var outputFile = FileSystem.Path.Join(outputDir, "9.2.0", "changelog.md"); + FileSystem.File.Exists(outputFile).Should().BeTrue(); + + // Should NOT create separate files + var indexFile = FileSystem.Path.Join(outputDir, "9.2.0", "index.md"); + var breakingChangesFile = FileSystem.Path.Join(outputDir, "9.2.0", "breaking-changes.md"); + FileSystem.File.Exists(indexFile).Should().BeFalse(); + FileSystem.File.Exists(breakingChangesFile).Should().BeFalse(); + + var content = await FileSystem.File.ReadAllTextAsync(outputFile, TestContext.Current.CancellationToken); + content.Should().Contain("## 9.2.0"); + content.Should().Contain("### Features and enhancements"); + content.Should().Contain("Test feature"); + content.Should().Contain("[#100](https://github.com/elastic/elasticsearch/pull/100)"); + + // Should NOT contain anchor brackets in headings + content.Should().NotContain("## 9.2.0 ["); + content.Should().NotContain("### Features and enhancements ["); + } + + [Fact] + public async Task RenderChangelogs_WithGfmFileType_IncludesAllSectionTypes() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog files for different types + // language=yaml + var feature = + """ + title: New feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + """; + + // language=yaml + var breakingChange = + """ + title: Breaking change + type: breaking-change + products: + - product: elasticsearch + target: 9.2.0 + """; + + // language=yaml + var deprecation = + """ + title: Deprecated API + type: deprecation + products: + - product: elasticsearch + target: 9.2.0 + """; + + // language=yaml + var bugFix = + """ + title: Bug fix + type: bug-fix + products: + - product: elasticsearch + target: 9.2.0 + """; + + // language=yaml + var knownIssue = + """ + title: Known issue + type: known-issue + products: + - product: elasticsearch + target: 9.2.0 + """; + + var featureFile = FileSystem.Path.Join(changelogDir, "feature.yaml"); + var breakingFile = FileSystem.Path.Join(changelogDir, "breaking.yaml"); + var deprecationFile = FileSystem.Path.Join(changelogDir, "deprecation.yaml"); + var bugFixFile = FileSystem.Path.Join(changelogDir, "bugfix.yaml"); + var knownIssueFile = FileSystem.Path.Join(changelogDir, "known-issue.yaml"); + + await FileSystem.File.WriteAllTextAsync(featureFile, feature, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(breakingFile, breakingChange, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(deprecationFile, deprecation, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(bugFixFile, bugFix, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(knownIssueFile, knownIssue, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: feature.yaml + checksum: {ComputeSha1(feature)} + - file: + name: breaking.yaml + checksum: {ComputeSha1(breakingChange)} + - file: + name: deprecation.yaml + checksum: {ComputeSha1(deprecation)} + - file: + name: bugfix.yaml + checksum: {ComputeSha1(bugFix)} + - file: + name: known-issue.yaml + checksum: {ComputeSha1(knownIssue)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir, + Title = "9.2.0", + FileType = ChangelogFileType.Gfm + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var outputChangelogPath = FileSystem.Path.Join(outputDir, "9.2.0", "changelog.md"); + var content = await FileSystem.File.ReadAllTextAsync(outputChangelogPath, TestContext.Current.CancellationToken); + + // Should include all section types in the proper order + content.Should().Contain("### Features and enhancements"); + content.Should().Contain("### Breaking changes"); + content.Should().Contain("### Deprecations"); + content.Should().Contain("### Bug fixes"); + content.Should().Contain("### Known issues"); + + // Should contain the entry titles + content.Should().Contain("New feature"); + content.Should().Contain("Breaking change"); + content.Should().Contain("Deprecated API"); + content.Should().Contain("Bug fix"); + content.Should().Contain("Known issue"); + + // Check section ordering (features should come before breaking changes) + var featuresIndex = content.IndexOf("### Features and enhancements", StringComparison.Ordinal); + var breakingIndex = content.IndexOf("### Breaking changes", StringComparison.Ordinal); + var deprecationIndex = content.IndexOf("### Deprecations", StringComparison.Ordinal); + var bugFixIndex = content.IndexOf("### Bug fixes", StringComparison.Ordinal); + var knownIssueIndex = content.IndexOf("### Known issues", StringComparison.Ordinal); + + featuresIndex.Should().BeLessThan(breakingIndex); + breakingIndex.Should().BeLessThan(deprecationIndex); + deprecationIndex.Should().BeLessThan(bugFixIndex); + bugFixIndex.Should().BeLessThan(knownIssueIndex); + } + + [Fact] + public async Task RenderChangelogs_WithGfmFileType_HandlesHighlights() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog with highlight + // language=yaml + var highlightedFeature = + """ + title: Important new feature + type: feature + highlight: true + products: + - product: elasticsearch + target: 9.2.0 + """; + + // language=yaml + var normalFeature = + """ + title: Regular feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + """; + + var highlightFile = FileSystem.Path.Join(changelogDir, "highlight.yaml"); + var normalFile = FileSystem.Path.Join(changelogDir, "normal.yaml"); + + await FileSystem.File.WriteAllTextAsync(highlightFile, highlightedFeature, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(normalFile, normalFeature, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: highlight.yaml + checksum: {ComputeSha1(highlightedFeature)} + - file: + name: normal.yaml + checksum: {ComputeSha1(normalFeature)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir, + Title = "9.2.0", + FileType = ChangelogFileType.Gfm + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var highlightsOutputFile = FileSystem.Path.Join(outputDir, "9.2.0", "changelog.md"); + var content = await FileSystem.File.ReadAllTextAsync(highlightsOutputFile, TestContext.Current.CancellationToken); + + // Should include highlights section first + content.Should().Contain("### Highlights"); + content.Should().Contain("### Features and enhancements"); + + // Highlights should come first + var highlightsIndex = content.IndexOf("### Highlights", StringComparison.Ordinal); + var featuresIndex = content.IndexOf("### Features and enhancements", StringComparison.Ordinal); + highlightsIndex.Should().BeLessThan(featuresIndex); + + // Both features should be present + content.Should().Contain("Important new feature"); + content.Should().Contain("Regular feature"); + } + + [Fact] + public async Task RenderChangelogs_WithGfmFileType_HandlesDescriptionsAndHideDescriptions() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog with description + // language=yaml + var changelog = + """ + title: Feature with description + type: feature + products: + - product: elasticsearch + target: 9.2.0 + description: | + This is a detailed description of the feature. + It spans multiple lines. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: feature.yaml + checksum: {ComputeSha1(changelog)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + // Test with descriptions shown (default) + var inputWithDescriptions = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir, + Title = "9.2.0", + FileType = ChangelogFileType.Gfm, + HideDescriptions = false + }; + + // Act + var result = await Service.RenderChangelogs(Collector, inputWithDescriptions, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var outputChangelogFile = FileSystem.Path.Join(outputDir, "9.2.0", "changelog.md"); + var content = await FileSystem.File.ReadAllTextAsync(outputChangelogFile, TestContext.Current.CancellationToken); + + content.Should().Contain("Feature with description"); + content.Should().Contain("This is a detailed description of the feature."); + + // Test with descriptions hidden + var outputDir2 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + var inputWithoutDescriptions = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir2, + Title = "9.2.0", + FileType = ChangelogFileType.Gfm, + HideDescriptions = true + }; + + var result2 = await Service.RenderChangelogs(Collector, inputWithoutDescriptions, TestContext.Current.CancellationToken); + + result2.Should().BeTrue(); + var changelogFile2 = FileSystem.Path.Join(outputDir2, "9.2.0", "changelog.md"); + var content2 = await FileSystem.File.ReadAllTextAsync(changelogFile2, TestContext.Current.CancellationToken); + + content2.Should().Contain("Feature with description"); + content2.Should().NotContain("This is a detailed description of the feature."); + } + + [Fact] + public async Task RenderChangelogs_WithGfmFileType_HandlesBundleDescriptionAndReleaseDate() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create simple changelog + // language=yaml + var changelog = + """ + title: Simple feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "feature.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); + + // Create bundle file with description and release date + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.2.0 + description: "This is a major release with many improvements." + release-date: "2024-03-15" + entries: + - file: + name: feature.yaml + checksum: {ComputeSha1(changelog)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir, + Title = "9.2.0", + FileType = ChangelogFileType.Gfm + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var finalChangelogFile = FileSystem.Path.Join(outputDir, "9.2.0", "changelog.md"); + var content = await FileSystem.File.ReadAllTextAsync(finalChangelogFile, TestContext.Current.CancellationToken); + + // Should include the bundle description and release date + content.Should().Contain("## 9.2.0"); + content.Should().Contain("_Released: March 15, 2024_"); + content.Should().Contain("This is a major release with many improvements."); + } +} From 14f2d2656b3ae9baf34b0b6281a6e8626578eb2f Mon Sep 17 00:00:00 2001 From: lcawl Date: Tue, 12 May 2026 09:54:51 -0700 Subject: [PATCH 10/12] Reconcile render docs --- docs/cli/changelog/cmd-render.md | 35 ++++- docs/cli/changelog/render.md | 212 ------------------------------- 2 files changed, 34 insertions(+), 213 deletions(-) delete mode 100644 docs/cli/changelog/render.md diff --git a/docs/cli/changelog/cmd-render.md b/docs/cli/changelog/cmd-render.md index ffd314a2b0..cd5b84e6c3 100644 --- a/docs/cli/changelog/cmd-render.md +++ b/docs/cli/changelog/cmd-render.md @@ -52,6 +52,33 @@ AsciiDoc output ignores the `--dropdowns` flag and always uses a standardized fo - Strong text formatting uses idiomatic single asterisk syntax (`*Impact:*`, `*Action:*`) following AsciiDoc best practices - All content blocks are properly attached to their parent list items for correct rendering. +### GFM format + +When `--file-type gfm` is specified, the command generates a single GitHub Flavored Markdown file optimized for GitHub releases: + +- `changelog.md` - Contains all sections in a single file with clean headings +- Clean section headings without anchor links (for example, `### Features and enhancements`) +- Simplified structure focused on readability +- Suitable for copy/pasting into GitHub releases + +The GFM output includes the following sections in order when entries are present: + +- Highlights (only included when at least one entry has `highlight: true`) +- Features and enhancements +- Breaking changes +- Deprecations +- Bug fixes (includes security updates) +- Known issues +- Documentation +- Regressions +- Other changes + +AsciiDoc output ignores the `--dropdowns` flag and always uses a standardized format with the following characteristics: + +- Multi-block entries (containing description, Impact, and Action sections) use proper list continuation markers (`+`) to maintain list structure +- Strong text formatting uses idiomatic single asterisk syntax (`*Impact:*`, `*Action:*`) following AsciiDoc best practices +- All content blocks are properly attached to their parent list items for correct rendering + ### Multiple PR and issue links Changelog entries can reference multiple pull requests and issues via the `prs` and `issues` array fields. All links are rendered inline: @@ -66,7 +93,7 @@ Changelog entries can reference multiple pull requests and issues via the `prs` # Render a single bundle docs-builder changelog render \ --input "./docs/changelog/bundles/9.3.0.yaml" \ - --output ./release-notes + --output ./release-notes \ # Render with explicit changelog dir and repo docs-builder changelog render \ @@ -94,4 +121,10 @@ docs-builder changelog render \ --input "./docs/changelog/bundles/9.3.0.yaml" \ --output ./release-notes \ --dropdowns + +# Render as GitHub Flavored Markdown +docs-builder changelog render \ + --input "./docs/changelog/bundles/9.3.0.yaml" \ + --output ./release-notes \ + --file-type gfm ``` diff --git a/docs/cli/changelog/render.md b/docs/cli/changelog/render.md deleted file mode 100644 index 2eabe5c726..0000000000 --- a/docs/cli/changelog/render.md +++ /dev/null @@ -1,212 +0,0 @@ -# changelog render - -Generate markdown or asciidoc files from changelog bundle files. - -To create the bundle files, use [](/cli/changelog/bundle.md). -For details and examples, go to [](/contribute/publish-changelogs.md). - -## Usage - -```sh -docs-builder changelog render [options...] [-h|--help] -``` - -## Options - -`--config ` -: Optional: Path to the changelog.yml configuration file. -: Defaults to `docs/changelog.yml`. -: Note: The `changelog render` command does not use `rules.publish` for filtering. Filtering must be done at bundle time using `rules.bundle`. - -`--hide-features ` -: Optional: Filter by feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. -: Each occurrence can be either comma-separated feature IDs (e.g., `--hide-features "feature:new-search-api,feature:enhanced-analytics"`) or a file path (e.g., `--hide-features /path/to/file.txt`). -: When specifying feature IDs directly, provide comma-separated values. -: When specifying a file path, provide a single value that points to a newline-delimited file. The file should contain one feature ID per line. -: Entries with matching `feature-id` values will be commented out in the output and a warning will be emitted. -: If the bundle contains `hide-features` values (that is to say, it was created with the `--hide-features` option), those values are merged with this list and are also hidden. - -`--input ` -: One or more bundle input files. -: Each bundle is specified as "bundle-file-path|changelog-file-path|repo|link-visibility" using pipe (`|`) as delimiter. -: To merge multiple bundles, separate them with commas: `--input "bundle1|dir1|repo1|keep-links,bundle2|dir2|repo2|hide-links"`. -: For example, `--input "/path/to/changelog-bundle.yaml|/path/to/changelogs|elasticsearch|keep-links"`. -: Only `bundle-file-path` is required for each bundle. -: Use `repo` if your changelogs do not contain full URLs for the pull requests or issues; otherwise they will be incorrectly derived with "elastic/elastic" in the URL by default. -: Use `link-visibility` to control whether PR/issue links are shown or hidden for entries from this bundle. Valid values are `keep-links` (default) or `hide-links`. Use `hide-links` for bundles from private repositories. When `hide-links` is set, all links are hidden for each affected entry — changelog entries can contain multiple PR links (`prs`) and issue links (`issues`), and all of them are hidden or shown together. -: Paths support tilde (`~`) expansion and relative paths. - -:::{note} -The `render` command automatically discovers and merges `.amend-*.yaml` files with their parent bundle. For more information about amended bundles, go to [](bundle-amend.md). -::: - -`--file-type ` -: Optional: Output file type. Valid values: `"markdown"`, `"asciidoc"`, or `"gfm"`. -: Defaults to `"markdown"`. -: When `"markdown"` is specified, the command generates multiple markdown files (index.md, breaking-changes.md, deprecations.md, known-issues.md). -: When `"asciidoc"` is specified, the command generates a single asciidoc file with all sections. -: When `"gfm"` is specified, the command generates a single changelog.md file optimized for GitHub releases with clean headings and no anchor links. - -`--output ` -: Optional: The output directory for rendered files. -: Defaults to current directory. - -`--subsections` -: Optional: Group entries by area in subsections. -: Defaults to false. -: When enabled, entries are grouped by their area within each section. The first area from each entry's areas list is used for grouping. - -`--dropdowns` -: Optional: Render separated types (breaking changes, deprecations, known issues, highlights) as MyST dropdowns. -: Defaults to false (flattened bulleted lists). -: When enabled, each entry in separated files is rendered as a collapsible dropdown section using MyST syntax (`::::{dropdown}`). -: When disabled (default), entries are rendered as flattened bulleted lists with PR/issue links inline and Impact/Action sections indented. -: This flag only affects markdown output; AsciiDoc output always uses its standard format. - -`--no-descriptions` -: Optional: Hide changelog record descriptions from output. -: Defaults to false (descriptions are shown). -: When enabled, entry titles, PR/issue links, Impact and Action sections remain visible. -: Bundle-level descriptions (release intro text) are unaffected. -: Works with both markdown and asciidoc output formats. - -`--title ` -: Optional: The title to use for section headers, directories, and anchors in output files. -: Defaults to the version in the first bundle. When omitted, ISO date targets are formatted for display the same way as the `{changelog}` directive (e.g., `2026-05-04` becomes "May 4, 2026", `2026-05` becomes "May 2026"), while directory names and heading anchors continue to use the raw target slug. -: If the string contains spaces, they are replaced with dashes when used in directory names and anchors. - -The `changelog render` command does **not** use `rules.publish` for filtering. Filtering must be done at bundle time using `rules.bundle`. For more information, refer to [](/contribute/publish-changelogs.md). For how the directive differs, see the [{changelog} directive syntax reference](/syntax/changelog.md). - -## Output formats - -### Markdown format - -When `--file-type markdown` is specified (the default), the command generates multiple markdown files: - -- `index.md` - Contains features, enhancements, bug fixes, security updates, documentation changes, regressions, and other changes -- `breaking-changes.md` - Contains breaking changes -- `deprecations.md` - Contains deprecations -- `known-issues.md` - Contains known issues -- `highlights.md` - Contains highlighted entries (only created when at least one entry has `highlight: true`) - -### Asciidoc format - -When `--file-type asciidoc` is specified, the command generates a single asciidoc file with all sections: - -- Security updates -- Bug fixes -- Highlights (only included when at least one entry has `highlight: true`) -- New features and enhancements -- Breaking changes -- Deprecations -- Known issues -- Documentation -- Regressions -- Other changes - -The asciidoc output uses attribute references for links (for example, `{repo-pull}NUMBER[#NUMBER]`). - -### GFM format - -When `--file-type gfm` is specified, the command generates a single GitHub Flavored Markdown file optimized for GitHub releases: - -- `changelog.md` - Contains all sections in a single file with clean headings -- Clean section headings without anchor links (e.g., `### Features and enhancements`) -- Simplified structure focused on readability -- Suitable for copy/pasting into GitHub releases - -The GFM output includes the following sections in order when entries are present: - -- Highlights (only included when at least one entry has `highlight: true`) -- Features and enhancements -- Breaking changes -- Deprecations -- Bug fixes (includes security updates) -- Known issues -- Documentation -- Regressions -- Other changes - -AsciiDoc output ignores the `--dropdowns` flag and always uses a standardized format with the following characteristics: - -- Multi-block entries (containing description, Impact, and Action sections) use proper list continuation markers (`+`) to maintain list structure -- Strong text formatting uses idiomatic single asterisk syntax (`*Impact:*`, `*Action:*`) following AsciiDoc best practices -- All content blocks are properly attached to their parent list items for correct rendering - -### Multiple PR and issue links - -Changelog entries can reference multiple pull requests and issues using the `prs` and `issues` array fields. When an entry has multiple links, all of them are rendered inline for that entry: - -```md -* Fix ML calendar event update scalability issues. [#136886](https://github.com/elastic/elastic/pull/136886) [#136900](https://github.com/elastic/elastic/pull/136900) -``` - -## Examples - -### Render a single bundle - -```sh -docs-builder changelog render \ - --input "./docs/changelog/bundles/9.3.0.yaml" \ - --output ./release-notes -``` - -### Render with tilde expansion - -```sh -docs-builder changelog render \ - --input "~/docs/changelog/bundles/9.3.0.yaml|~/docs/changelog|elasticsearch" \ - --output ~/release-notes -``` - -### Render with relative paths - -```sh -docs-builder changelog render \ - --input "./bundles/9.3.0.yaml|./changelog|elasticsearch|keep-links" \ - --file-type markdown \ - --output ./output -``` - -### Merge multiple bundles - -```sh -docs-builder changelog render \ - --input "./bundles/elasticsearch-9.3.0.yaml|./changelog|elasticsearch,./bundles/kibana-9.3.0.yaml|./changelog|kibana" \ - --output ./merged-release-notes -``` - -### Hide links from private repository bundles - -```sh -docs-builder changelog render \ - --input "./public-bundle.yaml|./changelog|elasticsearch|keep-links,./private-bundle.yaml|./private-changelog|internal-repo|hide-links" \ - --output ./release-notes -``` - -### Render with dropdown format - -```sh -docs-builder changelog render \ - --input "./bundles/9.3.0.yaml|./changelog|elasticsearch" \ - --dropdowns \ - --output ./release-notes -``` - -### Render with subsections and flattened format (default) - -```sh -docs-builder changelog render \ - --input "./bundles/9.3.0.yaml|./changelog|elasticsearch" \ - --subsections \ - --output ./release-notes -``` - -### Render as GitHub Flavored Markdown for releases - -```sh -docs-builder changelog render \ - --input "./bundles/9.3.0.yaml|./changelog|elasticsearch" \ - --file-type gfm \ - --output ./github-release -``` From d8d50b076d255d607b2639c0973107475e58d3a7 Mon Sep 17 00:00:00 2001 From: lcawl Date: Tue, 12 May 2026 13:09:20 -0700 Subject: [PATCH 11/12] Refresh cli-schema.json --- docs/cli-schema.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 1e089f1443..9d6d52fb9e 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -4500,7 +4500,7 @@ "shortName": null, "type": "string", "required": false, - "summary": "Optional: Output file type. Valid values: \u0022markdown\u0022 or \u0022asciidoc\u0022. Defaults to \u0022markdown\u0022", + "summary": "Optional: Output file type. Valid values: \u0022markdown\u0022, \u0022asciidoc\u0022, or \u0022gfm\u0022. Defaults to \u0022markdown\u0022", "defaultValue": "markdown" }, { @@ -4539,6 +4539,15 @@ "summary": "Optional: Render separated types (breaking changes, deprecations, known issues, highlights) as MyST dropdowns. When false (default), renders as flattened bulleted lists. Defaults to false", "defaultValue": "false" }, + { + "role": "flag", + "name": "no-descriptions", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Hide changelog record descriptions from output. When enabled, entry titles, PR/issue links, Impact and Action sections remain visible. Bundle-level descriptions are unaffected. Defaults to false", + "defaultValue": "false" + }, { "role": "flag", "name": "title", From fe615b8ca0b71901c211d737b067a0f50aa95cf3 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 14 May 2026 12:40:41 -0300 Subject: [PATCH 12/12] Address CodeRabbit review - Document --no-descriptions in cmd-render.md options and add an example entry; the flag existed in the XML doc and cli-schema.json but was missing from the user-facing page. - Drop a trailing backslash from the first shell example so the block copy/pastes correctly. - Strengthen the GFM hide-descriptions test: assert Collector.Errors after the second render and check both lines of the multi-line description in each pass so multi-line regressions are caught. Deferred CodeRabbit suggestions (with reason): - ConfigureAwait(false) on the new GFM awaits: not used anywhere else in src/services/Elastic.Changelog/ (zero usages); applying it only to the new code would create more drift than it removes. - Extract helpers from ChangelogGfmRenderer.RenderAsync / RenderEntriesByArea to lower branch count: the sibling per-type renderers (BreakingChangesMarkdownRenderer, etc.) follow the same pattern; refactoring just the new file would be inconsistent and is out of scope for this PR. Co-authored-by: Cursor --- docs/cli/changelog/cmd-render.md | 11 ++++++++++- .../Changelogs/Render/GfmRenderTests.cs | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/cli/changelog/cmd-render.md b/docs/cli/changelog/cmd-render.md index d1a328296a..51f4a37973 100644 --- a/docs/cli/changelog/cmd-render.md +++ b/docs/cli/changelog/cmd-render.md @@ -14,6 +14,9 @@ The `changelog render` command does **not** use `rules.publish` for filtering. F : `--dropdowns` Render separated types (breaking changes, deprecations, known issues, highlights) as MyST dropdowns. Defaults to false (flattened bulleted lists). When used, each entry in separated files is rendered as a collapsible dropdown section using MyST syntax (`::::{dropdown}`). When it's not used, entries are rendered as flattened bulleted lists with PR/issue links inline and `Impact` and `Action` sections indented. This flag affects only markdown output; AsciiDoc output always uses its standard format. +: `--no-descriptions` + Hide changelog entry descriptions from output. Entry titles, PR/issue links, and `Impact` / `Action` sections remain visible. Bundle-level descriptions are unaffected. Applies to all output formats (markdown, asciidoc, gfm). Defaults to false. + : `--title` The title to use for section headers, directories, and anchors in output files. Defaults to the version in the first bundle. When omitted, ISO date targets are formatted for display the same way as the `{changelog}` directive (for example, `2026-05-04` becomes "May 4, 2026", `2026-05` becomes "May 2026"), while directory names and heading anchors continue to use the raw target slug. If the string contains spaces, they are replaced with dashes when used in directory names and anchors. @@ -87,7 +90,7 @@ Changelog entries can reference multiple pull requests and issues via the `prs` # Render a single bundle docs-builder changelog render \ --input "./docs/changelog/bundles/9.3.0.yaml" \ - --output ./release-notes \ + --output ./release-notes # Render with explicit changelog dir and repo docs-builder changelog render \ @@ -121,4 +124,10 @@ docs-builder changelog render \ --input "./docs/changelog/bundles/9.3.0.yaml" \ --output ./release-notes \ --file-type gfm + +# Render without entry descriptions (titles, links, Impact/Action still shown) +docs-builder changelog render \ + --input "./docs/changelog/bundles/9.3.0.yaml" \ + --output ./release-notes \ + --no-descriptions ``` diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs index e6e71524e6..7d023cfc60 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs @@ -390,6 +390,7 @@ It spans multiple lines. content.Should().Contain("Feature with description"); content.Should().Contain("This is a detailed description of the feature."); + content.Should().Contain("It spans multiple lines."); // Test with descriptions hidden var outputDir2 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); @@ -405,11 +406,13 @@ It spans multiple lines. var result2 = await Service.RenderChangelogs(Collector, inputWithoutDescriptions, TestContext.Current.CancellationToken); result2.Should().BeTrue(); + Collector.Errors.Should().Be(0); var changelogFile2 = FileSystem.Path.Join(outputDir2, "9.2.0", "changelog.md"); var content2 = await FileSystem.File.ReadAllTextAsync(changelogFile2, TestContext.Current.CancellationToken); content2.Should().Contain("Feature with description"); content2.Should().NotContain("This is a detailed description of the feature."); + content2.Should().NotContain("It spans multiple lines."); } [Fact]