From 757832e257ff608f945a89ecc021d2c82ad74f16 Mon Sep 17 00:00:00 2001 From: lcawl Date: Thu, 7 May 2026 10:27:08 -0700 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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",